Move admin auth local
This commit is contained in:
+25
-7
@@ -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` |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
@@ -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
|
||||
|
||||
+114
-14
@@ -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;
|
||||
}
|
||||
|
||||
+39
-8
@@ -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 };
|
||||
}
|
||||
|
||||
+23
-6
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user