chore: checkpoint admin restore state
This commit is contained in:
+354
-2
@@ -1,6 +1,75 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import express from 'express';
|
||||
import { listUsagePaged, listLedgerPaged } from './pagination.mjs';
|
||||
|
||||
const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const RESTART_ACTIONS = {
|
||||
local_restart: {
|
||||
label: 'local_restart',
|
||||
script: path.join(projectRoot, 'scripts', 'local_restart.sh'),
|
||||
logFile: path.join(projectRoot, 'adm-local-restart.log'),
|
||||
detached: true,
|
||||
},
|
||||
pro_restart: {
|
||||
label: 'pro_restart',
|
||||
script: path.join(projectRoot, 'scripts', 'pro_restart.sh'),
|
||||
logFile: path.join(projectRoot, 'adm-pro-restart.log'),
|
||||
detached: false,
|
||||
},
|
||||
};
|
||||
|
||||
function isRestartAction(value) {
|
||||
return value === 'local_restart' || value === 'pro_restart';
|
||||
}
|
||||
|
||||
function spawnDetachedScript({ script, logFile, delaySeconds = 0 }) {
|
||||
const stdout = fs.openSync(logFile, 'a');
|
||||
const stderr = fs.openSync(logFile, 'a');
|
||||
const command = delaySeconds > 0 ? `sleep ${delaySeconds}; exec "${script}"` : `exec "${script}"`;
|
||||
const child = spawn('bash', ['-lc', command], {
|
||||
cwd: projectRoot,
|
||||
detached: true,
|
||||
stdio: ['ignore', stdout, stderr],
|
||||
env: process.env,
|
||||
});
|
||||
child.unref();
|
||||
return child.pid ?? null;
|
||||
}
|
||||
|
||||
function runManagedScript({ script, logFile }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stdout = fs.openSync(logFile, 'a');
|
||||
const stderr = fs.openSync(logFile, 'a');
|
||||
let combined = '';
|
||||
const child = spawn('bash', [script], {
|
||||
cwd: projectRoot,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
combined += text;
|
||||
fs.writeSync(stdout, text);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
combined += text;
|
||||
fs.writeSync(stderr, text);
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
fs.closeSync(stdout);
|
||||
fs.closeSync(stderr);
|
||||
resolve({ code: code ?? 0, output: combined.trim() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function asyncHandler(handler) {
|
||||
return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next);
|
||||
}
|
||||
@@ -19,7 +88,19 @@ function wrapRouterAsync(router) {
|
||||
}
|
||||
|
||||
export function createAdminApp(services) {
|
||||
const { userAuth, llmProviderService, pool, ready, wechatAdmin } = services;
|
||||
const {
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
pool,
|
||||
ready,
|
||||
wechatAdmin,
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
planSyncService,
|
||||
} = services;
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
@@ -142,6 +223,12 @@ export function createAdminApp(services) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/users/:userId', requireAdmin, async (req, res) => {
|
||||
const user = await userAuth.getUserPublic(req.params.userId);
|
||||
if (!user) return res.status(404).json({ message: '用户不存在' });
|
||||
res.json({ user });
|
||||
});
|
||||
|
||||
adminApi.get('/summary', requireAdmin, async (_req, res) => {
|
||||
const summary = await userAuth.getAdminSummary();
|
||||
let llm = null;
|
||||
@@ -198,6 +285,22 @@ export function createAdminApp(services) {
|
||||
res.json(await wechatAdmin.getSummary());
|
||||
});
|
||||
|
||||
adminApi.get('/mindspace/config', requireAdmin, async (_req, res) => {
|
||||
if (!loadMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' });
|
||||
const config = await loadMindSpaceConfig(pool);
|
||||
res.json({
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
adminApi.patch('/mindspace/config', requireAdmin, async (req, res) => {
|
||||
if (!updateMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' });
|
||||
const result = await updateMindSpaceConfig(pool, {
|
||||
publicPageLimit: req.body?.publicPageLimit,
|
||||
});
|
||||
res.json({ config: result });
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/bindings', requireAdmin, async (req, res) => {
|
||||
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
|
||||
res.json(await wechatAdmin.listBindings(req.query));
|
||||
@@ -218,6 +321,18 @@ export function createAdminApp(services) {
|
||||
res.json(await wechatAdmin.listDeliveries(req.query));
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/web-notifications', requireAdmin, async (req, res) => {
|
||||
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
|
||||
res.json(await wechatAdmin.listWebNotifications(req.query));
|
||||
});
|
||||
|
||||
adminApi.post('/wechat/web-notifications', requireAdmin, async (req, res) => {
|
||||
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
|
||||
const result = await wechatAdmin.createWebNotification(req.body ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message ?? '发送失败' });
|
||||
res.status(201).json(result);
|
||||
});
|
||||
|
||||
adminApi.post('/wechat/users/:userId/route/clear', requireAdmin, async (req, res) => {
|
||||
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
|
||||
const result = await wechatAdmin.clearRouteForUser(req.params.userId);
|
||||
@@ -364,7 +479,10 @@ export function createAdminApp(services) {
|
||||
|
||||
adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
res.json({ catalog: llmProviderService.catalog });
|
||||
res.json({
|
||||
catalog: llmProviderService.catalog,
|
||||
executors: llmProviderService.executorCatalog ?? [],
|
||||
});
|
||||
});
|
||||
|
||||
adminApi.get('/llm-providers/keys', requireAdmin, async (_req, res) => {
|
||||
@@ -414,6 +532,122 @@ export function createAdminApp(services) {
|
||||
res.json({ global: await llmProviderService.getGlobalSettings() });
|
||||
});
|
||||
|
||||
adminApi.get('/llm-providers/vision', requireAdmin, async (_req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
res.json({ vision: await llmProviderService.getVisionSettings() });
|
||||
});
|
||||
|
||||
adminApi.put('/llm-providers/vision', requireAdmin, async (req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
const result = await llmProviderService.setVisionKey(req.body?.keyId, req.body?.model);
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.delete('/llm-providers/vision', requireAdmin, async (_req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
const result = await llmProviderService.clearVisionKey();
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/llm-providers/executor-bindings', requireAdmin, async (_req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
res.json({ bindings: await llmProviderService.listExecutorBindings() });
|
||||
});
|
||||
|
||||
adminApi.put('/llm-providers/executor-bindings/:executor', requireAdmin, async (req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
const result = await llmProviderService.setExecutorBinding(req.params.executor, req.body ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/llm-providers/executor-runtime', requireAdmin, async (_req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
res.json({ runtimes: await llmProviderService.listExecutorRuntimeConfigs() });
|
||||
});
|
||||
|
||||
adminApi.get('/llm-providers/executor-launch-plan', requireAdmin, async (req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
const plans = await llmProviderService.listExecutorLaunchPlans({
|
||||
purpose: typeof req.query.purpose === 'string' ? req.query.purpose : 'default',
|
||||
mode: typeof req.query.mode === 'string' ? req.query.mode : 'serve',
|
||||
cwd: typeof req.query.cwd === 'string' ? req.query.cwd : undefined,
|
||||
instruction: typeof req.query.instruction === 'string' ? req.query.instruction : '',
|
||||
includeSecret: false,
|
||||
});
|
||||
res.json({ plans });
|
||||
});
|
||||
|
||||
adminApi.get('/llm-providers/executor-launch-status', requireAdmin, async (req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
const launches = await llmProviderService.listExecutorLaunchStates({
|
||||
purpose: typeof req.query.purpose === 'string' ? req.query.purpose : 'default',
|
||||
});
|
||||
res.json({ launches });
|
||||
});
|
||||
|
||||
adminApi.post('/llm-providers/executor-launch/:executor', requireAdmin, async (req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
const result = await llmProviderService.launchExecutor(req.params.executor, req.body ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message, launch: result });
|
||||
res.json({ launch: result.launch ?? result });
|
||||
});
|
||||
|
||||
adminApi.post('/llm-providers/executor-stop/:executor', requireAdmin, async (req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
const result = await llmProviderService.stopExecutor(req.params.executor, req.body ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json({ launch: result.launch });
|
||||
});
|
||||
|
||||
adminApi.post('/llm-providers/executor-restart/:executor', requireAdmin, async (req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
const result = await llmProviderService.restartExecutor(req.params.executor, req.body ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message, launch: result });
|
||||
res.json({ launch: result.launch ?? result });
|
||||
});
|
||||
|
||||
adminApi.post('/service-restart/:action', requireAdmin, async (req, res) => {
|
||||
const action = req.params.action;
|
||||
if (!isRestartAction(action)) {
|
||||
return res.status(400).json({ message: '仅支持 local_restart 或 pro_restart' });
|
||||
}
|
||||
const target = RESTART_ACTIONS[action];
|
||||
if (!fs.existsSync(target.script)) {
|
||||
return res.status(500).json({ message: `${target.label} 脚本不存在: ${target.script}` });
|
||||
}
|
||||
|
||||
if (target.detached) {
|
||||
const pid = spawnDetachedScript({ script: target.script, logFile: target.logFile, delaySeconds: 1 });
|
||||
return res.status(202).json({
|
||||
ok: true,
|
||||
action,
|
||||
message: '已触发本机重启,当前页面会在几秒内短暂断开,请稍后刷新。',
|
||||
pid,
|
||||
logFile: target.logFile,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await runManagedScript({ script: target.script, logFile: target.logFile });
|
||||
if (result.code !== 0) {
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
action,
|
||||
message: `${action} 执行失败`,
|
||||
output: result.output,
|
||||
logFile: target.logFile,
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
ok: true,
|
||||
action,
|
||||
message: '远程服务已重启',
|
||||
output: result.output,
|
||||
logFile: target.logFile,
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -439,6 +673,124 @@ export function createAdminApp(services) {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Blocked words ─────────────────────────────────────────────────────────
|
||||
|
||||
adminApi.get('/blocked-words', requireAdmin, async (_req, res) => {
|
||||
if (!wordFilterService) return res.status(503).json({ message: '词语过滤未启用' });
|
||||
const words = await wordFilterService.listBlockedWords();
|
||||
res.json({ words });
|
||||
});
|
||||
|
||||
adminApi.post('/blocked-words', requireAdmin, async (req, res) => {
|
||||
if (!wordFilterService) return res.status(503).json({ message: '词语过滤未启用' });
|
||||
const result = await wordFilterService.createBlockedWord(req.body ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.status(201).json({ blockedWord: result.blockedWord });
|
||||
});
|
||||
|
||||
adminApi.patch('/blocked-words/:id', requireAdmin, async (req, res) => {
|
||||
if (!wordFilterService) return res.status(503).json({ message: '词语过滤未启用' });
|
||||
const result = await wordFilterService.updateBlockedWord(req.params.id, req.body ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json({ blockedWord: result.blockedWord });
|
||||
});
|
||||
|
||||
adminApi.delete('/blocked-words/:id', requireAdmin, async (req, res) => {
|
||||
if (!wordFilterService) return res.status(503).json({ message: '词语过滤未启用' });
|
||||
const result = await wordFilterService.deleteBlockedWord(req.params.id);
|
||||
if (!result.ok) return res.status(404).json({ message: result.message });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Subscription Plan Catalog ───────────────────────────────────────────────
|
||||
|
||||
adminApi.get('/subscriptions/plans', requireAdmin, async (_req, res) => {
|
||||
if (!planCatalogService) return res.status(503).json({ message: '套餐服务未启用' });
|
||||
const plans = await planCatalogService.listPlans({ includeInactive: true });
|
||||
res.json({ plans });
|
||||
});
|
||||
|
||||
const shouldSkipPlanSync = (req) => req.get('x-plan-sync-hop') === '1';
|
||||
|
||||
adminApi.post('/subscriptions/plans', requireAdmin, async (req, res) => {
|
||||
if (!planCatalogService) return res.status(503).json({ message: '套餐服务未启用' });
|
||||
const { planType, ...data } = req.body ?? {};
|
||||
if (!planType) return res.status(400).json({ message: '缺少 planType' });
|
||||
const result = await planCatalogService.upsertPlan(planType, data);
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
const sync = shouldSkipPlanSync(req) ? undefined : await planSyncService?.syncPlanUpsert(planType, result.plan);
|
||||
res.status(201).json({ plan: result.plan, sync });
|
||||
});
|
||||
|
||||
adminApi.put('/subscriptions/plans/:planType', requireAdmin, async (req, res) => {
|
||||
if (!planCatalogService) return res.status(503).json({ message: '套餐服务未启用' });
|
||||
const result = await planCatalogService.upsertPlan(req.params.planType, req.body ?? {});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
const sync = shouldSkipPlanSync(req) ? undefined : await planSyncService?.syncPlanUpsert(req.params.planType, result.plan);
|
||||
res.json({ plan: result.plan, sync });
|
||||
});
|
||||
|
||||
adminApi.delete('/subscriptions/plans/:planType', requireAdmin, async (req, res) => {
|
||||
if (!planCatalogService) return res.status(503).json({ message: '套餐服务未启用' });
|
||||
const result = await planCatalogService.deletePlan(req.params.planType);
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
const sync = shouldSkipPlanSync(req) ? undefined : await planSyncService?.syncPlanDelete(req.params.planType);
|
||||
res.json({ ok: true, sync });
|
||||
});
|
||||
|
||||
adminApi.post('/subscriptions/plans/sync-production', requireAdmin, async (_req, res) => {
|
||||
if (!planCatalogService) return res.status(503).json({ message: '套餐服务未启用' });
|
||||
if (!planSyncService?.enabled) {
|
||||
return res.status(503).json({ message: planSyncService?.reason || '未配置生产套餐同步' });
|
||||
}
|
||||
const plans = await planCatalogService.listPlans({ includeInactive: true });
|
||||
const sync = await planSyncService.syncAllPlans(plans);
|
||||
res.json({ sync });
|
||||
});
|
||||
|
||||
// ── Subscription Management ─────────────────────────────────────────────────
|
||||
|
||||
adminApi.get('/subscriptions', requireAdmin, async (req, res) => {
|
||||
if (!subscriptionService) return res.status(503).json({ message: '套餐服务未启用' });
|
||||
const result = await subscriptionService.listSubscriptions({
|
||||
userId: req.query.userId || null,
|
||||
status: req.query.status || null,
|
||||
page: Number(req.query.page) || 1,
|
||||
pageSize: Math.min(Number(req.query.pageSize) || 20, 100),
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/users/:userId/subscription', requireAdmin, async (req, res) => {
|
||||
if (!subscriptionService) return res.status(503).json({ message: '套餐服务未启用' });
|
||||
const subscription = await subscriptionService.getActiveSubscription(req.params.userId);
|
||||
res.json({ subscription });
|
||||
});
|
||||
|
||||
adminApi.post('/users/:userId/subscription', requireAdmin, async (req, res) => {
|
||||
if (!subscriptionService) return res.status(503).json({ message: '套餐服务未启用' });
|
||||
const { planType, durationDays, note } = req.body ?? {};
|
||||
if (!planType) return res.status(400).json({ message: '缺少 planType' });
|
||||
const result = await subscriptionService.grantSubscription(
|
||||
req.params.userId,
|
||||
planType,
|
||||
durationDays ? Number(durationDays) : undefined,
|
||||
req.currentUser.id,
|
||||
note || '',
|
||||
);
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.status(201).json({ subscription: result.subscription });
|
||||
});
|
||||
|
||||
adminApi.delete('/users/:userId/subscription', requireAdmin, async (req, res) => {
|
||||
if (!subscriptionService) return res.status(503).json({ message: '套餐服务未启用' });
|
||||
const result = await subscriptionService.cancelSubscription(
|
||||
req.params.userId,
|
||||
req.currentUser.id,
|
||||
);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.use('/admin-api', adminApi);
|
||||
|
||||
if (services.createOpsApi) {
|
||||
|
||||
+70
-8
@@ -1,7 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import { createDbPool, isDatabaseConfigured } from './db.mjs';
|
||||
import { projectRoot } from './load-env.mjs';
|
||||
import { createLocalUserAuth } from './local-auth.mjs';
|
||||
import { importMemind, resolveMemindLib } from './lib-path.mjs';
|
||||
|
||||
export async function bootstrapAdminServices() {
|
||||
@@ -10,16 +9,38 @@ export async function bootstrapAdminServices() {
|
||||
}
|
||||
|
||||
const memindLib = resolveMemindLib();
|
||||
const h5Root =
|
||||
process.env.MEMIND_LIB_ROOT?.trim() ??
|
||||
path.join(projectRoot, '../Memind');
|
||||
const pool = createDbPool();
|
||||
const { createLlmProviderService } = await importMemind('llm-providers.mjs');
|
||||
const { loadCreateLlmProviderService } = await import('./llm-provider-loader.mjs');
|
||||
const createLlmProviderService = await loadCreateLlmProviderService();
|
||||
const { createWechatAdminService } = await importMemind('wechat-admin.mjs');
|
||||
const { loadWechatMpConfig } = await importMemind('wechat-mp.mjs');
|
||||
const { createWechatMpService, loadWechatMpConfig } = await importMemind('wechat-mp.mjs');
|
||||
const { createPlazaPostService, formatPostRow } = await importMemind('plaza-posts.mjs');
|
||||
const { createPlazaInteractionService } = await importMemind('plaza-interactions.mjs');
|
||||
const { createPlazaOpsService } = await importMemind('plaza-ops.mjs');
|
||||
const { createNoopPlazaRedis } = await importMemind('plaza-redis.mjs');
|
||||
const { ensureAlgorithmConfig, loadAlgorithmConfig } = await importMemind('plaza-algorithm.mjs');
|
||||
const {
|
||||
ensureMindSpaceConfig,
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
} = await importMemind('mindspace-config.mjs');
|
||||
const { createOpsApi } = await importMemind('admin-routes.mjs');
|
||||
const { createWordFilterService, ensureWordFilterSchema } = await importMemind('word-filter.mjs');
|
||||
const {
|
||||
USER_COOKIE,
|
||||
clearUserSessionCookie,
|
||||
createUserAuth,
|
||||
resolveCookieDomainForRequest,
|
||||
userLoginCookies,
|
||||
} = await importMemind('user-auth.mjs');
|
||||
const {
|
||||
ensurePlanCatalogSchema,
|
||||
createPlanCatalogService,
|
||||
createSubscriptionService,
|
||||
} = await importMemind('billing-subscription.mjs');
|
||||
|
||||
const usersRoot =
|
||||
process.env.H5_USERS_ROOT?.trim() ?? path.join(projectRoot, 'data', 'users');
|
||||
@@ -29,6 +50,8 @@ export async function bootstrapAdminServices() {
|
||||
|
||||
await ensureAlgorithmConfig(pool);
|
||||
const algorithmConfig = await loadAlgorithmConfig(pool);
|
||||
await ensureMindSpaceConfig(pool, { env: process.env, seedDefault: false });
|
||||
const mindSpaceConfig = await loadMindSpaceConfig(pool, { env: process.env });
|
||||
const plazaInteractions = createPlazaInteractionService(pool, { formatPostRow, plazaRedis });
|
||||
|
||||
let plazaOps = null;
|
||||
@@ -49,23 +72,62 @@ export async function bootstrapAdminServices() {
|
||||
invalidateFeedCaches: () => plazaRedis?.invalidateFeedCaches?.(),
|
||||
});
|
||||
|
||||
const userAuth = createLocalUserAuth(pool);
|
||||
|
||||
await userAuth.ensureAdminUser();
|
||||
const userAuth = createUserAuth(pool, {
|
||||
usersRoot,
|
||||
h5Root,
|
||||
defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500),
|
||||
});
|
||||
|
||||
const llmProviderService = createLlmProviderService(pool, {
|
||||
apiTarget,
|
||||
apiSecret,
|
||||
});
|
||||
const wechatMpConfig = loadWechatMpConfig();
|
||||
const wechatMpService = createWechatMpService({
|
||||
config: wechatMpConfig,
|
||||
userAuth,
|
||||
apiFetch: async () => {
|
||||
throw new Error('admin api does not proxy WeChat chat sessions');
|
||||
},
|
||||
});
|
||||
const wechatAdmin = createWechatAdminService(pool, {
|
||||
config: loadWechatMpConfig(),
|
||||
config: wechatMpConfig,
|
||||
scheduleEnabled: process.env.H5_SCHEDULE_ENABLED === '1',
|
||||
reminderWorkerEnabled: process.env.H5_REMINDER_WORKER_ENABLED === '1',
|
||||
sendWechatTextToUser: wechatMpService?.enabled
|
||||
? (userId, text) => wechatMpService.sendTextToUser(userId, text)
|
||||
: null,
|
||||
});
|
||||
|
||||
await ensureWordFilterSchema(pool);
|
||||
const wordFilterService = createWordFilterService(pool);
|
||||
|
||||
await ensurePlanCatalogSchema(pool);
|
||||
const planCatalogService = createPlanCatalogService(pool);
|
||||
const subscriptionService = createSubscriptionService(pool, {
|
||||
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
|
||||
});
|
||||
|
||||
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, plazaOps, createOpsApi, wechatAdmin };
|
||||
return {
|
||||
pool,
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
mindSpaceConfig,
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
USER_COOKIE,
|
||||
userLoginCookies,
|
||||
clearUserSessionCookie,
|
||||
resolveCookieDomainForRequest,
|
||||
};
|
||||
}
|
||||
|
||||
+133
-15
@@ -1,33 +1,117 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { ensureArgon2Sync } from './argon2-polyfill.mjs';
|
||||
import { loadProjectEnv } from './load-env.mjs';
|
||||
import {
|
||||
USER_COOKIE,
|
||||
clearUserSessionCookie,
|
||||
parseCookies,
|
||||
resolveCookieDomainForRequest,
|
||||
userLoginCookies,
|
||||
} from './local-auth.mjs';
|
||||
import { parseCookies } from './local-auth.mjs';
|
||||
import { createPlanSyncService } from './plan-sync.mjs';
|
||||
|
||||
ensureArgon2Sync();
|
||||
import { bootstrapAdminServices } from './bootstrap.mjs';
|
||||
import { createAdminApp } from './app.mjs';
|
||||
|
||||
loadProjectEnv();
|
||||
const projectRoot = loadProjectEnv();
|
||||
const pidFile = path.join(projectRoot, '.adm-api.pid');
|
||||
|
||||
const port = Number(process.env.ADM_API_PORT ?? 8085);
|
||||
|
||||
function readPidFile() {
|
||||
try {
|
||||
const raw = fs.readFileSync(pidFile, 'utf8').trim();
|
||||
if (!raw) return null;
|
||||
const pid = Number(raw);
|
||||
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupStalePidFile() {
|
||||
const existingPid = readPidFile();
|
||||
if (!existingPid || existingPid === process.pid) return;
|
||||
if (isProcessAlive(existingPid)) return;
|
||||
try {
|
||||
fs.unlinkSync(pidFile);
|
||||
console.warn(`Removed stale PID file ${pidFile} (pid ${existingPid})`);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function writePidFile() {
|
||||
try {
|
||||
fs.writeFileSync(pidFile, `${process.pid}\n`, 'utf8');
|
||||
} catch (err) {
|
||||
console.warn(`Failed to write PID file ${pidFile}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
function removePidFile() {
|
||||
const existingPid = readPidFile();
|
||||
if (existingPid !== process.pid) return;
|
||||
try {
|
||||
fs.unlinkSync(pidFile);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function lookupPortOwner(listenPort) {
|
||||
try {
|
||||
const out = execFileSync('lsof', ['-nP', '-iTCP:' + listenPort, '-sTCP:LISTEN', '-Fpc'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
let pid = null;
|
||||
let command = null;
|
||||
for (const line of out.split('\n')) {
|
||||
if (line.startsWith('p')) pid = line.slice(1);
|
||||
if (line.startsWith('c')) command = line.slice(1);
|
||||
if (pid && command) break;
|
||||
}
|
||||
if (!pid) return null;
|
||||
return command ? `${command} (pid ${pid})` : `pid ${pid}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
cleanupStalePidFile();
|
||||
|
||||
const ready = bootstrapAdminServices();
|
||||
const planSyncService = createPlanSyncService(console);
|
||||
const services = {
|
||||
ready,
|
||||
USER_COOKIE,
|
||||
parseCookies,
|
||||
userLoginCookies,
|
||||
clearUserSessionCookie,
|
||||
resolveCookieDomainForRequest,
|
||||
planSyncService,
|
||||
};
|
||||
|
||||
ready
|
||||
.then(({ pool, userAuth, llmProviderService, plazaOps, createOpsApi, wechatAdmin }) => {
|
||||
.then((bootstrapped) => {
|
||||
const {
|
||||
pool,
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
planSyncService,
|
||||
USER_COOKIE,
|
||||
userLoginCookies,
|
||||
clearUserSessionCookie,
|
||||
resolveCookieDomainForRequest,
|
||||
} = bootstrapped;
|
||||
Object.assign(services, {
|
||||
pool,
|
||||
userAuth,
|
||||
@@ -35,10 +119,44 @@ ready
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
planSyncService,
|
||||
USER_COOKIE,
|
||||
userLoginCookies,
|
||||
clearUserSessionCookie,
|
||||
resolveCookieDomainForRequest,
|
||||
});
|
||||
const app = createAdminApp(services);
|
||||
const server = app.listen(port, '127.0.0.1', () => {
|
||||
console.log(`TKMind Admin API @ http://127.0.0.1:${port}`);
|
||||
const host = process.env.ADM_API_HOST?.trim() || '127.0.0.1';
|
||||
const server = app.listen(port, host, () => {
|
||||
writePidFile();
|
||||
console.log(`TKMind Admin API @ http://${host}:${port}`);
|
||||
});
|
||||
server.on('close', removePidFile);
|
||||
server.on('error', (err) => {
|
||||
if (err?.code === 'EADDRINUSE') {
|
||||
const owner = lookupPortOwner(port);
|
||||
console.error(
|
||||
`Admin API 无法启动:${host}:${port} 已被占用${owner ? `,当前占用者是 ${owner}` : ''}。`,
|
||||
);
|
||||
console.error(`如需重启,可先停止旧进程,或修改 .env 中的 ADM_API_PORT。`);
|
||||
} else {
|
||||
console.error('Admin API listen failed:', err);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('exit', removePidFile);
|
||||
process.on('SIGINT', () => {
|
||||
removePidFile();
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
removePidFile();
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
globalThis.__tkmindAdminServer = server;
|
||||
})
|
||||
|
||||
+5
-1
@@ -4,8 +4,12 @@ import { pathToFileURL } from 'node:url';
|
||||
import { projectRoot } from './load-env.mjs';
|
||||
|
||||
export function resolveMemindLib() {
|
||||
const bundled = path.join(projectRoot, 'memind-lib');
|
||||
const candidates = [
|
||||
fs.existsSync(path.join(bundled, 'user-auth.mjs')) ? bundled : null,
|
||||
process.env.MEMIND_LIB_ROOT?.trim(),
|
||||
path.join(projectRoot, '../test-memind'),
|
||||
path.join(projectRoot, '../memind'),
|
||||
path.join(projectRoot, '../Memind'),
|
||||
path.join(projectRoot, '../tkmind_go/ui/h5'),
|
||||
].filter(Boolean);
|
||||
@@ -17,7 +21,7 @@ export function resolveMemindLib() {
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'未找到 Memind 业务模块目录(user-auth.mjs)。请设置 MEMIND_LIB_ROOT 或保持 ../Memind 存在。',
|
||||
'未找到 Memind 业务模块目录(user-auth.mjs)。请设置 MEMIND_LIB_ROOT,或保持 ../test-memind、../memind、../Memind 其中之一存在。',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { importMemind, resolveMemindLib } from './lib-path.mjs';
|
||||
|
||||
const ADMIN_METHODS = [
|
||||
'getVisionSettings',
|
||||
'setVisionKey',
|
||||
'clearVisionKey',
|
||||
'listExecutorBindings',
|
||||
'setExecutorBinding',
|
||||
'listExecutorRuntimeConfigs',
|
||||
'listExecutorLaunchPlans',
|
||||
'listExecutorLaunchStates',
|
||||
'launchExecutor',
|
||||
'stopExecutor',
|
||||
'restartExecutor',
|
||||
];
|
||||
|
||||
function hasAdminMethods(createFactory) {
|
||||
if (typeof createFactory !== 'function') return false;
|
||||
const probe = createFactory(null, {});
|
||||
return ADMIN_METHODS.every((method) => typeof probe?.[method] === 'function');
|
||||
}
|
||||
|
||||
async function importLlmProvidersModule(modulePath) {
|
||||
return import(pathToFileURL(modulePath).href);
|
||||
}
|
||||
|
||||
export async function loadCreateLlmProviderService() {
|
||||
const primary = await importMemind('llm-providers.mjs');
|
||||
if (hasAdminMethods(primary.createLlmProviderService)) {
|
||||
return primary.createLlmProviderService;
|
||||
}
|
||||
|
||||
const libRoot = resolveMemindLib();
|
||||
const fallbackCandidates = [
|
||||
path.join(libRoot, 'llm-providers.mjs.bak-20260625-221206'),
|
||||
path.join(libRoot, 'llm-providers.admin.mjs'),
|
||||
];
|
||||
|
||||
for (const candidate of fallbackCandidates) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
const fallback = await importLlmProvidersModule(candidate);
|
||||
if (hasAdminMethods(fallback.createLlmProviderService)) {
|
||||
console.warn(
|
||||
`[admin] Memind llm-providers.mjs 缺少管理端方法,已回退到 ${path.basename(candidate)}`,
|
||||
);
|
||||
return fallback.createLlmProviderService;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Memind llm-providers.mjs 缺少 getVisionSettings 等管理端方法。' +
|
||||
'请更新 MEMIND_LIB_ROOT 下的 llm-providers.mjs,或保留 llm-providers.mjs.bak-20260625-221206 备份文件。',
|
||||
);
|
||||
}
|
||||
+19
-10
@@ -4,18 +4,27 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
loadEnvFile(path.join(projectRoot, file));
|
||||
}
|
||||
const memindRoot = process.env.MEMIND_LIB_ROOT?.trim();
|
||||
if (memindRoot) {
|
||||
for (const file of ['.env', '.env.local']) {
|
||||
loadEnvFile(path.join(memindRoot, file));
|
||||
}
|
||||
}
|
||||
return projectRoot;
|
||||
|
||||
+13
-281
@@ -1,286 +1,18 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30;
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function hashPassword(password, salt = crypto.randomBytes(16).toString('hex')) {
|
||||
const digest = crypto.scryptSync(String(password), salt, 64).toString('hex');
|
||||
return `scrypt$${salt}$${digest}`;
|
||||
}
|
||||
|
||||
function verifyPassword(password, stored) {
|
||||
const [scheme, salt, digest] = String(stored ?? '').split('$');
|
||||
if (scheme !== 'scrypt' || !salt || !digest) return false;
|
||||
const next = crypto.scryptSync(String(password), salt, 64).toString('hex');
|
||||
return crypto.timingSafeEqual(Buffer.from(next, 'hex'), Buffer.from(digest, 'hex'));
|
||||
}
|
||||
|
||||
function rowToUser(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: String(row.id),
|
||||
username: row.username,
|
||||
displayName: row.display_name ?? row.username,
|
||||
role: row.role,
|
||||
status: row.status,
|
||||
balanceCents: Number(row.balance_cents ?? 0),
|
||||
workspaceRoot: row.workspace_root ?? '',
|
||||
createdAt: row.created_at ? new Date(row.created_at).getTime() : Date.now(),
|
||||
updatedAt: row.updated_at ? new Date(row.updated_at).getTime() : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultCapabilities() {
|
||||
return {};
|
||||
}
|
||||
// Legacy standalone auth was removed in favor of Memind shared user auth.
|
||||
// Keep only generic cookie parsing helpers for the admin API container.
|
||||
|
||||
export function parseCookies(cookieHeader = '') {
|
||||
return Object.fromEntries(
|
||||
cookieHeader.split(';').map((part) => {
|
||||
const index = part.indexOf('=');
|
||||
if (index < 0) return ['', ''];
|
||||
return [decodeURIComponent(part.slice(0, index).trim()), decodeURIComponent(part.slice(index + 1).trim())];
|
||||
}).filter(([k]) => k),
|
||||
cookieHeader
|
||||
.split(';')
|
||||
.map((part) => {
|
||||
const index = part.indexOf('=');
|
||||
if (index < 0) return ['', ''];
|
||||
return [
|
||||
decodeURIComponent(part.slice(0, index).trim()),
|
||||
decodeURIComponent(part.slice(index + 1).trim()),
|
||||
];
|
||||
})
|
||||
.filter(([key]) => key),
|
||||
);
|
||||
}
|
||||
|
||||
export const USER_COOKIE = 'tkmind_admin_token';
|
||||
|
||||
export function userLoginCookies(token, secure, domain) {
|
||||
const parts = [
|
||||
`${USER_COOKIE}=${encodeURIComponent(token)}`,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'SameSite=Lax',
|
||||
secure ? 'Secure' : null,
|
||||
domain ? `Domain=${domain}` : null,
|
||||
`Max-Age=${SESSION_TTL_MS / 1000}`,
|
||||
].filter(Boolean);
|
||||
return `${parts.join('; ')}`;
|
||||
}
|
||||
|
||||
export function clearUserSessionCookie(secure, domain) {
|
||||
const parts = [
|
||||
`${USER_COOKIE}=`,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'SameSite=Lax',
|
||||
secure ? 'Secure' : null,
|
||||
domain ? `Domain=${domain}` : null,
|
||||
'Max-Age=0',
|
||||
].filter(Boolean);
|
||||
return `${parts.join('; ')}`;
|
||||
}
|
||||
|
||||
export function resolveCookieDomainForRequest(_req) {
|
||||
return '';
|
||||
}
|
||||
|
||||
export function createLocalUserAuth(pool) {
|
||||
const capabilityCatalog = [];
|
||||
const policyCatalog = [];
|
||||
const skillCatalog = [];
|
||||
|
||||
async function ensureTables() {
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS auth_users (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(191) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(191) NULL,
|
||||
role VARCHAR(32) NOT NULL DEFAULT 'user',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
balance_cents BIGINT NOT NULL DEFAULT 0,
|
||||
workspace_root VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
token VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_auth_sessions_user_id (user_id),
|
||||
INDEX idx_auth_sessions_expires_at (expires_at)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async function ensureAdminUser() {
|
||||
await ensureTables();
|
||||
const [rows] = await pool.execute('SELECT id FROM auth_users WHERE username = ? LIMIT 1', ['admin']);
|
||||
if (rows.length) return;
|
||||
throw new Error('admin 账号未初始化,请先运行 npm run admin:init');
|
||||
}
|
||||
|
||||
async function getUserByUsername(username) {
|
||||
const [rows] = await pool.execute('SELECT * FROM auth_users WHERE username = ? LIMIT 1', [username]);
|
||||
return rowToUser(rows[0]);
|
||||
}
|
||||
|
||||
async function getUserById(id) {
|
||||
const [rows] = await pool.execute('SELECT * FROM auth_users WHERE id = ? LIMIT 1', [id]);
|
||||
return rowToUser(rows[0]);
|
||||
}
|
||||
|
||||
async function getMe(token) {
|
||||
if (!token) return null;
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT u.* FROM auth_sessions s JOIN auth_users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > NOW() LIMIT 1',
|
||||
[token],
|
||||
);
|
||||
return rowToUser(rows[0]);
|
||||
}
|
||||
|
||||
async function revoke(token) {
|
||||
if (!token) return;
|
||||
await pool.execute('DELETE FROM auth_sessions WHERE token = ?', [token]);
|
||||
}
|
||||
|
||||
async function login({ username, password }) {
|
||||
await ensureTables();
|
||||
const [rows] = await pool.execute('SELECT * FROM auth_users WHERE username = ? LIMIT 1', [username]);
|
||||
const row = rows[0];
|
||||
if (!row) return { ok: false, message: '账号或密码错误', retryAfterMs: 0 };
|
||||
if (!verifyPassword(password, row.password_hash)) return { ok: false, message: '账号或密码错误', retryAfterMs: 0 };
|
||||
const token = crypto.randomUUID().replace(/-/g, '');
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
||||
await pool.execute('INSERT INTO auth_sessions (token, user_id, expires_at) VALUES (?, ?, ?)', [token, row.id, expiresAt]);
|
||||
return { ok: true, token, user: rowToUser(row) };
|
||||
}
|
||||
|
||||
async function listUsers({ page = 1, pageSize = 20, search = '', role = '', status = '' }) {
|
||||
await ensureTables();
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
if (search) {
|
||||
clauses.push('(username LIKE ? OR display_name LIKE ?)');
|
||||
params.push(`%${search}%`, `%${search}%`);
|
||||
}
|
||||
if (role) {
|
||||
clauses.push('role = ?');
|
||||
params.push(role);
|
||||
}
|
||||
if (status) {
|
||||
clauses.push('status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const [[countRow]] = await pool.execute(`SELECT COUNT(*) AS total FROM auth_users ${where}`, params);
|
||||
const safePage = Math.max(Number(page) || 1, 1);
|
||||
const safePageSize = Math.max(Number(pageSize) || 20, 1);
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT * FROM auth_users ${where} ORDER BY id DESC LIMIT ${safePageSize} OFFSET ${offset}`,
|
||||
params,
|
||||
);
|
||||
return {
|
||||
items: rows.map((row) => rowToUser(row)),
|
||||
total: Number(countRow.total ?? 0),
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
totalPages: Math.max(1, Math.ceil(Number(countRow.total ?? 0) / safePageSize)),
|
||||
};
|
||||
}
|
||||
|
||||
async function createUser(payload) {
|
||||
if (!payload?.username || !payload?.password) return { ok: false, message: '用户名和密码不能为空' };
|
||||
const [exists] = await pool.execute('SELECT id FROM auth_users WHERE username = ? LIMIT 1', [payload.username]);
|
||||
if (exists.length) return { ok: false, message: '用户名已存在' };
|
||||
await pool.execute(
|
||||
'INSERT INTO auth_users (username, display_name, role, status, password_hash, balance_cents, workspace_root) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
payload.username,
|
||||
payload.displayName || payload.username,
|
||||
payload.role === 'admin' ? 'admin' : 'user',
|
||||
'active',
|
||||
hashPassword(payload.password),
|
||||
Number(payload.balanceCents ?? 0),
|
||||
payload.workspaceRoot ?? null,
|
||||
],
|
||||
);
|
||||
return { ok: true, user: await getUserByUsername(payload.username) };
|
||||
}
|
||||
|
||||
async function updateUser(userId, payload) {
|
||||
const user = await getUserById(userId);
|
||||
if (!user) return { ok: false, message: '用户不存在' };
|
||||
const displayName = payload.displayName ?? user.displayName;
|
||||
const workspaceRoot = payload.workspaceRoot ?? user.workspaceRoot;
|
||||
const status = payload.status ?? user.status;
|
||||
const role = payload.role === 'admin' ? 'admin' : user.role;
|
||||
const balanceCents = payload.balanceCents ?? user.balanceCents;
|
||||
await pool.execute(
|
||||
'UPDATE auth_users SET display_name = ?, workspace_root = ?, status = ?, role = ?, balance_cents = ? WHERE id = ?',
|
||||
[displayName, workspaceRoot || null, status, role, balanceCents, userId],
|
||||
);
|
||||
return { ok: true, user: await getUserById(userId) };
|
||||
}
|
||||
|
||||
async function recharge(userId, amountCents) {
|
||||
const user = await getUserById(userId);
|
||||
if (!user) return { ok: false, message: '用户不存在' };
|
||||
await pool.execute('UPDATE auth_users SET balance_cents = balance_cents + ? WHERE id = ?', [Number(amountCents ?? 0), userId]);
|
||||
return { ok: true, user: await getUserById(userId) };
|
||||
}
|
||||
|
||||
async function getAdminSummary() {
|
||||
const [[row]] = await pool.execute(
|
||||
`SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active,
|
||||
SUM(CASE WHEN balance_cents < 0 THEN 1 ELSE 0 END) AS lowBalance,
|
||||
SUM(balance_cents) AS totalBalanceCents
|
||||
FROM auth_users`,
|
||||
);
|
||||
return {
|
||||
users: {
|
||||
total: Number(row.total ?? 0),
|
||||
active: Number(row.active ?? 0),
|
||||
lowBalance: Number(row.lowBalance ?? 0),
|
||||
totalBalanceCents: Number(row.totalBalanceCents ?? 0),
|
||||
},
|
||||
usage24h: { count: 0, costCents: 0 },
|
||||
lowBalanceUsers: [],
|
||||
recentUsage: [],
|
||||
recentLedger: [],
|
||||
llm: { keyCount: 0, selectedKeyName: null, globalModel: null },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hashPassword,
|
||||
capabilityCatalog,
|
||||
policyCatalog,
|
||||
skillCatalog,
|
||||
ensureAdminUser,
|
||||
login,
|
||||
getMe,
|
||||
revoke,
|
||||
listUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
recharge,
|
||||
getAdminSummary,
|
||||
getRoleCapabilities: async () => ({ ok: true, role: 'user', capabilities: defaultCapabilities() }),
|
||||
setRoleCapabilities: async () => ({ ok: true, role: 'user', capabilities: defaultCapabilities() }),
|
||||
getUserCapabilities: async () => ({ ok: true, userId: null, capabilities: defaultCapabilities() }),
|
||||
setUserCapabilities: async () => ({ ok: true }),
|
||||
clearUserCapabilityOverrides: async () => ({ ok: true }),
|
||||
getRolePolicies: async () => ({ ok: true, role: 'user', policies: {} }),
|
||||
setRolePolicies: async () => ({ ok: true, role: 'user', policies: {} }),
|
||||
getUserPolicies: async () => ({ ok: true, userId: null, policies: {} }),
|
||||
setUserPolicies: async () => ({ ok: true }),
|
||||
clearUserPolicyOverrides: async () => ({ ok: true }),
|
||||
getRoleSkills: async () => ({ ok: true, role: 'user', skills: {} }),
|
||||
setRoleSkills: async () => ({ ok: true, role: 'user', skills: {} }),
|
||||
getUserSkills: async () => ({ ok: true, userId: null, skills: {} }),
|
||||
setUserSkills: async () => ({ ok: true }),
|
||||
clearUserSkillOverrides: async () => ({ ok: true }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
const DEFAULT_TIMEOUT_MS = 10000;
|
||||
|
||||
function trimEnv(name) {
|
||||
const value = process.env[name];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function parseTimeout(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
async function safeJson(response) {
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function responseMessage(payload, fallback) {
|
||||
if (payload && typeof payload === 'object' && typeof payload.message === 'string' && payload.message.trim()) {
|
||||
return payload.message.trim();
|
||||
}
|
||||
if (typeof payload === 'string' && payload.trim()) return payload.trim();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function createPlanSyncService(logger = console) {
|
||||
const baseUrl = trimEnv('PLAN_SYNC_TARGET_BASE_URL').replace(/\/$/, '');
|
||||
const username = trimEnv('PLAN_SYNC_USERNAME');
|
||||
const password = trimEnv('PLAN_SYNC_PASSWORD');
|
||||
const timeoutMs = parseTimeout(trimEnv('PLAN_SYNC_TIMEOUT_MS'));
|
||||
|
||||
if (!baseUrl || !username || !password) {
|
||||
return {
|
||||
enabled: false,
|
||||
reason: '未配置生产套餐同步环境变量',
|
||||
async syncPlanUpsert() {
|
||||
return { enabled: false, ok: false, message: '未配置生产套餐同步环境变量' };
|
||||
},
|
||||
async syncPlanDelete() {
|
||||
return { enabled: false, ok: false, message: '未配置生产套餐同步环境变量' };
|
||||
},
|
||||
async syncAllPlans() {
|
||||
return { enabled: false, ok: false, message: '未配置生产套餐同步环境变量', synced: 0, total: 0 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function request(path, init = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(`${baseUrl}${path}`, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function loginAndGetCookie() {
|
||||
const response = await request('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const payload = await safeJson(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(`生产后台登录失败:${responseMessage(payload, `${response.status} ${response.statusText}`)}`);
|
||||
}
|
||||
const cookie = response.headers.get('set-cookie');
|
||||
if (!cookie) throw new Error('生产后台登录成功,但未返回会话 Cookie');
|
||||
return cookie.split(';', 1)[0];
|
||||
}
|
||||
|
||||
async function syncRequest(path, init = {}) {
|
||||
const cookie = await loginAndGetCookie();
|
||||
const response = await request(path, {
|
||||
...init,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-plan-sync-hop': '1',
|
||||
...(init.headers ?? {}),
|
||||
cookie,
|
||||
},
|
||||
});
|
||||
const payload = await safeJson(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(responseMessage(payload, `${response.status} ${response.statusText}`));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function syncPlanUpsert(planType, plan) {
|
||||
try {
|
||||
await syncRequest(`/admin-api/subscriptions/plans/${encodeURIComponent(planType)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(plan),
|
||||
});
|
||||
return {
|
||||
enabled: true,
|
||||
ok: true,
|
||||
message: `已同步到生产后台 ${baseUrl}`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn?.('[plan-sync] upsert failed', { planType, error: String(error) });
|
||||
return {
|
||||
enabled: true,
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : '同步生产后台失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPlanDelete(planType) {
|
||||
try {
|
||||
await syncRequest(`/admin-api/subscriptions/plans/${encodeURIComponent(planType)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return {
|
||||
enabled: true,
|
||||
ok: true,
|
||||
message: `已同步删除生产后台套餐 ${planType}`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn?.('[plan-sync] delete failed', { planType, error: String(error) });
|
||||
return {
|
||||
enabled: true,
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : '同步生产后台失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAllPlans(plans) {
|
||||
let synced = 0;
|
||||
const failures = [];
|
||||
for (const plan of plans) {
|
||||
const result = await syncPlanUpsert(plan.planType, plan);
|
||||
if (result.ok) synced += 1;
|
||||
else failures.push(`${plan.planType}: ${result.message}`);
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
ok: failures.length === 0,
|
||||
message: failures.length === 0
|
||||
? `已同步 ${synced}/${plans.length} 个套餐到生产后台`
|
||||
: `已同步 ${synced}/${plans.length} 个套餐,失败:${failures.join(';')}`,
|
||||
synced,
|
||||
total: plans.length,
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
reason: '',
|
||||
syncPlanUpsert,
|
||||
syncPlanDelete,
|
||||
syncAllPlans,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user