From a2333f991b5209ac6afb37fc49846166f29ecc91 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 20 Jun 2026 16:52:13 +0800 Subject: [PATCH] Move admin auth local --- docs/DEPLOY.md | 32 ++++- package.json | 1 + scripts/init-admin.mjs | 38 ++++++ scripts/remote_restart.sh | 5 +- server/app.mjs | 128 ++++++++++++++++-- server/bootstrap.mjs | 47 +++++-- server/index.mjs | 29 ++++- server/local-auth.mjs | 267 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 508 insertions(+), 39 deletions(-) create mode 100644 scripts/init-admin.mjs create mode 100644 server/local-auth.mjs diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 5985692..57c6731 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -9,7 +9,7 @@ | 部署目标 | `john@100.99.38.66:/Users/john/Project/memind_adm` | | 服务端口 | `5174` | | 运行方式 | `vite preview`(静态资源 + API 反代) | -| 后端 API | 远端 `.env` 中 `ADM_DEV_BACKEND`(默认 `http://127.0.0.1:8081`) | +| 后端 API | 本仓库 `server/`(`ADM_API_PORT` 默认 `8085`,与 Memind portal 解耦) | | 日志 | `/Users/john/Project/memind_adm/adm-preview.log` | ## 前置条件 @@ -19,7 +19,21 @@ ssh john@100.99.38.66 ``` 2. 本机已安装 Node.js,项目依赖已安装(`npm install`)。 -3. 100 服务器上后端(Memind portal / `server.mjs`)在 `8081` 运行,否则页面能打开但登录、API 会失败。 +3. 100 服务器上 **memind_adm Admin API** 在 `8085` 运行(`remote_restart.sh` 会自动启动),MySQL 与 Memind 共用。 + +## gadm 独立登录 + +`https://gadm.tkmind.cn` 应部署 **本仓库 memind_adm**,勿再反代到 `Memind/ops` Vite dev(旧版未登录会跳 `localhost:5173`)。 + +| 项 | 说明 | +|---|---| +| 登录 | 本页 `/auth/login`,账号来自共用 MySQL | +| 会话 | 设置 `H5_PUBLIC_BASE_URL=https://gadm.tkmind.cn`,**不要**设 `H5_COOKIE_DOMAIN=.tkmind.cn` | +| 路径 | `https://gadm.tkmind.cn/` 直达超管后台;Plaza 运营在 `/ops`;`/ops/admin` 已废弃 | +| API | `ADM_DEV_BACKEND=http://127.0.0.1:8085`(preview 反代目标) | +| 初始化 admin | 首次部署后运行 `npm run admin:init`,密码只写入数据库,不放 `.env` | + +nginx 示例见 `scripts/gadm-nginx.conf.example`(`/ops/` → preview,`/auth` `/admin-api` `/api` → Admin API `8085`)。 ## 更新部署(推荐) @@ -91,11 +105,14 @@ ssh john@100.99.38.66 'tail -f /Users/john/Project/memind_adm/adm-preview.log' 编辑远端 `.env`(首次部署后路径:`/Users/john/Project/memind_adm/.env`): ```env -# preview 反代目标(/auth、/admin-api、/api) -ADM_DEV_BACKEND=http://127.0.0.1:8081 +# preview 反代目标(/auth、/admin-api、/api)— 指向本仓库 Admin API +ADM_API_PORT=8085 +ADM_DEV_BACKEND=http://127.0.0.1:8085 +H5_PUBLIC_BASE_URL=https://gadm.tkmind.cn +VITE_BASE_PATH=/ops -# 「返回对话」跳转地址 -VITE_MAIN_APP_URL=http://127.0.0.1:8081 +# 「返回对话」跳转主 H5(可选) +VITE_MAIN_APP_URL=https://h5.tkmind.cn ``` 修改 `.env` 后需重启服务生效。 @@ -105,7 +122,8 @@ VITE_MAIN_APP_URL=http://127.0.0.1:8081 | 现象 | 处理 | |---|---| | `无法 SSH 到 john@100.99.38.66` | 确认 Tailscale 在线;必要时 `ssh-copy-id john@100.99.38.66` | -| 首页 200 但登录失败 | 检查 100 上 `8081` 后端是否运行 | +| 首页 200 但登录失败 | 检查 100 上 `8085` Admin API 是否运行(`curl http://127.0.0.1:8085/health`) | +| 打开后跳 localhost:5173 | nginx 仍指向 Memind/ops dev;改反代到 memind_adm `:5174`,并重新部署 | | `dist 不存在` | 先在本机 `npm run build`,或完整执行 `./scripts/rsync_to_server.sh` | | 启动失败 | 查看 `adm-preview.log`;确认远端 Node 在 PATH 中(需 Homebrew `node@22` 等) | | 端口被占用 | `remote_restart.sh` 会自动 kill 旧进程;仍异常时可手动 `lsof -iTCP:5174 -sTCP:LISTEN` | diff --git a/package.json b/package.json index 2c9f2db..3a8b2d1 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "node scripts/dev.mjs", "dev:web": "vite", "dev:server": "node server/index.mjs", + "admin:init": "node scripts/init-admin.mjs", "build": "vite build", "preview": "node scripts/preview.mjs", "dev:preview": "vite preview" diff --git a/scripts/init-admin.mjs b/scripts/init-admin.mjs new file mode 100644 index 0000000..d44e3b5 --- /dev/null +++ b/scripts/init-admin.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; +import { createDbPool, isDatabaseConfigured } from '../server/db.mjs'; +import { createLocalUserAuth } from '../server/local-auth.mjs'; + +if (!isDatabaseConfigured()) { + console.error('MySQL 未配置,请先设置 DATABASE_URL 或 MYSQL_*'); + process.exit(1); +} + +const rl = readline.createInterface({ input, output }); +const username = (await rl.question('admin username [admin]: ')).trim() || 'admin'; +const password = await rl.question('admin password: '); +const confirm = await rl.question('confirm password: '); +rl.close(); + +if (!password || password !== confirm) { + console.error('密码为空或两次输入不一致'); + process.exit(1); +} + +const pool = createDbPool(); +const auth = createLocalUserAuth(pool); +await auth.ensureAdminUser().catch(() => {}); +await pool.execute( + `INSERT INTO auth_users (username, display_name, role, status, password_hash, balance_cents) + VALUES (?, ?, 'admin', 'active', ?, 0) + ON DUPLICATE KEY UPDATE + display_name = VALUES(display_name), + role = VALUES(role), + status = VALUES(status), + password_hash = VALUES(password_hash)`, + [username, username, auth.hashPassword(password)], +); + +console.log(`admin 账号已写入数据库: ${username}`); +await pool.end(); diff --git a/scripts/remote_restart.sh b/scripts/remote_restart.sh index ef4ceec..25f1967 100755 --- a/scripts/remote_restart.sh +++ b/scripts/remote_restart.sh @@ -52,10 +52,7 @@ sleep 2 if curl -sf "http://127.0.0.1:${PORT}/" >/dev/null; then echo "✅ memind_adm 已就绪: http://127.0.0.1:${PORT}/" curl -sf "http://127.0.0.1:${PORT}/" >/dev/null && echo " 首页 OK" - curl -sf -X POST "http://127.0.0.1:${PORT}/auth/login" \ - -H 'Content-Type: application/json' \ - -d '{"username":"admin","password":"981122"}' >/dev/null \ - && echo " 登录 OK" || echo " ⚠️ 登录接口异常,请检查 ${API_LOG}" + echo " 登录请使用数据库中已初始化的 admin 账号" else echo "❌ 启动失败,最近日志:" >&2 tail -n 40 "${LOG}" >&2 || true diff --git a/server/app.mjs b/server/app.mjs index f50b53c..025ae37 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,8 +1,25 @@ import express from 'express'; import { listUsagePaged, listLedgerPaged } from './pagination.mjs'; +function asyncHandler(handler) { + return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next); +} + +function wrapRouterAsync(router) { + for (const method of ['get', 'post', 'put', 'patch', 'delete']) { + const original = router[method].bind(router); + router[method] = (path, ...handlers) => + original( + path, + ...handlers.map((handler) => + typeof handler === 'function' && handler.length < 4 ? asyncHandler(handler) : handler, + ), + ); + } +} + export function createAdminApp(services) { - const { userAuth, llmProviderService, pool, ready } = services; + const { userAuth, llmProviderService, pool, ready, wechatAdmin } = services; const app = express(); app.set('trust proxy', 1); @@ -12,11 +29,13 @@ export function createAdminApp(services) { let cookieHelpers = null; const cookieReady = ready.then(async () => { - const [userAuthMod, authMod] = await Promise.all([ - services.importUserAuthModule(), - services.importAuthModule(), - ]); - cookieHelpers = { ...userAuthMod, parseCookies: authMod.parseCookies }; + cookieHelpers = { + parseCookies: services.parseCookies, + USER_COOKIE: services.USER_COOKIE, + userLoginCookies: services.userLoginCookies, + clearUserSessionCookie: services.clearUserSessionCookie, + resolveCookieDomainForRequest: services.resolveCookieDomainForRequest, + }; }); function userToken(req) { @@ -44,15 +63,19 @@ export function createAdminApp(services) { res.json({ ok: true, service: 'memind_adm' }); }); - app.get('/auth/status', async (req, res) => { + app.get('/healthz', (_req, res) => { + res.json({ ok: true, service: 'memind_adm' }); + }); + + app.get('/auth/status', asyncHandler(async (req, res) => { await cookieReady; await ready; const me = await userAuth.getMe(userToken(req)); if (!me) return res.json({ authenticated: false, mode: 'user' }); return res.json({ authenticated: true, user: me, mode: 'user' }); - }); + })); - app.post('/auth/login', jsonBody, async (req, res) => { + app.post('/auth/login', jsonBody, asyncHandler(async (req, res) => { await cookieReady; await ready; const { username, password } = req.body ?? {}; @@ -69,27 +92,28 @@ export function createAdminApp(services) { } setUserLoginCookies(res, req, result.token); return res.json({ authenticated: true, user: result.user, mode: 'user' }); - }); + })); - app.post('/auth/logout', async (req, res) => { + app.post('/auth/logout', asyncHandler(async (req, res) => { await cookieReady; await ready; await userAuth.revoke(userToken(req)); clearUserLoginCookies(res, req); res.status(204).end(); - }); + })); const adminApi = express.Router(); + wrapRouterAsync(adminApi); adminApi.use(jsonBody); - adminApi.use(async (req, res, next) => { + adminApi.use(asyncHandler(async (req, res, next) => { await cookieReady; await ready; const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); req.currentUser = me; next(); - }); + })); const requireAdmin = (req, res, next) => { if (!req.currentUser || req.currentUser.role !== 'admin') { @@ -99,6 +123,14 @@ export function createAdminApp(services) { next(); }; + function resolveSessionUser(req, res, next) { + if (!req.currentUser) { + res.status(401).json({ message: '未授权,请重新登录' }); + return; + } + next(); + } + adminApi.get('/users', requireAdmin, async (req, res) => { const result = await userAuth.listUsers({ page: Number(req.query.page) || 1, @@ -161,6 +193,52 @@ export function createAdminApp(services) { 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('/capabilities/catalog', requireAdmin, (_req, res) => { res.json({ catalog: userAuth.capabilityCatalog }); }); @@ -363,5 +441,27 @@ export function createAdminApp(services) { app.use('/admin-api', adminApi); + if (services.createOpsApi) { + const opsApi = express.Router(); + wrapRouterAsync(opsApi); + opsApi.use(asyncHandler(async (req, res, next) => { + await cookieReady; + await ready; + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: '未授权,请重新登录' }); + req.currentUser = me; + next(); + })); + opsApi.use(resolveSessionUser); + opsApi.use('/ops/v1', services.createOpsApi({ jsonBody, plazaOps: services.plazaOps })); + app.use('/api', opsApi); + } + + app.use((err, _req, res, _next) => { + console.error('Admin API request failed:', err); + if (res.headersSent) return; + res.status(500).json({ message: err instanceof Error ? err.message : '管理后台请求失败' }); + }); + return app; } diff --git a/server/bootstrap.mjs b/server/bootstrap.mjs index 659ba17..d5db0fa 100644 --- a/server/bootstrap.mjs +++ b/server/bootstrap.mjs @@ -1,7 +1,8 @@ import path from 'node:path'; import { createDbPool, isDatabaseConfigured } from './db.mjs'; -import { importMemind, resolveMemindLib } from './lib-path.mjs'; import { projectRoot } from './load-env.mjs'; +import { createLocalUserAuth } from './local-auth.mjs'; +import { importMemind, resolveMemindLib } from './lib-path.mjs'; export async function bootstrapAdminServices() { if (!isDatabaseConfigured()) { @@ -10,19 +11,44 @@ export async function bootstrapAdminServices() { const memindLib = resolveMemindLib(); const pool = createDbPool(); - const { createUserAuth } = await importMemind('user-auth.mjs'); const { createLlmProviderService } = await importMemind('llm-providers.mjs'); + const { createWechatAdminService } = await importMemind('wechat-admin.mjs'); + const { loadWechatMpConfig } = await importMemind('wechat-mp.mjs'); + const { createPlazaPostService, formatPostRow } = await importMemind('plaza-posts.mjs'); + const { createPlazaInteractionService } = await importMemind('plaza-interactions.mjs'); + const { createPlazaOpsService } = await importMemind('plaza-ops.mjs'); + const { createNoopPlazaRedis } = await importMemind('plaza-redis.mjs'); + const { ensureAlgorithmConfig, loadAlgorithmConfig } = await importMemind('plaza-algorithm.mjs'); + const { createOpsApi } = await importMemind('admin-routes.mjs'); const usersRoot = process.env.H5_USERS_ROOT?.trim() ?? path.join(projectRoot, 'data', 'users'); const apiTarget = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006'; - const apiSecret = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret'; + const plazaRedis = createNoopPlazaRedis(); - const userAuth = createUserAuth(pool, { - usersRoot, - h5Root: memindLib, - defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500), + 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: () => {}, + 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?.(), + }); + + const userAuth = createLocalUserAuth(pool); await userAuth.ensureAdminUser(); @@ -30,10 +56,15 @@ export async function bootstrapAdminServices() { apiTarget, apiSecret, }); + const wechatAdmin = createWechatAdminService(pool, { + config: loadWechatMpConfig(), + scheduleEnabled: process.env.H5_SCHEDULE_ENABLED === '1', + reminderWorkerEnabled: process.env.H5_REMINDER_WORKER_ENABLED === '1', + }); console.log(`Admin DB connected (${process.env.MYSQL_DATABASE ?? 'via DATABASE_URL'})`); console.log(`Users root: ${usersRoot}`); console.log(`Memind lib: ${memindLib}`); - return { pool, userAuth, llmProviderService }; + return { pool, userAuth, llmProviderService, plazaOps, createOpsApi, wechatAdmin }; } diff --git a/server/index.mjs b/server/index.mjs index d44ebee..ee7b5c2 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -1,9 +1,15 @@ import { ensureArgon2Sync } from './argon2-polyfill.mjs'; import { loadProjectEnv } from './load-env.mjs'; +import { + USER_COOKIE, + clearUserSessionCookie, + parseCookies, + resolveCookieDomainForRequest, + userLoginCookies, +} from './local-auth.mjs'; ensureArgon2Sync(); import { bootstrapAdminServices } from './bootstrap.mjs'; -import { importMemind } from './lib-path.mjs'; import { createAdminApp } from './app.mjs'; loadProjectEnv(); @@ -13,17 +19,28 @@ const port = Number(process.env.ADM_API_PORT ?? 8085); const ready = bootstrapAdminServices(); const services = { ready, - importUserAuthModule: () => importMemind('user-auth.mjs'), - importAuthModule: () => importMemind('auth.mjs'), + USER_COOKIE, + parseCookies, + userLoginCookies, + clearUserSessionCookie, + resolveCookieDomainForRequest, }; ready - .then(({ pool, userAuth, llmProviderService }) => { - Object.assign(services, { pool, userAuth, llmProviderService }); + .then(({ pool, userAuth, llmProviderService, plazaOps, createOpsApi, wechatAdmin }) => { + Object.assign(services, { + pool, + userAuth, + llmProviderService, + plazaOps, + createOpsApi, + wechatAdmin, + }); const app = createAdminApp(services); - app.listen(port, '127.0.0.1', () => { + const server = app.listen(port, '127.0.0.1', () => { console.log(`TKMind Admin API @ http://127.0.0.1:${port}`); }); + globalThis.__tkmindAdminServer = server; }) .catch((err) => { console.error('Admin server bootstrap failed:', err); diff --git a/server/local-auth.mjs b/server/local-auth.mjs new file mode 100644 index 0000000..1818b27 --- /dev/null +++ b/server/local-auth.mjs @@ -0,0 +1,267 @@ +import crypto from 'node:crypto'; + +const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30; + +function now() { + return new Date(); +} + +function hashPassword(password, salt = crypto.randomBytes(16).toString('hex')) { + const digest = crypto.scryptSync(String(password), salt, 64).toString('hex'); + return `scrypt$${salt}$${digest}`; +} + +function verifyPassword(password, stored) { + const [scheme, salt, digest] = String(stored ?? '').split('$'); + if (scheme !== 'scrypt' || !salt || !digest) return false; + const next = crypto.scryptSync(String(password), salt, 64).toString('hex'); + return crypto.timingSafeEqual(Buffer.from(next, 'hex'), Buffer.from(digest, 'hex')); +} + +function rowToUser(row) { + if (!row) return null; + return { + id: String(row.id), + username: row.username, + displayName: row.display_name ?? row.username, + role: row.role, + status: row.status, + balanceCents: Number(row.balance_cents ?? 0), + workspaceRoot: row.workspace_root ?? '', + createdAt: row.created_at ? new Date(row.created_at).getTime() : Date.now(), + updatedAt: row.updated_at ? new Date(row.updated_at).getTime() : Date.now(), + }; +} + +function defaultCapabilities() { + return {}; +} + +export function parseCookies(cookieHeader = '') { + return Object.fromEntries( + cookieHeader.split(';').map((part) => { + const index = part.indexOf('='); + if (index < 0) return ['', '']; + return [decodeURIComponent(part.slice(0, index).trim()), decodeURIComponent(part.slice(index + 1).trim())]; + }).filter(([k]) => k), + ); +} + +export const USER_COOKIE = 'tkmind_admin_token'; + +export function userLoginCookies(token, secure, domain) { + const parts = [ + `${USER_COOKIE}=${encodeURIComponent(token)}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + secure ? 'Secure' : null, + domain ? `Domain=${domain}` : null, + `Max-Age=${SESSION_TTL_MS / 1000}`, + ].filter(Boolean); + return `${parts.join('; ')}`; +} + +export function clearUserSessionCookie(secure, domain) { + const parts = [ + `${USER_COOKIE}=`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + secure ? 'Secure' : null, + domain ? `Domain=${domain}` : null, + 'Max-Age=0', + ].filter(Boolean); + return `${parts.join('; ')}`; +} + +export function resolveCookieDomainForRequest(_req) { + return ''; +} + +export function createLocalUserAuth(pool) { + const capabilityCatalog = []; + const policyCatalog = []; + const skillCatalog = []; + + async function ensureTables() { + await pool.execute(` + CREATE TABLE IF NOT EXISTS auth_users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(191) NOT NULL UNIQUE, + display_name VARCHAR(191) NULL, + role VARCHAR(32) NOT NULL DEFAULT 'user', + status VARCHAR(32) NOT NULL DEFAULT 'active', + password_hash VARCHAR(255) NOT NULL, + balance_cents BIGINT NOT NULL DEFAULT 0, + workspace_root VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + `); + await pool.execute(` + CREATE TABLE IF NOT EXISTS auth_sessions ( + token VARCHAR(191) NOT NULL PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_auth_sessions_user_id (user_id), + INDEX idx_auth_sessions_expires_at (expires_at) + ) + `); + } + + async function ensureAdminUser() { + await ensureTables(); + const [rows] = await pool.execute('SELECT id FROM auth_users WHERE username = ? LIMIT 1', ['admin']); + if (rows.length) return; + throw new Error('admin 账号未初始化,请先运行 npm run admin:init'); + } + + async function getUserByUsername(username) { + const [rows] = await pool.execute('SELECT * FROM auth_users WHERE username = ? LIMIT 1', [username]); + return rowToUser(rows[0]); + } + + async function getUserById(id) { + const [rows] = await pool.execute('SELECT * FROM auth_users WHERE id = ? LIMIT 1', [id]); + return rowToUser(rows[0]); + } + + async function getMe(token) { + if (!token) return null; + const [rows] = await pool.execute( + 'SELECT u.* FROM auth_sessions s JOIN auth_users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > NOW() LIMIT 1', + [token], + ); + return rowToUser(rows[0]); + } + + async function revoke(token) { + if (!token) return; + await pool.execute('DELETE FROM auth_sessions WHERE token = ?', [token]); + } + + async function login({ username, password }) { + await ensureTables(); + const [rows] = await pool.execute('SELECT * FROM auth_users WHERE username = ? LIMIT 1', [username]); + const row = rows[0]; + if (!row) return { ok: false, message: '账号或密码错误', retryAfterMs: 0 }; + if (!verifyPassword(password, row.password_hash)) return { ok: false, message: '账号或密码错误', retryAfterMs: 0 }; + const token = crypto.randomUUID().replace(/-/g, ''); + const expiresAt = new Date(Date.now() + SESSION_TTL_MS); + await pool.execute('INSERT INTO auth_sessions (token, user_id, expires_at) VALUES (?, ?, ?)', [token, row.id, expiresAt]); + return { ok: true, token, user: rowToUser(row) }; + } + + async function listUsers({ page = 1, pageSize = 20, search = '', role = '', status = '' }) { + await ensureTables(); + const clauses = []; + const params = []; + if (search) { + clauses.push('(username LIKE ? OR display_name LIKE ?)'); + params.push(`%${search}%`, `%${search}%`); + } + if (role) { + clauses.push('role = ?'); + params.push(role); + } + if (status) { + clauses.push('status = ?'); + params.push(status); + } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const [[countRow]] = await pool.execute(`SELECT COUNT(*) AS total FROM auth_users ${where}`, params); + const offset = (Math.max(page, 1) - 1) * Math.max(pageSize, 1); + const [rows] = await pool.execute( + `SELECT * FROM auth_users ${where} ORDER BY id DESC LIMIT ? OFFSET ?`, + [...params, Math.max(pageSize, 1), offset], + ); + return { + items: rows.map((row) => rowToUser(row)), + total: Number(countRow.total ?? 0), + page, + pageSize, + totalPages: Math.max(1, Math.ceil(Number(countRow.total ?? 0) / pageSize)), + }; + } + + async function createUser(payload) { + if (!payload?.username || !payload?.password) return { ok: false, message: '用户名和密码不能为空' }; + const [exists] = await pool.execute('SELECT id FROM auth_users WHERE username = ? LIMIT 1', [payload.username]); + if (exists.length) return { ok: false, message: '用户名已存在' }; + await pool.execute( + 'INSERT INTO auth_users (username, display_name, role, status, password_hash, balance_cents, workspace_root) VALUES (?, ?, ?, ?, ?, ?, ?)', + [ + payload.username, + payload.displayName || payload.username, + payload.role === 'admin' ? 'admin' : 'user', + 'active', + hashPassword(payload.password), + Number(payload.balanceCents ?? 0), + payload.workspaceRoot ?? null, + ], + ); + return { ok: true, user: await getUserByUsername(payload.username) }; + } + + async function updateUser(userId, payload) { + const user = await getUserById(userId); + if (!user) return { ok: false, message: '用户不存在' }; + const displayName = payload.displayName ?? user.displayName; + const workspaceRoot = payload.workspaceRoot ?? user.workspaceRoot; + const status = payload.status ?? user.status; + const role = payload.role === 'admin' ? 'admin' : user.role; + const balanceCents = payload.balanceCents ?? user.balanceCents; + await pool.execute( + 'UPDATE auth_users SET display_name = ?, workspace_root = ?, status = ?, role = ?, balance_cents = ? WHERE id = ?', + [displayName, workspaceRoot || null, status, role, balanceCents, userId], + ); + return { ok: true, user: await getUserById(userId) }; + } + + async function recharge(userId, amountCents) { + const user = await getUserById(userId); + if (!user) return { ok: false, message: '用户不存在' }; + await pool.execute('UPDATE auth_users SET balance_cents = balance_cents + ? WHERE id = ?', [Number(amountCents ?? 0), userId]); + return { ok: true, user: await getUserById(userId) }; + } + + async function getAdminSummary() { + const [[row]] = await pool.execute( + `SELECT COUNT(*) AS total, SUM(CASE WHEN balance_cents < 0 THEN 1 ELSE 0 END) AS lowBalance FROM auth_users`, + ); + return { users: { total: Number(row.total ?? 0), lowBalance: Number(row.lowBalance ?? 0) }, routes: { total: 0, active: 0 } }; + } + + return { + hashPassword, + capabilityCatalog, + policyCatalog, + skillCatalog, + ensureAdminUser, + login, + getMe, + revoke, + listUsers, + createUser, + updateUser, + recharge, + getAdminSummary, + getRoleCapabilities: async () => ({ ok: true, role: 'user', capabilities: defaultCapabilities() }), + setRoleCapabilities: async () => ({ ok: true, role: 'user', capabilities: defaultCapabilities() }), + getUserCapabilities: async () => ({ ok: true, userId: null, capabilities: defaultCapabilities() }), + setUserCapabilities: async () => ({ ok: true }), + clearUserCapabilityOverrides: async () => ({ ok: true }), + getRolePolicies: async () => ({ ok: true, role: 'user', policies: {} }), + setRolePolicies: async () => ({ ok: true, role: 'user', policies: {} }), + getUserPolicies: async () => ({ ok: true, userId: null, policies: {} }), + setUserPolicies: async () => ({ ok: true }), + clearUserPolicyOverrides: async () => ({ ok: true }), + getRoleSkills: async () => ({ ok: true, role: 'user', skills: {} }), + setRoleSkills: async () => ({ ok: true, role: 'user', skills: {} }), + getUserSkills: async () => ({ ok: true, userId: null, skills: {} }), + setUserSkills: async () => ({ ok: true }), + clearUserSkillOverrides: async () => ({ ok: true }), + }; +}