Split back-office into standalone memind_adm service
Carve the platform super-admin API (/admin-api) and plaza operations console API (/api/ops/v1) out of the public server.mjs into their own process, so the user-facing fleet can no longer be taken down or scaled by back-office traffic. Architecture (split-ready, single process for now): - admin-routes.mjs: createAdminApi / createOpsApi route factories (DI, single source of truth; the route logic moved verbatim out of server.mjs). - admin-bootstrap.mjs: lean service container (user-auth, LLM providers, minimal plaza graph) with no user-facing daemons. - admin-server.mjs: standalone entry with a console registry. ADMIN_CONSOLES selects which consoles a process mounts (default both), so splitting into two processes later is a config change, not a code change. - admin-guard.mjs (+ tests): per-console host / IP-CIDR allowlists, letting the super-admin surface be locked down harder than moderation. server.mjs no longer serves or mounts either surface. user-auth.mjs gains pagination on listUsers/listUsageRecords/listBillingLedger to back the admin dashboards. dev.mjs launches memind_adm; the ops SPA proxies /api to it while /auth stays on the portal. Sessions are read from the shared cookie (H5_COOKIE_DOMAIN=.tkmind.cn); login stays on the main domain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,24 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:8081
|
||||
# MindSpace 与 Plaza 跨子域共享登录(如 g2.tkmind.cn + plaza.tkmind.cn)
|
||||
# H5_COOKIE_DOMAIN=.tkmind.cn
|
||||
|
||||
# ===== memind_adm(独立后台服务)=====
|
||||
# 平台超管 /admin-api/* 与广场运营 /api/ops/v1/* 已从公网服务拆出,独立进程运行。
|
||||
# 登录仍在主域完成;后台凭 H5_COOKIE_DOMAIN=.tkmind.cn 共享的会话 Cookie 鉴权(生产务必设置)。
|
||||
ADMIN_PORT=8082
|
||||
ADMIN_HOST=127.0.0.1
|
||||
ADMIN_PUBLIC_HOST=gadm.tkmind.cn
|
||||
|
||||
# 本进程挂载哪些控制台(可拆分架构):admin=平台超管, ops=广场运营。
|
||||
# 默认两者合一;将来拆成两个进程时,A 实例设 admin、B 实例设 ops,路由代码无需改动。
|
||||
# ADMIN_CONSOLES=admin,ops
|
||||
|
||||
# 每控制台独立网络护栏(逗号分隔;留空=放开。支持精确 IP 与 CIDR)。
|
||||
# 超管面建议收得最紧(仅内网/VPN),广场运营可放宽给审核团队网段。
|
||||
# ADMIN_API_ALLOWED_HOSTS=gadm.tkmind.cn
|
||||
# ADMIN_API_IP_ALLOWLIST=10.0.0.0/8,127.0.0.1
|
||||
# OPS_API_ALLOWED_HOSTS=gadm.tkmind.cn
|
||||
# OPS_API_IP_ALLOWLIST=
|
||||
|
||||
# 多用户系统(MySQL 配置后启用;未配置时回退到 H5_ACCESS_PASSWORD)
|
||||
DATABASE_URL=mysql://boot:password@localhost:3306/tkmind
|
||||
# 或分别设置 MYSQL_HOST / MYSQL_PORT / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DATABASE
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// admin-bootstrap.mjs
|
||||
//
|
||||
// Service container for the standalone memind_adm process.
|
||||
//
|
||||
// Builds only the domain services the back-office routes need (user-auth, LLM
|
||||
// providers, and the minimal plaza graph behind the ops console). It deliberately
|
||||
// does NOT start any user-facing daemons — no thumbnail watcher, no asset-sync
|
||||
// watcher, no plaza hot-score scheduler, no goosed auto-sync. Those belong to the
|
||||
// public server. Side-effecting actions (e.g. push selected LLM key to goosed)
|
||||
// stay behind explicit admin routes.
|
||||
//
|
||||
// The same factory functions imported here are the ones server.mjs uses, so the
|
||||
// domain logic has a single source of truth; only the wiring differs.
|
||||
import path from 'node:path';
|
||||
import { createDbPool, isDatabaseConfigured } from './db.mjs';
|
||||
import { createUserAuth } from './user-auth.mjs';
|
||||
import { createLlmProviderService } from './llm-providers.mjs';
|
||||
import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs';
|
||||
import { createPlazaInteractionService } from './plaza-interactions.mjs';
|
||||
import { createPlazaOpsService } from './plaza-ops.mjs';
|
||||
import { createNoopPlazaRedis } from './plaza-redis.mjs';
|
||||
import { ensureAlgorithmConfig, loadAlgorithmConfig } from './plaza-algorithm.mjs';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
/**
|
||||
* Build the admin service container.
|
||||
* @param {object} [env]
|
||||
* @param {string} [env.h5Root] Repo root used for workspace path resolution.
|
||||
* @param {string} [env.usersRoot] User workspace root.
|
||||
* @param {string} [env.apiTarget] Goosed/relay API target.
|
||||
* @param {string} [env.apiSecret] Goosed/relay API secret.
|
||||
* @param {number} [env.defaultSignupBalanceCents]
|
||||
* @param {boolean} [env.ensureAdminUser] Ensure an admin account exists on boot (default true).
|
||||
* @returns {Promise<{pool, userAuth, llmProviderService, plazaPosts, plazaOps}>}
|
||||
*/
|
||||
export async function createAdminServices(env = {}) {
|
||||
if (!isDatabaseConfigured()) {
|
||||
throw new Error('admin-server requires a database (set DATABASE_URL or MYSQL_* env)');
|
||||
}
|
||||
|
||||
const h5Root = env.h5Root ?? process.cwd();
|
||||
const usersRoot = env.usersRoot ?? process.env.H5_USERS_ROOT ?? path.join(h5Root, 'users');
|
||||
const apiTarget = env.apiTarget ?? process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006';
|
||||
const apiSecret = env.apiSecret ?? process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
|
||||
const defaultSignupBalanceCents = Number(
|
||||
env.defaultSignupBalanceCents ?? process.env.H5_SIGNUP_BALANCE_CENTS ?? 500,
|
||||
);
|
||||
|
||||
const pool = createDbPool();
|
||||
|
||||
// --- plaza graph (review queue, reports, featured, analytics, creators) ---
|
||||
const plazaRedis = createNoopPlazaRedis();
|
||||
await ensureAlgorithmConfig(pool);
|
||||
const algorithmConfig = await loadAlgorithmConfig(pool);
|
||||
|
||||
const plazaInteractions = createPlazaInteractionService(pool, { formatPostRow, plazaRedis });
|
||||
|
||||
let plazaOps = null;
|
||||
const plazaPosts = createPlazaPostService(pool, {
|
||||
loadViewerReactions: (viewerId, postIds) =>
|
||||
plazaInteractions.loadViewerReactions(viewerId, postIds),
|
||||
plazaRedis,
|
||||
algorithmConfig,
|
||||
onPostPublished: noop,
|
||||
loadFeaturedPosts: async (viewerId) => {
|
||||
if (!plazaOps) return { homepage_banner: [], trending: [], category_top: {} };
|
||||
return plazaOps.loadActiveFeaturedPosts(viewerId);
|
||||
},
|
||||
});
|
||||
plazaOps = createPlazaOpsService(pool, {
|
||||
formatPostRow,
|
||||
reviewPost: (...args) => plazaPosts.reviewPost(...args),
|
||||
invalidateFeedCaches: () => plazaRedis?.invalidateFeedCaches?.(),
|
||||
});
|
||||
|
||||
// --- platform super-admin services ---
|
||||
const userAuth = createUserAuth(pool, {
|
||||
usersRoot,
|
||||
h5Root,
|
||||
defaultSignupBalanceCents,
|
||||
});
|
||||
if (env.ensureAdminUser !== false) {
|
||||
await userAuth.ensureAdminUser();
|
||||
}
|
||||
|
||||
const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret });
|
||||
|
||||
return { pool, userAuth, llmProviderService, plazaPosts, plazaOps };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// admin-guard.mjs
|
||||
//
|
||||
// Pure, testable network-guard helpers for the memind_adm consoles.
|
||||
//
|
||||
// Each back-office console (platform super-admin, plaza operations) can be
|
||||
// pinned to an allowlist of request hosts and/or client IPs, independently.
|
||||
// This lets the super-admin surface be locked down harder than moderation
|
||||
// while both still live in one process — and the same knobs keep working
|
||||
// unchanged once the consoles are split into separate processes.
|
||||
|
||||
export function parseList(value) {
|
||||
return String(value ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function hostAllowed(reqHost, allowedHosts) {
|
||||
if (!allowedHosts.length) return true;
|
||||
const hostname = String(reqHost ?? '').split(':')[0].toLowerCase();
|
||||
return allowedHosts.some((h) => h.toLowerCase() === hostname);
|
||||
}
|
||||
|
||||
function ipv4ToInt(ip) {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
let acc = 0;
|
||||
for (const part of parts) {
|
||||
if (!/^\d{1,3}$/.test(part)) return null;
|
||||
const n = Number(part);
|
||||
if (n < 0 || n > 255) return null;
|
||||
acc = (acc * 256 + n) >>> 0;
|
||||
}
|
||||
return acc >>> 0;
|
||||
}
|
||||
|
||||
// Strip an IPv4-mapped IPv6 prefix so "::ffff:127.0.0.1" matches "127.0.0.1".
|
||||
function normalizeIp(ip) {
|
||||
return String(ip ?? '').replace(/^::ffff:/i, '');
|
||||
}
|
||||
|
||||
export function ipMatches(clientIp, rule) {
|
||||
const ip = normalizeIp(clientIp);
|
||||
if (rule.includes('/')) {
|
||||
const [base, bitsStr] = rule.split('/');
|
||||
const bits = Number(bitsStr);
|
||||
const ipInt = ipv4ToInt(ip);
|
||||
const baseInt = ipv4ToInt(base);
|
||||
if (ipInt === null || baseInt === null) return false;
|
||||
if (!Number.isInteger(bits) || bits < 0 || bits > 32) return false;
|
||||
if (bits === 0) return true;
|
||||
const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0;
|
||||
return (ipInt & mask) === (baseInt & mask);
|
||||
}
|
||||
return ip === normalizeIp(rule);
|
||||
}
|
||||
|
||||
export function ipAllowed(clientIp, ipAllowlist) {
|
||||
if (!ipAllowlist.length) return true;
|
||||
return ipAllowlist.some((rule) => ipMatches(clientIp, rule));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an Express middleware enforcing host + IP allowlists for one console.
|
||||
* Returns null when neither list is configured (no-op — common in dev).
|
||||
* @param {object} opts
|
||||
* @param {string[]} [opts.allowedHosts]
|
||||
* @param {string[]} [opts.ipAllowlist]
|
||||
* @param {(res, req, reason: 'host'|'ip') => unknown} opts.onDeny
|
||||
*/
|
||||
export function buildNetworkGuard({ allowedHosts = [], ipAllowlist = [], onDeny }) {
|
||||
if (!allowedHosts.length && !ipAllowlist.length) return null;
|
||||
return (req, res, next) => {
|
||||
if (!hostAllowed(req.get('host'), allowedHosts)) return onDeny(res, req, 'host');
|
||||
if (!ipAllowed(req.ip, ipAllowlist)) return onDeny(res, req, 'ip');
|
||||
return next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
parseList,
|
||||
hostAllowed,
|
||||
ipMatches,
|
||||
ipAllowed,
|
||||
buildNetworkGuard,
|
||||
} from './admin-guard.mjs';
|
||||
|
||||
test('parseList splits, trims, and drops empties', () => {
|
||||
assert.deepEqual(parseList('a, b ,,c'), ['a', 'b', 'c']);
|
||||
assert.deepEqual(parseList(''), []);
|
||||
assert.deepEqual(parseList(undefined), []);
|
||||
});
|
||||
|
||||
test('hostAllowed: empty list allows all, otherwise matches hostname ignoring port/case', () => {
|
||||
assert.equal(hostAllowed('gadm.tkmind.cn:8082', []), true);
|
||||
assert.equal(hostAllowed('GADM.tkmind.cn', ['gadm.tkmind.cn']), true);
|
||||
assert.equal(hostAllowed('evil.example.com', ['gadm.tkmind.cn']), false);
|
||||
assert.equal(hostAllowed('gadm.tkmind.cn:443', ['gadm.tkmind.cn', 'localhost']), true);
|
||||
});
|
||||
|
||||
test('ipMatches: exact and IPv4-mapped IPv6', () => {
|
||||
assert.equal(ipMatches('127.0.0.1', '127.0.0.1'), true);
|
||||
assert.equal(ipMatches('::ffff:127.0.0.1', '127.0.0.1'), true);
|
||||
assert.equal(ipMatches('10.0.0.5', '10.0.0.6'), false);
|
||||
});
|
||||
|
||||
test('ipMatches: CIDR ranges', () => {
|
||||
assert.equal(ipMatches('10.1.2.3', '10.0.0.0/8'), true);
|
||||
assert.equal(ipMatches('11.1.2.3', '10.0.0.0/8'), false);
|
||||
assert.equal(ipMatches('192.168.1.50', '192.168.1.0/24'), true);
|
||||
assert.equal(ipMatches('192.168.2.50', '192.168.1.0/24'), false);
|
||||
assert.equal(ipMatches('203.0.113.9', '0.0.0.0/0'), true);
|
||||
assert.equal(ipMatches('not-an-ip', '10.0.0.0/8'), false);
|
||||
});
|
||||
|
||||
test('ipAllowed: empty list allows all', () => {
|
||||
assert.equal(ipAllowed('1.2.3.4', []), true);
|
||||
assert.equal(ipAllowed('1.2.3.4', ['10.0.0.0/8']), false);
|
||||
assert.equal(ipAllowed('10.9.9.9', ['10.0.0.0/8', '127.0.0.1']), true);
|
||||
});
|
||||
|
||||
test('buildNetworkGuard returns null when nothing configured', () => {
|
||||
assert.equal(buildNetworkGuard({ onDeny: () => {} }), null);
|
||||
});
|
||||
|
||||
test('buildNetworkGuard denies on host then ip, allows when both pass', () => {
|
||||
const guard = buildNetworkGuard({
|
||||
allowedHosts: ['gadm.tkmind.cn'],
|
||||
ipAllowlist: ['10.0.0.0/8'],
|
||||
onDeny: (_res, _req, reason) => `deny:${reason}`,
|
||||
});
|
||||
const mk = (host, ip) => ({ get: () => host, ip });
|
||||
|
||||
let nexted = false;
|
||||
const next = () => {
|
||||
nexted = true;
|
||||
};
|
||||
|
||||
assert.equal(guard(mk('evil.com', '10.1.1.1'), {}, next), 'deny:host');
|
||||
assert.equal(guard(mk('gadm.tkmind.cn', '8.8.8.8'), {}, next), 'deny:ip');
|
||||
assert.equal(nexted, false);
|
||||
guard(mk('gadm.tkmind.cn', '10.1.1.1'), {}, next);
|
||||
assert.equal(nexted, true);
|
||||
});
|
||||
@@ -0,0 +1,484 @@
|
||||
// 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
|
||||
*/
|
||||
export function createAdminApi({ jsonBody, getToken, ready, userAuth, llmProviderService, plazaPosts, plazaOps }) {
|
||||
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('/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;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// admin-server.mjs
|
||||
//
|
||||
// Standalone memind_adm service — the back-office process.
|
||||
//
|
||||
// Serves the platform super-admin API (/admin-api/*) and the plaza operations
|
||||
// console API (/api/ops/v1/*) on its own port, isolated from the public
|
||||
// user-facing server (server.mjs). In production this is fronted by
|
||||
// gadm.tkmind.cn; in dev the ops SPA (:3002) proxies to it directly.
|
||||
//
|
||||
// It trusts the shared session cookie (H5_COOKIE_DOMAIN=.tkmind.cn), so login
|
||||
// continues to happen on the main app domain — this process only reads the
|
||||
// session and authorizes admin/ops roles. No daemons, no public traffic.
|
||||
import express from 'express';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseCookies } from './auth.mjs';
|
||||
import { USER_COOKIE } from './user-auth.mjs';
|
||||
import { createAdminServices } from './admin-bootstrap.mjs';
|
||||
import { createAdminApi, createOpsApi } from './admin-routes.mjs';
|
||||
import { buildNetworkGuard, parseList } from './admin-guard.mjs';
|
||||
import { sendError } from './api-response.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Mirror server.mjs env loading so the admin service can run standalone.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(__dirname, '../../.env.local'));
|
||||
loadEnvFile(path.join(__dirname, '.env'));
|
||||
|
||||
const PORT = Number(process.env.ADMIN_PORT ?? 8082);
|
||||
const HOST = process.env.ADMIN_HOST ?? '127.0.0.1';
|
||||
const PUBLIC_HOST = process.env.ADMIN_PUBLIC_HOST ?? 'gadm.tkmind.cn';
|
||||
|
||||
const getToken = (req) => parseCookies(req.get('cookie'))[USER_COOKIE];
|
||||
|
||||
// Resolve the shared session cookie into req.currentUser, else 401.
|
||||
function resolveSessionUser(userAuth) {
|
||||
return async (req, res, next) => {
|
||||
const token = getToken(req);
|
||||
const me = token ? await userAuth.getMe(token) : null;
|
||||
if (!me) return res.status(401).json({ message: '未授权,请重新登录' });
|
||||
req.currentUser = me;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Console registry ----------------------------------------------------
|
||||
// Each back-office console is an independently mountable unit. A process serves
|
||||
// whichever subset ADMIN_CONSOLES names (default: all). Splitting into separate
|
||||
// processes later is purely a config change — run one instance with
|
||||
// ADMIN_CONSOLES=admin and another with ADMIN_CONSOLES=ops; no code changes.
|
||||
const CONSOLES = {
|
||||
admin: {
|
||||
label: 'platform super-admin',
|
||||
mountPath: '/admin-api',
|
||||
routeHint: '/admin-api/*',
|
||||
envPrefix: 'ADMIN_API', // ADMIN_API_ALLOWED_HOSTS / ADMIN_API_IP_ALLOWLIST
|
||||
build: ({ jsonBody, services }) =>
|
||||
// The admin router gates currentUser itself, then requireAdmin per route.
|
||||
createAdminApi({
|
||||
jsonBody,
|
||||
getToken,
|
||||
userAuth: services.userAuth,
|
||||
llmProviderService: services.llmProviderService,
|
||||
plazaPosts: services.plazaPosts,
|
||||
plazaOps: services.plazaOps,
|
||||
}),
|
||||
},
|
||||
ops: {
|
||||
label: 'plaza operations console',
|
||||
mountPath: '/api',
|
||||
routeHint: '/api/ops/v1/*',
|
||||
envPrefix: 'OPS_API', // OPS_API_ALLOWED_HOSTS / OPS_API_IP_ALLOWLIST
|
||||
build: ({ jsonBody, services }) => {
|
||||
const router = express.Router();
|
||||
router.use(resolveSessionUser(services.userAuth));
|
||||
router.use('/ops/v1', createOpsApi({ jsonBody, plazaOps: services.plazaOps }));
|
||||
return router;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function isLoopbackHostname(hostname) {
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
||||
}
|
||||
|
||||
// Same-origin / referer check for state-changing requests. Allows the public
|
||||
// admin host, loopback (dev), and any host that matches the request host.
|
||||
function csrfOriginCheck(req, res, next) {
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
|
||||
const host = req.get('host');
|
||||
const origin = req.get('origin');
|
||||
const referer = req.get('referer');
|
||||
if (!origin && !referer) return next();
|
||||
const requestHostname = (host ?? '').split(':')[0];
|
||||
const allowed = [origin, referer].some((value) => {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const source = new URL(value);
|
||||
if (source.host === host) return true;
|
||||
if (source.hostname === PUBLIC_HOST) return true;
|
||||
return isLoopbackHostname(requestHostname) && isLoopbackHostname(source.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!allowed) {
|
||||
return sendError(res, req, 403, 'csrf_failed', '来源校验失败');
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
function resolveEnabledConsoles() {
|
||||
const enabled = parseList(process.env.ADMIN_CONSOLES || 'admin,ops');
|
||||
if (!enabled.length) throw new Error('ADMIN_CONSOLES resolved to an empty set');
|
||||
const unknown = enabled.filter((key) => !CONSOLES[key]);
|
||||
if (unknown.length) {
|
||||
throw new Error(
|
||||
`unknown ADMIN_CONSOLES: ${unknown.join(', ')} (valid: ${Object.keys(CONSOLES).join(', ')})`,
|
||||
);
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const enabled = resolveEnabledConsoles();
|
||||
const services = await createAdminServices({ h5Root: __dirname });
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', true);
|
||||
app.disable('x-powered-by');
|
||||
|
||||
const jsonBody = express.json({ limit: '1mb' });
|
||||
const ctx = { jsonBody, services };
|
||||
|
||||
app.get('/healthz', (_req, res) =>
|
||||
res.json({ ok: true, service: 'memind_adm', consoles: enabled }),
|
||||
);
|
||||
|
||||
const summary = [];
|
||||
for (const key of enabled) {
|
||||
const console_ = CONSOLES[key];
|
||||
const allowedHosts = parseList(process.env[`${console_.envPrefix}_ALLOWED_HOSTS`]);
|
||||
const ipAllowlist = parseList(process.env[`${console_.envPrefix}_IP_ALLOWLIST`]);
|
||||
const guard = buildNetworkGuard({
|
||||
allowedHosts,
|
||||
ipAllowlist,
|
||||
onDeny: (res, req, reason) =>
|
||||
sendError(
|
||||
res,
|
||||
req,
|
||||
403,
|
||||
'forbidden_network',
|
||||
reason === 'host' ? '主机不在白名单' : 'IP 不在白名单',
|
||||
),
|
||||
});
|
||||
|
||||
app.use(console_.mountPath, csrfOriginCheck);
|
||||
if (guard) app.use(console_.mountPath, guard);
|
||||
app.use(console_.mountPath, console_.build(ctx));
|
||||
|
||||
summary.push({ key, console_, allowedHosts, ipAllowlist, guarded: Boolean(guard) });
|
||||
}
|
||||
|
||||
app.use((req, res) => res.status(404).json({ message: 'not found' }));
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`memind_adm @ http://${HOST}:${PORT} (public host: ${PUBLIC_HOST})`);
|
||||
for (const { console_, allowedHosts, ipAllowlist, guarded } of summary) {
|
||||
const fence = guarded
|
||||
? ` [hosts: ${allowedHosts.join(',') || '*'} | ip: ${ipAllowlist.join(',') || '*'}]`
|
||||
: '';
|
||||
console.log(` ${console_.routeHint.padEnd(16)} ${console_.label}${fence}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('admin-server failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
+6
-3
@@ -1,7 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const apiProxy = process.env.OPS_API_PROXY ?? 'http://127.0.0.1:8081';
|
||||
// /auth (login + session status) is served by the public portal; the ops
|
||||
// console API (/api/ops/v1) lives in the standalone memind_adm service.
|
||||
const authProxy = process.env.OPS_API_PROXY ?? 'http://127.0.0.1:8081';
|
||||
const adminProxy = process.env.OPS_ADMIN_PROXY ?? 'http://127.0.0.1:8082';
|
||||
|
||||
export default defineConfig({
|
||||
base: '/ops/',
|
||||
@@ -9,8 +12,8 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 3002,
|
||||
proxy: {
|
||||
'/api': apiProxy,
|
||||
'/auth': apiProxy,
|
||||
'/api': adminProxy,
|
||||
'/auth': authProxy,
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
+2
-1
@@ -18,6 +18,7 @@
|
||||
"setup:plaza-tls": "node scripts/plaza-local-tls.mjs",
|
||||
"dev:vite": "vite",
|
||||
"dev:server": "node server.mjs",
|
||||
"dev:adm": "node admin-server.mjs",
|
||||
"dev:plaza-express": "node plaza-server.mjs",
|
||||
"dev:ops": "cd ops && npm run dev",
|
||||
"ops:grant": "node scripts/grant-ops-role.mjs",
|
||||
@@ -25,7 +26,7 @@
|
||||
"seed:plaza-covers": "node scripts/seed-plaza-covers.mjs",
|
||||
"enrich:plaza": "node scripts/enrich-plaza-posts.mjs",
|
||||
"build": "vite build",
|
||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs",
|
||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs",
|
||||
"test:mindspace-e2e": "node scripts/mindspace-e2e.mjs",
|
||||
"test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs",
|
||||
"test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs",
|
||||
|
||||
+14
-1
@@ -13,7 +13,9 @@ const portalPort = Number(process.env.H5_PORT ?? 8081);
|
||||
const vitePort = Number(process.env.VITE_PORT ?? 5173);
|
||||
const plazaPort = Number(process.env.PLAZA_PORT ?? 3001);
|
||||
const opsPort = Number(process.env.OPS_PORT ?? 3002);
|
||||
const adminPort = Number(process.env.ADMIN_PORT ?? 8082);
|
||||
const portalUrl = `http://127.0.0.1:${portalPort}`;
|
||||
const adminUrl = `http://127.0.0.1:${adminPort}`;
|
||||
const plazaHost = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn';
|
||||
const plazaPublicPort = Number(process.env.PLAZA_PUBLIC_PORT ?? 443);
|
||||
const plazaPublicScheme = String(process.env.PLAZA_PUBLIC_BASE ?? 'https://plaza.tkmind.cn').startsWith('http://')
|
||||
@@ -69,6 +71,7 @@ function spawnChild(command, args, label, cwd = root, extraEnv = {}) {
|
||||
}
|
||||
|
||||
let server;
|
||||
let admin;
|
||||
let plaza;
|
||||
let plazaProxy;
|
||||
let ops;
|
||||
@@ -79,6 +82,7 @@ function shutdown(code = 0) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
server?.kill('SIGTERM');
|
||||
admin?.kill('SIGTERM');
|
||||
plaza?.kill('SIGTERM');
|
||||
plazaProxy?.kill('SIGTERM');
|
||||
ops?.kill('SIGTERM');
|
||||
@@ -121,7 +125,9 @@ const viteEnv = {
|
||||
};
|
||||
|
||||
const opsEnv = {
|
||||
// /auth (login/session) stays on the public portal; /api (ops console) hits memind_adm.
|
||||
OPS_API_PROXY: portalUrl,
|
||||
OPS_ADMIN_PROXY: adminUrl,
|
||||
VITE_PLAZA_BASE: plazaPublicBase,
|
||||
VITE_MINDSPACE_BASE: mindSpacePublicBase,
|
||||
};
|
||||
@@ -210,8 +216,9 @@ function plazaHostConfigured(host) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`==> 清理端口 ${portalPort} / ${vitePort} / ${plazaPort} / ${opsPort}...`);
|
||||
console.log(`==> 清理端口 ${portalPort} / ${adminPort} / ${vitePort} / ${plazaPort} / ${opsPort}...`);
|
||||
freePort(portalPort);
|
||||
freePort(adminPort);
|
||||
freePort(vitePort);
|
||||
freePort(plazaPort);
|
||||
freePort(opsPort);
|
||||
@@ -233,6 +240,11 @@ try {
|
||||
await waitFor(portalUrl, async (url) => (await fetch(`${url}/auth/status`)).ok, 'Portal');
|
||||
console.log(`==> Portal 就绪: ${portalUrl}`);
|
||||
|
||||
console.log('==> 启动 memind_adm (admin-server.mjs)...');
|
||||
admin = spawnChild('node', ['admin-server.mjs'], 'admin');
|
||||
await waitFor(adminUrl, async (url) => (await fetch(`${url}/healthz`)).ok, 'Admin');
|
||||
console.log(`==> memind_adm 就绪: ${adminUrl}`);
|
||||
|
||||
console.log(`==> 启动 Plaza Next.js @ ${plazaPublicBase} (bind ${plazaHost})`);
|
||||
plaza = spawnChild(
|
||||
'npm',
|
||||
@@ -288,6 +300,7 @@ try {
|
||||
console.log(` (setup) sudo pnpm setup:plaza-local && sudo pnpm dev:plaza-proxy`);
|
||||
console.log(` Ops 审核后台 http://localhost:${opsPort}/ops/`);
|
||||
console.log(` API / Portal ${portalUrl}`);
|
||||
console.log(` memind_adm ${adminUrl} (gadm.tkmind.cn)`);
|
||||
console.log('');
|
||||
console.log('首次使用 Ops:先登录 MindSpace,再执行 node scripts/grant-ops-role.mjs admin ops_admin');
|
||||
} catch (err) {
|
||||
|
||||
+1
-439
@@ -44,7 +44,7 @@ import {
|
||||
import { createPlazaRedis, createNoopPlazaRedis } from './plaza-redis.mjs';
|
||||
import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs';
|
||||
import { createPlazaSeoService } from './plaza-seo.mjs';
|
||||
import { createPlazaOpsService, hasOpsRole } from './plaza-ops.mjs';
|
||||
import { createPlazaOpsService } from './plaza-ops.mjs';
|
||||
import {
|
||||
preparePublicationHtmlForEmbed,
|
||||
isPlazaEmbedRequest,
|
||||
@@ -736,324 +736,6 @@ app.post('/auth/logout', async (req, res) => {
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ============ Admin API ============
|
||||
|
||||
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) => {
|
||||
await userAuthReady;
|
||||
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
||||
const me = await userAuth.getMe(userToken(req));
|
||||
if (!me) return res.status(401).json({ message: '未登录' });
|
||||
req.currentUser = me;
|
||||
next();
|
||||
});
|
||||
|
||||
adminApi.get('/users', requireAdmin, async (_req, res) => {
|
||||
const users = await userAuth.listUsers();
|
||||
res.json({ users });
|
||||
});
|
||||
|
||||
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 limit = Number(req.query.limit ?? 50);
|
||||
const records = await userAuth.listUsageRecords({ userId, limit });
|
||||
res.json({ records });
|
||||
});
|
||||
|
||||
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 limit = Number(req.query.limit ?? 50);
|
||||
const entries = await userAuth.listBillingLedger({ userId, limit });
|
||||
res.json({ entries });
|
||||
});
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/auth/usage', async (req, res) => {
|
||||
await userAuthReady;
|
||||
@@ -1167,7 +849,6 @@ app.post('/webhooks/wechat-pay/notify', wechatNotifyBody, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/admin-api', adminApi);
|
||||
|
||||
// ============ Wiki API ============
|
||||
|
||||
@@ -1408,124 +1089,6 @@ function plazaClientIp(req) {
|
||||
return req.ip;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
function mindSpaceError(res, req, error) {
|
||||
const statusByCode = {
|
||||
@@ -3183,7 +2746,6 @@ api.use(async (req, res, next) => {
|
||||
return tkmindProxy.proxyFallback(req, res);
|
||||
});
|
||||
|
||||
api.use('/ops/v1', opsApi);
|
||||
|
||||
api.use(
|
||||
createProxyMiddleware({
|
||||
|
||||
+94
-57
@@ -682,20 +682,40 @@ export function createUserAuth(pool, options = {}) {
|
||||
return { ok: true, balanceCents };
|
||||
};
|
||||
|
||||
const listUsers = async () => {
|
||||
const listUsers = async ({ page = 1, pageSize = 20, search = '', role = '', status = '' } = {}) => {
|
||||
const safePageSize = Math.min(Math.max(Number(pageSize) || 20, 1), 100);
|
||||
const safePage = Math.max(Number(page) || 1, 1);
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
const params = [];
|
||||
const clauses = [];
|
||||
if (search) {
|
||||
clauses.push('(u.username LIKE ? OR u.display_name LIKE ?)');
|
||||
params.push(`%${search}%`, `%${search}%`);
|
||||
}
|
||||
if (role) { clauses.push('u.role = ?'); params.push(role); }
|
||||
if (status) { clauses.push('u.status = ?'); params.push(status); }
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const [[{ total }]] = await pool.query(
|
||||
`SELECT COUNT(*) AS total FROM h5_users u ${where}`,
|
||||
params,
|
||||
);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
|
||||
u.plan_type, u.workspace_root,
|
||||
u.created_at, u.updated_at, w.balance_cents, w.tokens_used
|
||||
FROM h5_users u
|
||||
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
|
||||
ORDER BY u.created_at DESC`,
|
||||
${where}
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT ${safePageSize} OFFSET ${offset}`,
|
||||
params,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...publicUser(row),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
}));
|
||||
return {
|
||||
users: rows.map((row) => ({ ...publicUser(row), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at) })),
|
||||
total: Number(total),
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
};
|
||||
};
|
||||
|
||||
const createUser = async ({
|
||||
@@ -1039,72 +1059,89 @@ export function createUserAuth(pool, options = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const listUsageRecords = async ({ userId = null, limit = 50 } = {}) => {
|
||||
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||
const params = [];
|
||||
let where = '';
|
||||
if (userId) {
|
||||
where = 'WHERE r.user_id = ?';
|
||||
params.push(userId);
|
||||
const listUsageRecords = async ({ userId = null, page = 1, pageSize = 20, limit = null } = {}) => {
|
||||
// legacy: if limit is passed (from dashboard summary), skip pagination
|
||||
if (limit !== null) {
|
||||
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||
const params = [];
|
||||
const where = userId ? 'WHERE r.user_id = ?' : '';
|
||||
if (userId) params.push(userId);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
|
||||
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
|
||||
FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id
|
||||
${where} ORDER BY r.created_at DESC LIMIT ${safeLimit}`,
|
||||
params,
|
||||
);
|
||||
return rows.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), createdAt: Number(row.created_at) }));
|
||||
}
|
||||
const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200);
|
||||
const safePage = Math.max(Number(page) || 1, 1);
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
const params = [];
|
||||
const where = userId ? 'WHERE r.user_id = ?' : '';
|
||||
if (userId) params.push(userId);
|
||||
const [[{ total }]] = await pool.query(
|
||||
`SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`,
|
||||
params,
|
||||
);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
|
||||
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
|
||||
FROM h5_usage_records r
|
||||
JOIN h5_users u ON u.id = r.user_id
|
||||
FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id
|
||||
${where}
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT ${safeLimit}`,
|
||||
LIMIT ${safePageSize} OFFSET ${offset}`,
|
||||
params,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
userId: row.user_id,
|
||||
username: row.username,
|
||||
agentSessionId: row.agent_session_id,
|
||||
requestId: row.request_id,
|
||||
inputTokens: Number(row.input_tokens),
|
||||
outputTokens: Number(row.output_tokens),
|
||||
costCents: Number(row.cost_cents),
|
||||
balanceAfterCents: Number(row.balance_after_cents),
|
||||
createdAt: Number(row.created_at),
|
||||
}));
|
||||
return {
|
||||
records: rows.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), createdAt: Number(row.created_at) })),
|
||||
total: Number(total),
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
};
|
||||
};
|
||||
|
||||
const listBillingLedger = async ({ userId = null, limit = 50, types = null } = {}) => {
|
||||
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||
const params = [];
|
||||
const clauses = [];
|
||||
if (userId) {
|
||||
clauses.push('l.user_id = ?');
|
||||
params.push(userId);
|
||||
const listBillingLedger = async ({ userId = null, page = 1, pageSize = 20, limit = null, types = null } = {}) => {
|
||||
const buildWhere = (params) => {
|
||||
const clauses = [];
|
||||
if (userId) { clauses.push('l.user_id = ?'); params.push(userId); }
|
||||
if (Array.isArray(types) && types.length) {
|
||||
clauses.push(`l.type IN (${types.map(() => '?').join(', ')})`);
|
||||
params.push(...types);
|
||||
}
|
||||
return clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
};
|
||||
const mapRow = (row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, type: row.type, amountCents: Number(row.amount_cents), tokens: Number(row.tokens), sessionId: row.session_id, note: row.note, createdAt: Number(row.created_at) });
|
||||
// legacy: if limit is passed (from dashboard summary), skip pagination
|
||||
if (limit !== null) {
|
||||
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||
const params = [];
|
||||
const where = buildWhere(params);
|
||||
const [rows] = await pool.query(`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens, l.session_id, l.note, l.created_at FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id ${where} ORDER BY l.created_at DESC LIMIT ${safeLimit}`, params);
|
||||
return rows.map(mapRow);
|
||||
}
|
||||
if (Array.isArray(types) && types.length) {
|
||||
clauses.push(`l.type IN (${types.map(() => '?').join(', ')})`);
|
||||
params.push(...types);
|
||||
}
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200);
|
||||
const safePage = Math.max(Number(page) || 1, 1);
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
const countParams = [];
|
||||
const where = buildWhere(countParams);
|
||||
const [[{ total }]] = await pool.query(
|
||||
`SELECT COUNT(*) AS total FROM h5_billing_ledger l ${where}`,
|
||||
countParams,
|
||||
);
|
||||
const dataParams = [];
|
||||
const dataWhere = buildWhere(dataParams);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens,
|
||||
l.session_id, l.note, l.created_at
|
||||
FROM h5_billing_ledger l
|
||||
JOIN h5_users u ON u.id = l.user_id
|
||||
${where}
|
||||
FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id
|
||||
${dataWhere}
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT ${safeLimit}`,
|
||||
params,
|
||||
LIMIT ${safePageSize} OFFSET ${offset}`,
|
||||
dataParams,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
userId: row.user_id,
|
||||
username: row.username,
|
||||
type: row.type,
|
||||
amountCents: Number(row.amount_cents),
|
||||
tokens: Number(row.tokens),
|
||||
sessionId: row.session_id,
|
||||
note: row.note,
|
||||
createdAt: Number(row.created_at),
|
||||
}));
|
||||
return { entries: rows.map(mapRow), total: Number(total), page: safePage, pageSize: safePageSize };
|
||||
};
|
||||
|
||||
const getAdminSummary = async () => {
|
||||
|
||||
+3
-1
@@ -4,6 +4,8 @@ import react from '@vitejs/plugin-react';
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const portalTarget = env.H5_DEV_PORTAL ?? 'http://127.0.0.1:8081';
|
||||
// Platform super-admin API now lives in the standalone memind_adm service.
|
||||
const adminTarget = env.H5_DEV_ADMIN ?? 'http://127.0.0.1:8082';
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
@@ -14,7 +16,7 @@ export default defineConfig(({ mode }) => {
|
||||
proxy: {
|
||||
'/api': portalTarget,
|
||||
'/auth': portalTarget,
|
||||
'/admin-api': portalTarget,
|
||||
'/admin-api': adminTarget,
|
||||
'/MindSpace': portalTarget,
|
||||
'^/u/': portalTarget,
|
||||
'^/s/': portalTarget,
|
||||
|
||||
Reference in New Issue
Block a user