Files
memind/admin-routes.mjs
T
john 229805a070 Improve WeChat MP replies and ship MindSpace/H5 production updates.
Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 23:06:43 +08:00

541 lines
21 KiB
JavaScript

// admin-routes.mjs
//
// Back-office HTTP route factories for the standalone memind_adm service.
//
// Route logic lives here exactly once. The public server (server.mjs) no longer
// serves these surfaces; admin-server.mjs is the sole consumer. Services are
// injected so the same router can be wired against any container (tests, future
// read-replica pool, etc.).
//
// createAdminApi -> /admin-api/* platform super-admin (role === 'admin')
// createOpsApi -> /api/ops/v1/* plaza operations console (ops_role)
import express from 'express';
import { sendData, sendError } from './api-response.mjs';
import { hasOpsRole } from './plaza-ops.mjs';
import { mapPlazaError } from './plaza-posts.mjs';
function plazaRouteError(res, req, error) {
const status = mapPlazaError(error);
const code = error?.code ?? 'internal_error';
const message = error instanceof Error ? error.message : 'Plaza 请求失败';
return sendError(res, req, status, code, message, error?.details);
}
/**
* Platform super-admin API (role === 'admin').
* @param {object} deps
* @param {import('express').RequestHandler} deps.jsonBody JSON body parser middleware.
* @param {(req: import('express').Request) => (string|undefined)} deps.getToken Extract the user session token from a request.
* @param {Promise<unknown>} [deps.ready] Optional bootstrap gate awaited before the first request resolves auth.
* @param {object} deps.userAuth
* @param {object|null} deps.llmProviderService
* @param {object|null} deps.plazaPosts
* @param {object|null} deps.plazaOps
* @param {object|null} deps.wechatAdmin
*/
export function createAdminApi({
jsonBody,
getToken,
ready,
userAuth,
llmProviderService,
plazaPosts,
plazaOps,
wechatAdmin,
}) {
function requireAdmin(req, res, next) {
if (!req.currentUser || req.currentUser.role !== 'admin') {
res.status(403).json({ message: '需要管理员权限' });
return;
}
next();
}
const adminApi = express.Router();
adminApi.use(jsonBody);
adminApi.use(async (req, res, next) => {
if (ready) await ready;
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const me = await userAuth.getMe(getToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
req.currentUser = me;
next();
});
adminApi.get('/users', requireAdmin, async (req, res) => {
const { page, pageSize, search, role, status } = req.query;
const result = await userAuth.listUsers({ page, pageSize, search, role, status });
res.json(result);
});
adminApi.get('/summary', requireAdmin, async (_req, res) => {
const summary = await userAuth.getAdminSummary();
let llm = null;
if (llmProviderService) {
const keys = await llmProviderService.listKeys();
const global = await llmProviderService.getGlobalSettings();
const selected = keys.find((key) => key.isSelected);
llm = {
keyCount: keys.length,
selectedKeyName: selected?.name ?? null,
globalModel: global?.model ?? null,
};
}
res.json({ summary: { ...summary, llm } });
});
adminApi.post('/users', requireAdmin, async (req, res) => {
const result = await userAuth.createUser(req.body ?? {});
if (!result.ok) return res.status(400).json({ message: result.message });
res.status(201).json({ user: result.user });
});
adminApi.patch('/users/:userId', requireAdmin, async (req, res) => {
const result = await userAuth.updateUser(req.params.userId, req.body ?? {});
if (!result.ok) return res.status(400).json({ message: result.message });
res.json({ user: result.user });
});
adminApi.post('/users/:userId/recharge', requireAdmin, async (req, res) => {
const amountCents = Number(req.body?.amountCents);
const note = typeof req.body?.note === 'string' ? req.body.note : '';
const result = await userAuth.recharge(
req.params.userId,
amountCents,
req.currentUser.id,
note,
);
if (!result.ok) return res.status(400).json({ message: result.message });
res.json({ user: result.user });
});
adminApi.get('/usage', requireAdmin, async (req, res) => {
const userId = typeof req.query.userId === 'string' ? req.query.userId : null;
const { page, pageSize } = req.query;
const result = await userAuth.listUsageRecords({ userId, page, pageSize });
res.json(result);
});
adminApi.get('/capabilities/catalog', requireAdmin, (_req, res) => {
res.json({ catalog: userAuth.capabilityCatalog });
});
adminApi.get('/capabilities/role/:role', requireAdmin, async (req, res) => {
const role = req.params.role === 'admin' ? 'admin' : 'user';
if (role === 'admin') {
return res.json({
role,
capabilities: Object.fromEntries(
userAuth.capabilityCatalog.map((item) => [item.key, true]),
),
unrestricted: true,
});
}
res.json(await userAuth.getRoleCapabilities('user'));
});
adminApi.put('/capabilities/role/:role', requireAdmin, async (req, res) => {
const role = req.params.role === 'admin' ? 'admin' : 'user';
const result = await userAuth.setRoleCapabilities(role, req.body?.capabilities ?? {});
if (!result.ok) return res.status(400).json({ message: result.message });
res.json(result);
});
adminApi.get('/users/:userId/capabilities', requireAdmin, async (req, res) => {
const result = await userAuth.getUserCapabilities(req.params.userId);
if (!result.ok) return res.status(404).json({ message: result.message });
res.json(result);
});
adminApi.put('/users/:userId/capabilities', requireAdmin, async (req, res) => {
const result = await userAuth.setUserCapabilities(
req.params.userId,
req.body?.capabilities ?? {},
);
if (!result.ok) return res.status(400).json({ message: result.message });
res.json(result);
});
adminApi.delete('/users/:userId/capabilities', requireAdmin, async (req, res) => {
const result = await userAuth.clearUserCapabilityOverrides(req.params.userId);
if (!result.ok) return res.status(404).json({ message: result.message });
res.json(result);
});
adminApi.get('/policies/catalog', requireAdmin, (_req, res) => {
res.json({ catalog: userAuth.policyCatalog });
});
adminApi.get('/policies/role/:role', requireAdmin, async (req, res) => {
const role = req.params.role === 'admin' ? 'admin' : 'user';
if (role === 'admin') {
return res.json({ role, policies: {}, unrestricted: true });
}
res.json(await userAuth.getRolePolicies('user'));
});
adminApi.put('/policies/role/:role', requireAdmin, async (req, res) => {
const role = req.params.role === 'admin' ? 'admin' : 'user';
const result = await userAuth.setRolePolicies(role, req.body?.policies ?? {});
if (!result.ok) return res.status(400).json({ message: result.message });
res.json(result);
});
adminApi.get('/users/:userId/policies', requireAdmin, async (req, res) => {
const result = await userAuth.getUserPolicies(req.params.userId);
if (!result.ok) return res.status(404).json({ message: result.message });
res.json(result);
});
adminApi.put('/users/:userId/policies', requireAdmin, async (req, res) => {
const result = await userAuth.setUserPolicies(req.params.userId, req.body?.policies ?? {});
if (!result.ok) return res.status(400).json({ message: result.message });
res.json(result);
});
adminApi.delete('/users/:userId/policies', requireAdmin, async (req, res) => {
const result = await userAuth.clearUserPolicyOverrides(req.params.userId);
if (!result.ok) return res.status(404).json({ message: result.message });
res.json(result);
});
adminApi.get('/skills/catalog', requireAdmin, (_req, res) => {
res.json({ catalog: userAuth.skillCatalog });
});
adminApi.get('/skills/role/:role', requireAdmin, async (req, res) => {
const role = req.params.role === 'admin' ? 'admin' : 'user';
if (role === 'admin') {
return res.json({
role,
skills: Object.fromEntries(userAuth.skillCatalog.map((item) => [item.name, true])),
});
}
res.json(await userAuth.getRoleSkills('user'));
});
adminApi.put('/skills/role/:role', requireAdmin, async (req, res) => {
const role = req.params.role === 'admin' ? 'admin' : 'user';
const result = await userAuth.setRoleSkills(role, req.body?.skills ?? {});
if (!result.ok) return res.status(400).json({ message: result.message });
res.json(result);
});
adminApi.get('/users/:userId/skills', requireAdmin, async (req, res) => {
const result = await userAuth.getUserSkills(req.params.userId);
if (!result.ok) return res.status(404).json({ message: result.message });
res.json(result);
});
adminApi.put('/users/:userId/skills', requireAdmin, async (req, res) => {
const result = await userAuth.setUserSkills(req.params.userId, req.body?.skills ?? {});
if (!result.ok) return res.status(400).json({ message: result.message });
res.json(result);
});
adminApi.delete('/users/:userId/skills', requireAdmin, async (req, res) => {
const result = await userAuth.clearUserSkillOverrides(req.params.userId);
if (!result.ok) return res.status(404).json({ message: result.message });
res.json(result);
});
adminApi.get('/ledger', requireAdmin, async (req, res) => {
const userId = typeof req.query.userId === 'string' ? req.query.userId : null;
const { page, pageSize } = req.query;
const result = await userAuth.listBillingLedger({ userId, page, pageSize });
res.json(result);
});
adminApi.get('/wechat/summary', requireAdmin, async (_req, res) => {
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
res.json(await wechatAdmin.getSummary());
});
adminApi.get('/wechat/bindings', requireAdmin, async (req, res) => {
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
res.json(await wechatAdmin.listBindings(req.query));
});
adminApi.get('/wechat/messages', requireAdmin, async (req, res) => {
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
res.json(await wechatAdmin.listMessages(req.query));
});
adminApi.get('/wechat/digests', requireAdmin, async (req, res) => {
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
res.json(await wechatAdmin.listDigests(req.query));
});
adminApi.get('/wechat/deliveries', requireAdmin, async (req, res) => {
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
res.json(await wechatAdmin.listDeliveries(req.query));
});
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);
if (!result.ok) return res.status(404).json({ message: result.message ?? '清除失败' });
res.json(result);
});
adminApi.post('/wechat/digests/:id/cancel', requireAdmin, async (req, res) => {
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
const result = await wechatAdmin.cancelDigest(req.params.id);
if (!result.ok) return res.status(404).json({ message: '订阅不存在或已暂停' });
res.json(result);
});
adminApi.post('/wechat/digests/:id/resume', requireAdmin, async (req, res) => {
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
const result = await wechatAdmin.resumeDigest(req.params.id);
if (!result.ok) return res.status(404).json({ message: result.message ?? '恢复失败' });
res.json(result);
});
adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => {
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
res.json({ catalog: llmProviderService.catalog });
});
adminApi.get('/llm-providers/keys', requireAdmin, async (_req, res) => {
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
const keys = await llmProviderService.listKeys();
res.json({ keys });
});
adminApi.post('/llm-providers/keys', requireAdmin, async (req, res) => {
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
const result = await llmProviderService.createKey(req.body ?? {});
if (!result.ok) return res.status(400).json({ message: result.message });
res.status(201).json({ key: result.key });
});
adminApi.patch('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => {
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
const result = await llmProviderService.updateKey(req.params.keyId, req.body ?? {});
if (!result.ok) return res.status(400).json({ message: result.message });
res.json({ key: result.key });
});
adminApi.post('/llm-providers/keys/:keyId/select', requireAdmin, async (req, res) => {
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
const result = await llmProviderService.selectKey(req.params.keyId);
if (!result.ok) return res.status(400).json({ message: result.message });
res.json({ key: result.key });
});
adminApi.delete('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => {
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
const result = await llmProviderService.deleteKey(req.params.keyId);
if (!result.ok) return res.status(400).json({ message: result.message });
res.json({ ok: true });
});
adminApi.post('/llm-providers/sync', requireAdmin, async (_req, res) => {
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
try {
const result = await llmProviderService.syncSelectedToGoosed();
res.json(result);
} catch (err) {
res.status(500).json({
message: err instanceof Error ? err.message : '同步失败',
});
}
});
adminApi.get('/llm-providers/global', requireAdmin, async (_req, res) => {
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
const global = await llmProviderService.getGlobalSettings();
res.json({ global });
});
adminApi.put('/llm-providers/global', requireAdmin, async (req, res) => {
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
const result = await llmProviderService.setGlobalModel(req.body?.model);
if (!result.ok) return res.status(400).json({ message: result.message });
res.json(result);
});
adminApi.post('/llm-providers/test', requireAdmin, async (req, res) => {
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
try {
const result = await llmProviderService.testDraft(req.body ?? {});
res.json(result);
} catch (err) {
res.status(500).json({
ok: false,
message: err instanceof Error ? err.message : '联通测试失败',
});
}
});
adminApi.post('/llm-providers/keys/:keyId/test', requireAdmin, async (req, res) => {
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
try {
const result = await llmProviderService.testKey(req.params.keyId, req.body?.model);
res.json(result);
} catch (err) {
res.status(500).json({
ok: false,
message: err instanceof Error ? err.message : '联通测试失败',
});
}
});
adminApi.get('/plaza/pending', requireAdmin, async (_req, res) => {
if (!plazaPosts) return res.status(503).json({ message: 'Plaza 未启用' });
const posts = await plazaPosts.listPendingPosts();
res.json({ data: { posts } });
});
adminApi.post('/plaza/posts/:id/review', requireAdmin, async (req, res) => {
if (!plazaPosts) return res.status(503).json({ message: 'Plaza 未启用' });
try {
const action = String(req.body?.action ?? 'approve');
const reason = req.body?.reason ?? null;
const result =
plazaOps && req.currentUser?.id
? await plazaOps.reviewPostAsOps(req.currentUser.id, req.params.id, action, { reason })
: await plazaPosts.reviewPost(req.params.id, action, { reason });
res.json({ data: result });
} catch (error) {
const status = mapPlazaError(error);
res.status(status).json({
error: { code: error?.code ?? 'review_failed', message: error.message },
});
}
});
return adminApi;
}
/**
* Plaza operations console API (ops_role: reviewer < editor < ops_admin).
* @param {object} deps
* @param {import('express').RequestHandler} deps.jsonBody
* @param {object|null} deps.plazaOps
*/
export function createOpsApi({ jsonBody, plazaOps }) {
function makeRequireOps(minRole = 'reviewer') {
return async (req, res, next) => {
if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
const role = await plazaOps.loadOperatorRole(req.currentUser.id);
if (!hasOpsRole(role, minRole)) {
return sendError(res, req, 403, 'OPS_PERMISSION_DENIED', '无运营权限');
}
req.opsRole = role;
return next();
};
}
const opsApi = express.Router();
opsApi.use(jsonBody);
opsApi.use(makeRequireOps('reviewer'));
opsApi.get('/review/queue', async (req, res) => {
try {
const queue = await plazaOps.listReviewQueue({
status: req.query.status ?? 'pending_review',
cursor: req.query.cursor ?? null,
limit: req.query.limit,
keyword: req.query.keyword ?? null,
});
return sendData(res, req, queue);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
opsApi.post('/review/posts/:id', async (req, res) => {
try {
const result = await plazaOps.reviewPostAsOps(req.currentUser.id, req.params.id, req.body?.action, {
reason: req.body?.reason ?? null,
});
return sendData(res, req, { post: result });
} catch (error) {
return plazaRouteError(res, req, error);
}
});
opsApi.post('/review/batch', makeRequireOps('editor'), async (req, res) => {
try {
const result = await plazaOps.batchReviewPosts(req.currentUser.id, req.body ?? {});
return sendData(res, req, result);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
opsApi.get('/reports', async (req, res) => {
try {
return sendData(res, req, await plazaOps.listReports({ status: req.query.status ?? 'pending' }));
} catch (error) {
return plazaRouteError(res, req, error);
}
});
opsApi.post('/reports/:id/process', async (req, res) => {
try {
const result = await plazaOps.processReport(req.currentUser.id, req.params.id, req.body ?? {});
return sendData(res, req, { report: result });
} catch (error) {
return plazaRouteError(res, req, error);
}
});
opsApi.get('/featured', makeRequireOps('editor'), async (req, res) => {
try {
return sendData(res, req, await plazaOps.listFeatured());
} catch (error) {
return plazaRouteError(res, req, error);
}
});
opsApi.post('/featured', makeRequireOps('editor'), async (req, res) => {
try {
const result = await plazaOps.setFeatured(req.currentUser.id, req.body ?? {});
return sendData(res, req, { featured: result }, 201);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
opsApi.delete('/featured/:id', makeRequireOps('editor'), async (req, res) => {
try {
const result = await plazaOps.removeFeatured(req.currentUser.id, req.params.id);
return sendData(res, req, { featured: result });
} catch (error) {
return plazaRouteError(res, req, error);
}
});
opsApi.get('/analytics/overview', makeRequireOps('ops_admin'), async (req, res) => {
try {
return sendData(res, req, await plazaOps.getAnalyticsOverview());
} catch (error) {
return plazaRouteError(res, req, error);
}
});
opsApi.get('/creators', async (req, res) => {
try {
return sendData(res, req, await plazaOps.listCreators({ keyword: req.query.keyword ?? null }));
} catch (error) {
return plazaRouteError(res, req, error);
}
});
opsApi.patch('/creators/:userId', async (req, res) => {
try {
const result = await plazaOps.updateCreator(req.currentUser.id, req.params.userId, req.body ?? {});
return sendData(res, req, { creator: result });
} catch (error) {
return plazaRouteError(res, req, error);
}
});
return opsApi;
}