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) {
|
||||
|
||||
Reference in New Issue
Block a user