chore: checkpoint admin restore state

This commit is contained in:
john
2026-06-30 20:26:33 +08:00
parent f2172322fd
commit f9a7584ad4
63 changed files with 7555 additions and 1030 deletions
+354 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 其中之一存在。',
);
}
+57
View File
@@ -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
View File
@@ -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
View File
@@ -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 }),
};
}
+164
View File
@@ -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,
};
}