Add embedded Express admin API and unified dev workflow.
Move admin backend into this repo (Express + MySQL on :8085) so npm run dev starts both API and Vite, with updated env defaults and Caddy config for gadm.tkmind.cn. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+27
-3
@@ -1,5 +1,29 @@
|
||||
# 开发模式:后端地址(默认 http://127.0.0.1:8081)
|
||||
ADM_DEV_BACKEND=http://127.0.0.1:8081
|
||||
# ── 管理端 API(本仓库 server/,默认 8085)──
|
||||
ADM_API_PORT=8085
|
||||
ADM_DEV_BACKEND=http://127.0.0.1:8085
|
||||
|
||||
# 点击"返回对话"时跳转到主 H5 的地址
|
||||
# ── MySQL(与 Memind 共用远程库,配置在本项目 .env)──
|
||||
DATABASE_URL=mysql://boot:password@host:3306/goose
|
||||
# 或分别设置:
|
||||
# MYSQL_HOST=
|
||||
# MYSQL_PORT=3306
|
||||
# MYSQL_USER=boot
|
||||
# MYSQL_PASSWORD=
|
||||
# MYSQL_DATABASE=goose
|
||||
|
||||
# 用户工作区根目录(创建用户时使用)
|
||||
H5_USERS_ROOT=/Users/john/Project/memind_adm/data/users
|
||||
|
||||
# 管理员(可选;设置后启动时同步到数据库)
|
||||
# H5_ADMIN_USERNAME=admin
|
||||
# H5_ADMIN_PASSWORD=change-me-admin
|
||||
|
||||
# LLM 配置同步(可选)
|
||||
# TKMIND_API_TARGET=https://127.0.0.1:18006
|
||||
# TKMIND_SERVER__SECRET_KEY=local-dev-secret
|
||||
|
||||
# Memind 业务模块路径(user-auth / llm-providers 等,默认 ../Memind)
|
||||
# MEMIND_LIB_ROOT=/Users/john/Project/Memind
|
||||
|
||||
# 点击「返回对话」时跳转到主 H5
|
||||
VITE_MAIN_APP_URL=http://localhost:5173
|
||||
|
||||
Generated
+989
-40
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -4,11 +4,15 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"dev:web": "vite",
|
||||
"dev:server": "node server/index.mjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"mysql2": "^3.22.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.13.1"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function spawnChild(command, args, label) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) return;
|
||||
if (code && code !== 0) shutdown(code ?? 1);
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
let server;
|
||||
let web;
|
||||
let stopping = false;
|
||||
|
||||
function shutdown(code = 0) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
server?.kill('SIGTERM');
|
||||
web?.kill('SIGTERM');
|
||||
setTimeout(() => process.exit(code), 200).unref();
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown(0));
|
||||
process.on('SIGTERM', () => shutdown(0));
|
||||
|
||||
console.log('==> 启动 Admin API (memind_adm/server)');
|
||||
server = spawnChild('node', ['server/index.mjs'], 'server');
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('==> 启动管理端 Vite @ http://localhost:5174');
|
||||
web = spawnChild('npm', ['run', 'dev:web'], 'web');
|
||||
}, 800);
|
||||
@@ -0,0 +1,10 @@
|
||||
# gadm.tkmind.cn → 本机管理端 (vite preview :5174,API 反代 :8085)
|
||||
:8091 {
|
||||
reverse_proxy 127.0.0.1:5174 {
|
||||
flush_interval -1
|
||||
transport http {
|
||||
read_timeout 0
|
||||
write_timeout 0
|
||||
}
|
||||
}
|
||||
}
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
import express from 'express';
|
||||
import { paginateArray, listUsagePaged, listLedgerPaged } from './pagination.mjs';
|
||||
|
||||
export function createAdminApp(services) {
|
||||
const { userAuth, llmProviderService, pool, ready } = services;
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
const isSecureRequest = (req) =>
|
||||
req.secure || req.get('x-forwarded-proto')?.split(',')[0]?.trim() === 'https';
|
||||
const jsonBody = express.json({ limit: '1mb' });
|
||||
|
||||
let cookieHelpers = null;
|
||||
const cookieReady = ready.then(async () => {
|
||||
const [userAuthMod, authMod] = await Promise.all([
|
||||
services.importUserAuthModule(),
|
||||
services.importAuthModule(),
|
||||
]);
|
||||
cookieHelpers = { ...userAuthMod, parseCookies: authMod.parseCookies };
|
||||
});
|
||||
|
||||
function userToken(req) {
|
||||
const { parseCookies, USER_COOKIE } = cookieHelpers;
|
||||
return parseCookies(req.get('cookie'))[USER_COOKIE];
|
||||
}
|
||||
|
||||
function setUserLoginCookies(res, req, token) {
|
||||
const { userLoginCookies, resolveCookieDomainForRequest } = cookieHelpers;
|
||||
res.set(
|
||||
'Set-Cookie',
|
||||
userLoginCookies(token, isSecureRequest(req), resolveCookieDomainForRequest(req)),
|
||||
);
|
||||
}
|
||||
|
||||
function clearUserLoginCookies(res, req) {
|
||||
const { clearUserSessionCookie, resolveCookieDomainForRequest } = cookieHelpers;
|
||||
res.set(
|
||||
'Set-Cookie',
|
||||
clearUserSessionCookie(isSecureRequest(req), resolveCookieDomainForRequest(req)),
|
||||
);
|
||||
}
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ ok: true, service: 'memind_adm' });
|
||||
});
|
||||
|
||||
app.get('/auth/status', async (req, res) => {
|
||||
await cookieReady;
|
||||
await ready;
|
||||
const me = await userAuth.getMe(userToken(req));
|
||||
if (!me) return res.json({ authenticated: false, mode: 'user' });
|
||||
return res.json({ authenticated: true, user: me, mode: 'user' });
|
||||
});
|
||||
|
||||
app.post('/auth/login', jsonBody, async (req, res) => {
|
||||
await cookieReady;
|
||||
await ready;
|
||||
const { username, password } = req.body ?? {};
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ message: '用户名和密码不能为空' });
|
||||
}
|
||||
const result = await userAuth.login({ username, password, ip: req.ip });
|
||||
if (!result.ok) {
|
||||
if (result.retryAfterMs > 0) {
|
||||
res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
|
||||
return res.status(429).json({ message: result.message });
|
||||
}
|
||||
return res.status(401).json({ message: result.message });
|
||||
}
|
||||
setUserLoginCookies(res, req, result.token);
|
||||
return res.json({ authenticated: true, user: result.user, mode: 'user' });
|
||||
});
|
||||
|
||||
app.post('/auth/logout', async (req, res) => {
|
||||
await cookieReady;
|
||||
await ready;
|
||||
await userAuth.revoke(userToken(req));
|
||||
clearUserLoginCookies(res, req);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
const adminApi = express.Router();
|
||||
adminApi.use(jsonBody);
|
||||
|
||||
adminApi.use(async (req, res, next) => {
|
||||
await cookieReady;
|
||||
await ready;
|
||||
const me = await userAuth.getMe(userToken(req));
|
||||
if (!me) return res.status(401).json({ message: '未登录' });
|
||||
req.currentUser = me;
|
||||
next();
|
||||
});
|
||||
|
||||
const requireAdmin = (req, res, next) => {
|
||||
if (!req.currentUser || req.currentUser.role !== 'admin') {
|
||||
res.status(403).json({ message: '需要管理员权限' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
adminApi.get('/users', requireAdmin, async (req, res) => {
|
||||
let users = await userAuth.listUsers();
|
||||
const search = typeof req.query.search === 'string' ? req.query.search.trim().toLowerCase() : '';
|
||||
const role = typeof req.query.role === 'string' ? req.query.role.trim() : '';
|
||||
const status = typeof req.query.status === 'string' ? req.query.status.trim() : '';
|
||||
if (search) {
|
||||
users = users.filter(
|
||||
(user) =>
|
||||
user.username?.toLowerCase().includes(search) ||
|
||||
user.displayName?.toLowerCase().includes(search) ||
|
||||
user.email?.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
if (role) users = users.filter((user) => user.role === role);
|
||||
if (status) users = users.filter((user) => user.status === status);
|
||||
const paged = paginateArray(users, req.query, 20);
|
||||
res.json({ users: paged.items, total: paged.total, page: paged.page, pageSize: paged.pageSize });
|
||||
});
|
||||
|
||||
adminApi.get('/summary', requireAdmin, async (_req, res) => {
|
||||
const summary = await userAuth.getAdminSummary();
|
||||
let llm = null;
|
||||
if (llmProviderService) {
|
||||
const keys = await llmProviderService.listKeys();
|
||||
const global = await llmProviderService.getGlobalSettings();
|
||||
const selected = keys.find((key) => key.isSelected);
|
||||
llm = {
|
||||
keyCount: keys.length,
|
||||
selectedKeyName: selected?.name ?? null,
|
||||
globalModel: global?.model ?? null,
|
||||
};
|
||||
}
|
||||
res.json({ summary: { ...summary, llm } });
|
||||
});
|
||||
|
||||
adminApi.post('/users', requireAdmin, async (req, res) => {
|
||||
const result = await userAuth.createUser(req.body ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.status(201).json({ user: result.user });
|
||||
});
|
||||
|
||||
adminApi.patch('/users/:userId', requireAdmin, async (req, res) => {
|
||||
const result = await userAuth.updateUser(req.params.userId, req.body ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json({ user: result.user });
|
||||
});
|
||||
|
||||
adminApi.post('/users/:userId/recharge', requireAdmin, async (req, res) => {
|
||||
const amountCents = Number(req.body?.amountCents);
|
||||
const note = typeof req.body?.note === 'string' ? req.body.note : '';
|
||||
const result = await userAuth.recharge(
|
||||
req.params.userId,
|
||||
amountCents,
|
||||
req.currentUser.id,
|
||||
note,
|
||||
);
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json({ user: result.user });
|
||||
});
|
||||
|
||||
adminApi.get('/usage', requireAdmin, async (req, res) => {
|
||||
const result = await listUsagePaged(pool, req.query);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/ledger', requireAdmin, async (req, res) => {
|
||||
const result = await listLedgerPaged(pool, req.query);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/capabilities/catalog', requireAdmin, (_req, res) => {
|
||||
res.json({ catalog: userAuth.capabilityCatalog });
|
||||
});
|
||||
|
||||
adminApi.get('/capabilities/role/:role', requireAdmin, async (req, res) => {
|
||||
const role = req.params.role === 'admin' ? 'admin' : 'user';
|
||||
if (role === 'admin') {
|
||||
return res.json({
|
||||
role,
|
||||
capabilities: Object.fromEntries(
|
||||
userAuth.capabilityCatalog.map((item) => [item.key, true]),
|
||||
),
|
||||
unrestricted: true,
|
||||
});
|
||||
}
|
||||
res.json(await userAuth.getRoleCapabilities('user'));
|
||||
});
|
||||
|
||||
adminApi.put('/capabilities/role/:role', requireAdmin, async (req, res) => {
|
||||
const role = req.params.role === 'admin' ? 'admin' : 'user';
|
||||
const result = await userAuth.setRoleCapabilities(role, req.body?.capabilities ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/users/:userId/capabilities', requireAdmin, async (req, res) => {
|
||||
const result = await userAuth.getUserCapabilities(req.params.userId);
|
||||
if (!result.ok) return res.status(404).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.put('/users/:userId/capabilities', requireAdmin, async (req, res) => {
|
||||
const result = await userAuth.setUserCapabilities(
|
||||
req.params.userId,
|
||||
req.body?.capabilities ?? {},
|
||||
);
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.delete('/users/:userId/capabilities', requireAdmin, async (req, res) => {
|
||||
const result = await userAuth.clearUserCapabilityOverrides(req.params.userId);
|
||||
if (!result.ok) return res.status(404).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/policies/catalog', requireAdmin, (_req, res) => {
|
||||
res.json({ catalog: userAuth.policyCatalog });
|
||||
});
|
||||
|
||||
adminApi.get('/policies/role/:role', requireAdmin, async (req, res) => {
|
||||
const role = req.params.role === 'admin' ? 'admin' : 'user';
|
||||
if (role === 'admin') {
|
||||
return res.json({ role, policies: {}, unrestricted: true });
|
||||
}
|
||||
res.json(await userAuth.getRolePolicies('user'));
|
||||
});
|
||||
|
||||
adminApi.put('/policies/role/:role', requireAdmin, async (req, res) => {
|
||||
const role = req.params.role === 'admin' ? 'admin' : 'user';
|
||||
const result = await userAuth.setRolePolicies(role, req.body?.policies ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/users/:userId/policies', requireAdmin, async (req, res) => {
|
||||
const result = await userAuth.getUserPolicies(req.params.userId);
|
||||
if (!result.ok) return res.status(404).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.put('/users/:userId/policies', requireAdmin, async (req, res) => {
|
||||
const result = await userAuth.setUserPolicies(req.params.userId, req.body?.policies ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.delete('/users/:userId/policies', requireAdmin, async (req, res) => {
|
||||
const result = await userAuth.clearUserPolicyOverrides(req.params.userId);
|
||||
if (!result.ok) return res.status(404).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/skills/catalog', requireAdmin, (_req, res) => {
|
||||
res.json({ catalog: userAuth.skillCatalog });
|
||||
});
|
||||
|
||||
adminApi.get('/skills/role/:role', requireAdmin, async (req, res) => {
|
||||
const role = req.params.role === 'admin' ? 'admin' : 'user';
|
||||
if (role === 'admin') {
|
||||
return res.json({
|
||||
role,
|
||||
skills: Object.fromEntries(userAuth.skillCatalog.map((item) => [item.name, true])),
|
||||
});
|
||||
}
|
||||
res.json(await userAuth.getRoleSkills('user'));
|
||||
});
|
||||
|
||||
adminApi.put('/skills/role/:role', requireAdmin, async (req, res) => {
|
||||
const role = req.params.role === 'admin' ? 'admin' : 'user';
|
||||
const result = await userAuth.setRoleSkills(role, req.body?.skills ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/users/:userId/skills', requireAdmin, async (req, res) => {
|
||||
const result = await userAuth.getUserSkills(req.params.userId);
|
||||
if (!result.ok) return res.status(404).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.put('/users/:userId/skills', requireAdmin, async (req, res) => {
|
||||
const result = await userAuth.setUserSkills(req.params.userId, req.body?.skills ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.delete('/users/:userId/skills', requireAdmin, async (req, res) => {
|
||||
const result = await userAuth.clearUserSkillOverrides(req.params.userId);
|
||||
if (!result.ok) return res.status(404).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
res.json({ catalog: llmProviderService.catalog });
|
||||
});
|
||||
|
||||
adminApi.get('/llm-providers/keys', requireAdmin, async (_req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
res.json({ keys: await llmProviderService.listKeys() });
|
||||
});
|
||||
|
||||
adminApi.post('/llm-providers/keys', requireAdmin, async (req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
const result = await llmProviderService.createKey(req.body ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.status(201).json({ key: result.key });
|
||||
});
|
||||
|
||||
adminApi.patch('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
const result = await llmProviderService.updateKey(req.params.keyId, req.body ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json({ key: result.key });
|
||||
});
|
||||
|
||||
adminApi.post('/llm-providers/keys/:keyId/select', requireAdmin, async (req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
const result = await llmProviderService.selectKey(req.params.keyId);
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json({ key: result.key });
|
||||
});
|
||||
|
||||
adminApi.delete('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
const result = await llmProviderService.deleteKey(req.params.keyId);
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
adminApi.post('/llm-providers/sync', requireAdmin, async (_req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
try {
|
||||
res.json(await llmProviderService.syncSelectedToGoosed());
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: err instanceof Error ? err.message : '同步失败' });
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.get('/llm-providers/global', requireAdmin, async (_req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
res.json({ global: await llmProviderService.getGlobalSettings() });
|
||||
});
|
||||
|
||||
adminApi.put('/llm-providers/global', requireAdmin, async (req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
const result = await llmProviderService.setGlobalModel(req.body?.model);
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.post('/llm-providers/test', requireAdmin, async (req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
try {
|
||||
res.json(await llmProviderService.testDraft(req.body ?? {}));
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, message: err instanceof Error ? err.message : '联通测试失败' });
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.post('/llm-providers/keys/:keyId/test', requireAdmin, async (req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
try {
|
||||
res.json(await llmProviderService.testKey(req.params.keyId, req.body?.model));
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, message: err instanceof Error ? err.message : '联通测试失败' });
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/admin-api', adminApi);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import path from 'node:path';
|
||||
import { createDbPool, isDatabaseConfigured } from './db.mjs';
|
||||
import { importMemind, resolveMemindLib } from './lib-path.mjs';
|
||||
import { projectRoot } from './load-env.mjs';
|
||||
|
||||
export async function bootstrapAdminServices() {
|
||||
if (!isDatabaseConfigured()) {
|
||||
throw new Error('数据库未配置');
|
||||
}
|
||||
|
||||
const memindLib = resolveMemindLib();
|
||||
const pool = createDbPool();
|
||||
const { createUserAuth } = await importMemind('user-auth.mjs');
|
||||
const { createLlmProviderService } = await importMemind('llm-providers.mjs');
|
||||
|
||||
const usersRoot =
|
||||
process.env.H5_USERS_ROOT?.trim() ?? path.join(projectRoot, 'data', 'users');
|
||||
const apiTarget = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006';
|
||||
const apiSecret = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
|
||||
|
||||
const userAuth = createUserAuth(pool, {
|
||||
usersRoot,
|
||||
h5Root: memindLib,
|
||||
defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500),
|
||||
});
|
||||
|
||||
await userAuth.ensureAdminUser();
|
||||
|
||||
const llmProviderService = createLlmProviderService(pool, {
|
||||
apiTarget,
|
||||
apiSecret,
|
||||
});
|
||||
|
||||
console.log(`Admin DB connected (${process.env.MYSQL_DATABASE ?? 'via DATABASE_URL'})`);
|
||||
console.log(`Users root: ${usersRoot}`);
|
||||
console.log(`Memind lib: ${memindLib}`);
|
||||
|
||||
return { pool, userAuth, llmProviderService };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
export function isDatabaseConfigured() {
|
||||
return Boolean(
|
||||
process.env.DATABASE_URL ||
|
||||
(process.env.MYSQL_HOST && process.env.MYSQL_DATABASE),
|
||||
);
|
||||
}
|
||||
|
||||
export function createDbPool() {
|
||||
if (!isDatabaseConfigured()) {
|
||||
throw new Error('MySQL 未配置,请在 .env 设置 DATABASE_URL 或 MYSQL_*');
|
||||
}
|
||||
|
||||
if (process.env.DATABASE_URL) {
|
||||
return mysql.createPool(process.env.DATABASE_URL);
|
||||
}
|
||||
|
||||
return mysql.createPool({
|
||||
host: process.env.MYSQL_HOST ?? 'localhost',
|
||||
port: Number(process.env.MYSQL_PORT ?? 3306),
|
||||
user: process.env.MYSQL_USER ?? 'boot',
|
||||
password: process.env.MYSQL_PASSWORD ?? '',
|
||||
database: process.env.MYSQL_DATABASE ?? 'tkmind',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { loadProjectEnv } from './load-env.mjs';
|
||||
import { bootstrapAdminServices } from './bootstrap.mjs';
|
||||
import { importMemind } from './lib-path.mjs';
|
||||
import { createAdminApp } from './app.mjs';
|
||||
|
||||
loadProjectEnv();
|
||||
|
||||
const port = Number(process.env.ADM_API_PORT ?? 8085);
|
||||
|
||||
const ready = bootstrapAdminServices();
|
||||
const services = {
|
||||
ready,
|
||||
importUserAuthModule: () => importMemind('user-auth.mjs'),
|
||||
importAuthModule: () => importMemind('auth.mjs'),
|
||||
};
|
||||
|
||||
ready
|
||||
.then(({ pool, userAuth, llmProviderService }) => {
|
||||
Object.assign(services, { pool, userAuth, llmProviderService });
|
||||
const app = createAdminApp(services);
|
||||
app.listen(port, '127.0.0.1', () => {
|
||||
console.log(`TKMind Admin API @ http://127.0.0.1:${port}`);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Admin server bootstrap failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { projectRoot } from './load-env.mjs';
|
||||
|
||||
export function resolveMemindLib() {
|
||||
const candidates = [
|
||||
process.env.MEMIND_LIB_ROOT?.trim(),
|
||||
path.join(projectRoot, '../Memind'),
|
||||
path.join(projectRoot, '../tkmind_go/ui/h5'),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const dir of candidates) {
|
||||
if (fs.existsSync(path.join(dir, 'user-auth.mjs'))) {
|
||||
return path.resolve(dir);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'未找到 Memind 业务模块目录(user-auth.mjs)。请设置 MEMIND_LIB_ROOT 或保持 ../Memind 存在。',
|
||||
);
|
||||
}
|
||||
|
||||
export async function importMemind(subpath) {
|
||||
const libRoot = resolveMemindLib();
|
||||
const modulePath = path.join(libRoot, subpath);
|
||||
return import(pathToFileURL(modulePath).href);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
export function loadProjectEnv() {
|
||||
for (const file of ['.env', '.env.local']) {
|
||||
const filePath = path.join(projectRoot, file);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
export { projectRoot };
|
||||
@@ -0,0 +1,104 @@
|
||||
export function parsePageQuery(query, defaultPageSize = 20) {
|
||||
const page = Math.max(1, Number(query.page) || 1);
|
||||
const pageSize = Math.min(Math.max(Number(query.pageSize) || defaultPageSize, 1), 200);
|
||||
const offset = (page - 1) * pageSize;
|
||||
return { page, pageSize, offset };
|
||||
}
|
||||
|
||||
export function paginateArray(items, query, defaultPageSize = 20) {
|
||||
const { page, pageSize, offset } = parsePageQuery(query, defaultPageSize);
|
||||
const total = items.length;
|
||||
return {
|
||||
items: items.slice(offset, offset + pageSize),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listUsagePaged(pool, query) {
|
||||
const { page, pageSize, offset } = parsePageQuery(query, 20);
|
||||
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
|
||||
const params = [];
|
||||
let where = '';
|
||||
if (userId) {
|
||||
where = 'WHERE r.user_id = ?';
|
||||
params.push(userId);
|
||||
}
|
||||
const [[{ total }]] = await pool.query(
|
||||
`SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`,
|
||||
params,
|
||||
);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
|
||||
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
|
||||
FROM h5_usage_records r
|
||||
JOIN h5_users u ON u.id = r.user_id
|
||||
${where}
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset],
|
||||
);
|
||||
return {
|
||||
records: rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
userId: row.user_id,
|
||||
username: row.username,
|
||||
agentSessionId: row.agent_session_id,
|
||||
requestId: row.request_id,
|
||||
inputTokens: Number(row.input_tokens),
|
||||
outputTokens: Number(row.output_tokens),
|
||||
costCents: Number(row.cost_cents),
|
||||
balanceAfterCents: Number(row.balance_after_cents),
|
||||
createdAt: Number(row.created_at),
|
||||
})),
|
||||
total: Number(total),
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(Number(total) / pageSize)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLedgerPaged(pool, query) {
|
||||
const { page, pageSize, offset } = parsePageQuery(query, 20);
|
||||
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
|
||||
const params = [];
|
||||
const clauses = [];
|
||||
if (userId) {
|
||||
clauses.push('l.user_id = ?');
|
||||
params.push(userId);
|
||||
}
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const [[{ total }]] = await pool.query(
|
||||
`SELECT COUNT(*) AS total FROM h5_billing_ledger l ${where}`,
|
||||
params,
|
||||
);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens,
|
||||
l.session_id, l.note, l.created_at
|
||||
FROM h5_billing_ledger l
|
||||
JOIN h5_users u ON u.id = l.user_id
|
||||
${where}
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset],
|
||||
);
|
||||
return {
|
||||
entries: rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
userId: row.user_id,
|
||||
username: row.username,
|
||||
type: row.type,
|
||||
amountCents: Number(row.amount_cents),
|
||||
tokens: Number(row.tokens),
|
||||
sessionId: row.session_id,
|
||||
note: row.note,
|
||||
createdAt: Number(row.created_at),
|
||||
})),
|
||||
total: Number(total),
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(Number(total) / pageSize)),
|
||||
};
|
||||
}
|
||||
+3
-1
@@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const backendTarget = env.ADM_DEV_BACKEND ?? 'http://127.0.0.1:8081';
|
||||
const backendTarget = env.ADM_DEV_BACKEND ?? 'http://127.0.0.1:8085';
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
@@ -11,6 +11,7 @@ export default defineConfig(({ mode }) => {
|
||||
host: true,
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
allowedHosts: ['gadm.tkmind.cn', 'localhost', '127.0.0.1'],
|
||||
proxy: {
|
||||
'/api': backendTarget,
|
||||
'/auth': backendTarget,
|
||||
@@ -21,6 +22,7 @@ export default defineConfig(({ mode }) => {
|
||||
host: '127.0.0.1',
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
allowedHosts: ['gadm.tkmind.cn', 'localhost', '127.0.0.1'],
|
||||
proxy: {
|
||||
'/api': backendTarget,
|
||||
'/auth': backendTarget,
|
||||
|
||||
Reference in New Issue
Block a user