fix(security): apply ce-code-review fixes (1 P0, 6 P1, 6 P2)
P0: - sync-token.js: remove dead consumeSyncToken code, use crypto.randomBytes for jti P1: - admin-sync.js: add SSRF protection (protocol/host allowlist, block private IPs in prod) - admin-sync.js: add POST /:roleId/reset for syncing state recovery - admin-sync.js: use BASE_URL env var instead of forgeable Host header - admin-sync.js: guard catch block to only rollback syncing->failed (not approved) - admin-config.js: write-protect SYNC_SECRET from manual override - admin-config.js: add updatedAt to PUT response - roles.js: reset reviewStatus to pending_review when editing synced role - roles.js: filter GET /:id by reviewStatus=synced - scripts/migrate-existing-roles-to-synced.js: data migration for existing roles P2: - server.js: mock-hermes use explicit allowlist [development, test] - auth.js: ADMIN_JWT_SECRET fail-fast in production - hermes.js: unify error messages to Chinese - admin-sync.js: do not leak err.message in response - admin.js: validate pagination params (page/pageSize bounds) All 54 E2E tests pass (19 admin-sync-flow + 35 existing).
This commit is contained in:
parent
848939dc21
commit
a921f64ee0
|
|
@ -0,0 +1,79 @@
|
||||||
|
// 数据迁移脚本:将现有角色(reviewStatus 为空或默认 pending_review)标记为 synced
|
||||||
|
// 背景:管理员审核 + Hermes 同步流程上线后,角色库过滤改为 reviewStatus='synced'。
|
||||||
|
// 现有角色是在审核流程上线前创建的,直接标记为 synced 以保持向后兼容。
|
||||||
|
//
|
||||||
|
// 用法: node scripts/migrate-existing-roles-to-synced.js
|
||||||
|
//
|
||||||
|
// 安全说明:
|
||||||
|
// - 仅迁移 status='running' 且 reviewStatus='pending_review' 的角色
|
||||||
|
// - 已有 reviewStatus(approved/synced/rejected/failed)的角色不受影响
|
||||||
|
// - 执行前会打印待迁移数量,需用户确认(除非传入 --yes 跳过确认)
|
||||||
|
|
||||||
|
const prisma = require('../src/lib/prisma');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const skipConfirm = process.argv.includes('--yes');
|
||||||
|
|
||||||
|
// 查找待迁移角色:status='running' 且 reviewStatus='pending_review'
|
||||||
|
// 这些是审核流程上线前创建的角色
|
||||||
|
const candidates = await prisma.role.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'running',
|
||||||
|
reviewStatus: 'pending_review',
|
||||||
|
},
|
||||||
|
select: { id: true, displayName: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`找到 ${candidates.length} 个待迁移角色(status=running 且 reviewStatus=pending_review)`);
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.log('无需迁移');
|
||||||
|
await prisma.$disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipConfirm) {
|
||||||
|
console.log('\n待迁移角色列表(前 10 个):');
|
||||||
|
candidates.slice(0, 10).forEach((r, i) => {
|
||||||
|
console.log(` ${i + 1}. ${r.displayName} (id=${r.id}, createdAt=${r.createdAt.toISOString()})`);
|
||||||
|
});
|
||||||
|
if (candidates.length > 10) {
|
||||||
|
console.log(` ... 还有 ${candidates.length - 10} 个`);
|
||||||
|
}
|
||||||
|
console.log('\n将这些角色标记为 synced(已同步)?此操作不可逆。');
|
||||||
|
console.log('确认请输入 yes,否则取消:');
|
||||||
|
|
||||||
|
const readline = require('readline');
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const answer = await new Promise((resolve) => {
|
||||||
|
rl.question('> ', (a) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(a.trim().toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (answer !== 'yes') {
|
||||||
|
console.log('已取消');
|
||||||
|
await prisma.$disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.role.updateMany({
|
||||||
|
where: {
|
||||||
|
status: 'running',
|
||||||
|
reviewStatus: 'pending_review',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
reviewStatus: 'synced',
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n迁移完成:${result.count} 个角色已标记为 synced`);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('迁移失败:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -20,8 +20,9 @@ app.use('/api/admin', require('./src/routes/admin'));
|
||||||
app.use('/api/admin/config', require('./src/routes/admin-config'));
|
app.use('/api/admin/config', require('./src/routes/admin-config'));
|
||||||
app.use('/api/admin/sync', require('./src/routes/admin-sync'));
|
app.use('/api/admin/sync', require('./src/routes/admin-sync'));
|
||||||
|
|
||||||
// Mock Hermes 端点(仅非 production 环境)
|
// Mock Hermes 端点(仅 development/test 环境注册,显式 allowlist 防止误开放)
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
const MOCK_HERMES_ALLOWED_ENVS = ['development', 'test'];
|
||||||
|
if (MOCK_HERMES_ALLOWED_ENVS.includes(process.env.NODE_ENV || 'development')) {
|
||||||
app.use('/api/mock-hermes', require('./src/routes/mock-hermes'));
|
app.use('/api/mock-hermes', require('./src/routes/mock-hermes'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,17 @@ if (!JWT_SECRET) {
|
||||||
const SECRET = JWT_SECRET || 'dev_only_insecure_secret_do_not_use_in_production';
|
const SECRET = JWT_SECRET || 'dev_only_insecure_secret_do_not_use_in_production';
|
||||||
|
|
||||||
// Admin JWT 使用独立 secret,防止跨角色伪造
|
// Admin JWT 使用独立 secret,防止跨角色伪造
|
||||||
const ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET || (process.env.NODE_ENV === 'production'
|
// 生产环境必须配置 ADMIN_JWT_SECRET,否则 fail-fast
|
||||||
? null
|
const ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET;
|
||||||
: 'dev_only_admin_insecure_secret');
|
if (!ADMIN_JWT_SECRET) {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
throw new Error(
|
||||||
|
'ADMIN_JWT_SECRET 环境变量未设置。请在 .env 文件中配置一个独立于 JWT_SECRET 的随机密钥(可用 `openssl rand -hex 32` 生成)。'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.warn('[安全警告] ADMIN_JWT_SECRET 未设置,使用开发环境临时密钥。请勿在生产环境使用。');
|
||||||
|
}
|
||||||
|
const ADMIN_SECRET = ADMIN_JWT_SECRET || 'dev_only_admin_insecure_secret';
|
||||||
|
|
||||||
// 哈希密码
|
// 哈希密码
|
||||||
function hashPassword(password) {
|
function hashPassword(password) {
|
||||||
|
|
@ -111,13 +119,13 @@ async function apiKeyMiddleware(req, res, next) {
|
||||||
|
|
||||||
// 生成 Admin JWT
|
// 生成 Admin JWT
|
||||||
function adminSignToken(adminId) {
|
function adminSignToken(adminId) {
|
||||||
return jwt.sign({ adminId, role: 'admin' }, ADMIN_JWT_SECRET, { expiresIn: ADMIN_JWT_EXPIRES_IN });
|
return jwt.sign({ adminId, role: 'admin' }, ADMIN_SECRET, { expiresIn: ADMIN_JWT_EXPIRES_IN });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证 Admin JWT
|
// 验证 Admin JWT
|
||||||
function adminVerifyToken(token) {
|
function adminVerifyToken(token) {
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, ADMIN_JWT_SECRET);
|
const decoded = jwt.verify(token, ADMIN_SECRET);
|
||||||
if (decoded.role !== 'admin') return null;
|
if (decoded.role !== 'admin') return null;
|
||||||
return decoded.adminId;
|
return decoded.adminId;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,13 @@
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const crypto = require('crypto');
|
||||||
const prisma = require('./prisma');
|
const prisma = require('./prisma');
|
||||||
|
|
||||||
const SYNC_TOKEN_EXPIRES_IN = '5m';
|
const SYNC_TOKEN_EXPIRES_IN = '5m';
|
||||||
|
|
||||||
// 内存 Set 记录已消费的 sync_token jti,5 分钟后自动清理
|
|
||||||
const consumedTokens = new Map();
|
|
||||||
|
|
||||||
// 清理过期 token(每 5 分钟调用一次)
|
|
||||||
function cleanupConsumedTokens() {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [jti, expiry] of consumedTokens.entries()) {
|
|
||||||
if (now > expiry) {
|
|
||||||
consumedTokens.delete(jti);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setInterval(cleanupConsumedTokens, 5 * 60 * 1000);
|
|
||||||
|
|
||||||
// 从 SystemConfig 获取 SYNC_SECRET,不存在则自动生成
|
// 从 SystemConfig 获取 SYNC_SECRET,不存在则自动生成
|
||||||
async function getSyncSecret() {
|
async function getSyncSecret() {
|
||||||
let config = await prisma.systemConfig.findUnique({ where: { key: 'SYNC_SECRET' } });
|
let config = await prisma.systemConfig.findUnique({ where: { key: 'SYNC_SECRET' } });
|
||||||
if (!config) {
|
if (!config) {
|
||||||
const crypto = require('crypto');
|
|
||||||
const secret = crypto.randomBytes(32).toString('hex');
|
const secret = crypto.randomBytes(32).toString('hex');
|
||||||
config = await prisma.systemConfig.create({
|
config = await prisma.systemConfig.create({
|
||||||
data: { key: 'SYNC_SECRET', value: secret },
|
data: { key: 'SYNC_SECRET', value: secret },
|
||||||
|
|
@ -30,38 +16,28 @@ async function getSyncSecret() {
|
||||||
return config.value;
|
return config.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成 sync_token
|
// 生成 sync_token(5 分钟过期,jti 用 CSPRNG 生成)
|
||||||
async function generateSyncToken(roleId, adminId) {
|
async function generateSyncToken(roleId, adminId) {
|
||||||
const secret = await getSyncSecret();
|
const secret = await getSyncSecret();
|
||||||
const jti = `${roleId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
const jti = crypto.randomBytes(16).toString('hex');
|
||||||
return jwt.sign({ roleId, adminId, jti, type: 'sync' }, secret, { expiresIn: SYNC_TOKEN_EXPIRES_IN });
|
return jwt.sign({ roleId, adminId, jti, type: 'sync' }, secret, { expiresIn: SYNC_TOKEN_EXPIRES_IN });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证 sync_token,返回 payload 或 null
|
// 验证 sync_token,返回 payload 或 null
|
||||||
|
// 注意:sync_token 在 5 分钟有效期内可多次使用(Hermes 需拉取 SOUL.md + config.yaml 两个文件)
|
||||||
|
// 重放防护依赖 5 分钟短过期 + HTTPS 传输 + roleId 绑定
|
||||||
async function verifySyncToken(token) {
|
async function verifySyncToken(token) {
|
||||||
try {
|
try {
|
||||||
const secret = await getSyncSecret();
|
const secret = await getSyncSecret();
|
||||||
const decoded = jwt.verify(token, secret);
|
const decoded = jwt.verify(token, secret);
|
||||||
if (decoded.type !== 'sync') return null;
|
if (decoded.type !== 'sync') return null;
|
||||||
|
|
||||||
// 检查是否已消费
|
|
||||||
if (consumedTokens.has(decoded.jti)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return decoded;
|
return decoded;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标记 sync_token 为已消费
|
|
||||||
function consumeSyncToken(jti) {
|
|
||||||
consumedTokens.set(jti, Date.now() + 5 * 60 * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
generateSyncToken,
|
generateSyncToken,
|
||||||
verifySyncToken,
|
verifySyncToken,
|
||||||
consumeSyncToken,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ const router = express.Router();
|
||||||
// 敏感配置项的值需要脱敏显示
|
// 敏感配置项的值需要脱敏显示
|
||||||
const SENSITIVE_KEYS = ['SYNC_SECRET'];
|
const SENSITIVE_KEYS = ['SYNC_SECRET'];
|
||||||
|
|
||||||
|
// 受保护配置项:禁止通过 PUT 接口直接覆写
|
||||||
|
// SYNC_SECRET 由系统自动生成并轮换,管理员手动覆写会导致已知密钥攻击
|
||||||
|
const PROTECTED_KEYS = ['SYNC_SECRET'];
|
||||||
|
|
||||||
// 获取所有配置
|
// 获取所有配置
|
||||||
router.get('/', adminAuthMiddleware, async (req, res) => {
|
router.get('/', adminAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -30,13 +34,29 @@ router.put('/:key', adminAuthMiddleware, async (req, res) => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return res.status(400).json({ error: 'value 不能为空' });
|
return res.status(400).json({ error: 'value 不能为空' });
|
||||||
}
|
}
|
||||||
|
if (typeof value !== 'string' || value.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'value 必须为非空字符串' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 受保护配置项禁止通过此接口覆写
|
||||||
|
if (PROTECTED_KEYS.includes(req.params.key)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: `配置项 ${req.params.key} 受系统保护,禁止手动修改(由系统自动管理)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const config = await prisma.systemConfig.upsert({
|
const config = await prisma.systemConfig.upsert({
|
||||||
where: { key: req.params.key },
|
where: { key: req.params.key },
|
||||||
update: { value },
|
update: { value },
|
||||||
create: { key: req.params.key, value },
|
create: { key: req.params.key, value },
|
||||||
});
|
});
|
||||||
res.json({ config: { key: config.key, value: SENSITIVE_KEYS.includes(config.key) ? '***' : config.value } });
|
res.json({
|
||||||
|
config: {
|
||||||
|
key: config.key,
|
||||||
|
value: SENSITIVE_KEYS.includes(config.key) ? '***' : config.value,
|
||||||
|
updatedAt: config.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('更新系统配置失败:', err);
|
console.error('更新系统配置失败:', err);
|
||||||
res.status(500).json({ error: '更新失败' });
|
res.status(500).json({ error: '更新失败' });
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,56 @@ const { postSync } = require('../lib/hermes-client');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// SSRF 防护:校验 webhookUrl 协议与主机
|
||||||
|
// - 仅允许 http/https(开发环境允许 http,生产环境强制 https)
|
||||||
|
// - 禁止指向内网/回环地址(10.x、127.x、169.254.x、172.16-31.x、192.168.x、::1、fc00::/7)
|
||||||
|
function isPrivateIPv4(hostname) {
|
||||||
|
const parts = hostname.split('.').map(Number);
|
||||||
|
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return false;
|
||||||
|
const [a, b] = parts;
|
||||||
|
if (a === 10) return true;
|
||||||
|
if (a === 127) return true;
|
||||||
|
if (a === 169 && b === 254) return true;
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||||
|
if (a === 192 && b === 168) return true;
|
||||||
|
if (a === 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateWebhookUrl(rawUrl) {
|
||||||
|
if (!rawUrl || typeof rawUrl !== 'string') {
|
||||||
|
return { ok: false, error: 'webhook URL 不能为空' };
|
||||||
|
}
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: 'webhook URL 格式无效' };
|
||||||
|
}
|
||||||
|
const hostname = url.hostname.toLowerCase();
|
||||||
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||||
|
return { ok: false, error: 'webhook URL 协议仅支持 http/https' };
|
||||||
|
}
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
if (isProduction && url.protocol !== 'https:') {
|
||||||
|
return { ok: false, error: '生产环境 webhook URL 必须使用 https' };
|
||||||
|
}
|
||||||
|
// 生产环境禁止指向内网/回环地址(SSRF 防护)
|
||||||
|
// 非生产环境允许 localhost(用于 mock-hermes 测试)
|
||||||
|
if (isProduction) {
|
||||||
|
if (isPrivateIPv4(hostname)) {
|
||||||
|
return { ok: false, error: 'webhook URL 不允许指向内网地址' };
|
||||||
|
}
|
||||||
|
if (hostname === 'localhost' || hostname === '::1' || hostname.endsWith('.local')) {
|
||||||
|
return { ok: false, error: 'webhook URL 不允许指向本地地址' };
|
||||||
|
}
|
||||||
|
if (hostname.startsWith('fc') || hostname.startsWith('fd') || hostname.startsWith('fe80')) {
|
||||||
|
return { ok: false, error: 'webhook URL 不允许指向内网地址' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, url };
|
||||||
|
}
|
||||||
|
|
||||||
// 管理员发起同步
|
// 管理员发起同步
|
||||||
router.post('/:roleId', adminAuthMiddleware, async (req, res) => {
|
router.post('/:roleId', adminAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -41,8 +91,16 @@ router.post('/:roleId', adminAuthMiddleware, async (req, res) => {
|
||||||
return res.status(400).json({ error: '未配置 Hermes webhook URL' });
|
return res.status(400).json({ error: '未配置 Hermes webhook URL' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSRF 防护:校验 webhookUrl
|
||||||
|
const urlCheck = validateWebhookUrl(hermesUrl);
|
||||||
|
if (!urlCheck.ok) {
|
||||||
|
return res.status(400).json({ error: urlCheck.error });
|
||||||
|
}
|
||||||
|
hermesUrl = urlCheck.url.toString();
|
||||||
|
|
||||||
// 获取 EternalAI 自身基础 URL(供 Hermes 回调拉取文件)
|
// 获取 EternalAI 自身基础 URL(供 Hermes 回调拉取文件)
|
||||||
let baseUrl = req.protocol + '://' + req.get('host');
|
// 优先使用环境变量 BASE_URL,避免 Host 头被伪造
|
||||||
|
const baseUrl = process.env.BASE_URL || (req.protocol + '://' + req.get('host'));
|
||||||
|
|
||||||
// 生成 sync_token
|
// 生成 sync_token
|
||||||
const syncToken = await generateSyncToken(role.id, req.adminId);
|
const syncToken = await generateSyncToken(role.id, req.adminId);
|
||||||
|
|
@ -87,19 +145,47 @@ router.post('/:roleId', adminAuthMiddleware, async (req, res) => {
|
||||||
|
|
||||||
res.json({ role: updated, profileId: result.profileId });
|
res.json({ role: updated, profileId: result.profileId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('同步失败:', err.message);
|
console.error('同步失败:', err);
|
||||||
|
|
||||||
// 同步失败,更新状态
|
// 同步失败,仅当当前状态为 syncing 时才回滚为 failed(避免误覆盖 approved)
|
||||||
try {
|
try {
|
||||||
await prisma.role.update({
|
const current = await prisma.role.findUnique({
|
||||||
where: { id: req.params.roleId },
|
where: { id: req.params.roleId },
|
||||||
data: { reviewStatus: 'failed' },
|
select: { reviewStatus: true },
|
||||||
});
|
});
|
||||||
|
if (current && current.reviewStatus === 'syncing') {
|
||||||
|
await prisma.role.update({
|
||||||
|
where: { id: req.params.roleId },
|
||||||
|
data: { reviewStatus: 'failed' },
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (updateErr) {
|
} catch (updateErr) {
|
||||||
console.error('更新失败状态出错:', updateErr);
|
console.error('更新失败状态出错:', updateErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(500).json({ error: err.message || '同步失败' });
|
res.status(500).json({ error: '同步失败,请稍后重试或检查 Hermes 服务状态' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 管理员强制重置 syncing 卡死状态(恢复路径)
|
||||||
|
router.post('/:roleId/reset', adminAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const role = await prisma.role.findUnique({ where: { id: req.params.roleId } });
|
||||||
|
if (!role) {
|
||||||
|
return res.status(404).json({ error: '角色不存在' });
|
||||||
|
}
|
||||||
|
if (role.reviewStatus !== 'syncing') {
|
||||||
|
return res.status(400).json({ error: `当前状态为 ${role.reviewStatus},仅 syncing 状态可重置` });
|
||||||
|
}
|
||||||
|
const updated = await prisma.role.update({
|
||||||
|
where: { id: req.params.roleId },
|
||||||
|
data: { reviewStatus: 'failed' },
|
||||||
|
select: { id: true, displayName: true, reviewStatus: true },
|
||||||
|
});
|
||||||
|
res.json({ role: updated });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('重置同步状态失败:', err);
|
||||||
|
res.status(500).json({ error: '重置失败' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,21 @@ const router = express.Router();
|
||||||
// 获取待审核列表
|
// 获取待审核列表
|
||||||
router.get('/reviews', adminAuthMiddleware, async (req, res) => {
|
router.get('/reviews', adminAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status, page = 1, pageSize = 20 } = req.query;
|
const { status } = req.query;
|
||||||
|
// 分页参数校验:默认 page=1, pageSize=20,限制最大 100
|
||||||
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(req.query.pageSize) || 20)));
|
||||||
|
if (Number.isNaN(page) || Number.isNaN(pageSize)) {
|
||||||
|
return res.status(400).json({ error: 'page 和 pageSize 必须为正整数' });
|
||||||
|
}
|
||||||
const where = status ? { reviewStatus: status } : { reviewStatus: 'pending_review' };
|
const where = status ? { reviewStatus: status } : { reviewStatus: 'pending_review' };
|
||||||
|
|
||||||
const [roles, total] = await Promise.all([
|
const [roles, total] = await Promise.all([
|
||||||
prisma.role.findMany({
|
prisma.role.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip: (Number(page) - 1) * Number(pageSize),
|
skip: (page - 1) * pageSize,
|
||||||
take: Number(pageSize),
|
take: pageSize,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
displayName: true,
|
displayName: true,
|
||||||
|
|
@ -30,7 +36,7 @@ router.get('/reviews', adminAuthMiddleware, async (req, res) => {
|
||||||
prisma.role.count({ where }),
|
prisma.role.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.json({ roles, total, page: Number(page), pageSize: Number(pageSize) });
|
res.json({ roles, total, page, pageSize });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('获取审核列表失败:', err);
|
console.error('获取审核列表失败:', err);
|
||||||
res.status(500).json({ error: '服务器错误' });
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ async function hermesAuthMiddleware(req, res, next) {
|
||||||
select: { creatorId: true },
|
select: { creatorId: true },
|
||||||
});
|
});
|
||||||
if (!role) {
|
if (!role) {
|
||||||
return res.status(404).json({ error: 'Role not found' });
|
return res.status(404).json({ error: '角色不存在' });
|
||||||
}
|
}
|
||||||
req.userId = role.creatorId;
|
req.userId = role.creatorId;
|
||||||
req.authMethod = 'sync_token';
|
req.authMethod = 'sync_token';
|
||||||
|
|
@ -85,18 +85,18 @@ router.get('/roles/:id/SOUL.md', hermesAuthMiddleware, async (req, res) => {
|
||||||
select: { id: true, creatorId: true, displayName: true, soulMd: true },
|
select: { id: true, creatorId: true, displayName: true, soulMd: true },
|
||||||
});
|
});
|
||||||
if (!role) {
|
if (!role) {
|
||||||
return res.status(404).json({ error: 'Role not found' });
|
return res.status(404).json({ error: '角色不存在' });
|
||||||
}
|
}
|
||||||
if (role.creatorId !== req.userId) {
|
if (role.creatorId !== req.userId) {
|
||||||
return res.status(403).json({ error: 'Forbidden: not the role owner' });
|
return res.status(403).json({ error: '无权访问该角色' });
|
||||||
}
|
}
|
||||||
if (!role.soulMd) {
|
if (!role.soulMd) {
|
||||||
return res.status(404).json({ error: 'SOUL.md not generated for this role' });
|
return res.status(404).json({ error: '该角色尚未生成 SOUL.md' });
|
||||||
}
|
}
|
||||||
res.type('text/plain').send(role.soulMd);
|
res.type('text/plain').send(role.soulMd);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('获取 SOUL.md 失败:', err);
|
console.error('获取 SOUL.md 失败:', err);
|
||||||
res.status(500).json({ error: 'Server error' });
|
res.status(500).json({ error: '服务器错误' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -118,16 +118,16 @@ router.get('/roles/:id/config.yaml', hermesAuthMiddleware, async (req, res) => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!role) {
|
if (!role) {
|
||||||
return res.status(404).json({ error: 'Role not found' });
|
return res.status(404).json({ error: '角色不存在' });
|
||||||
}
|
}
|
||||||
if (role.creatorId !== req.userId) {
|
if (role.creatorId !== req.userId) {
|
||||||
return res.status(403).json({ error: 'Forbidden: not the role owner' });
|
return res.status(403).json({ error: '无权访问该角色' });
|
||||||
}
|
}
|
||||||
const config = adaptToHermesConfig(role);
|
const config = adaptToHermesConfig(role);
|
||||||
res.type('text/plain').send(config);
|
res.type('text/plain').send(config);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('获取 config.yaml 失败:', err);
|
console.error('获取 config.yaml 失败:', err);
|
||||||
res.status(500).json({ error: 'Server error' });
|
res.status(500).json({ error: '服务器错误' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ router.get('/', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取角色详情
|
// 获取角色详情(仅显示已同步完成的角色)
|
||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const role = await prisma.role.findUnique({
|
const role = await prisma.role.findUnique({
|
||||||
|
|
@ -46,11 +46,16 @@ router.get('/:id', async (req, res) => {
|
||||||
speechStyle: true,
|
speechStyle: true,
|
||||||
greeting: true,
|
greeting: true,
|
||||||
creatorId: true,
|
creatorId: true,
|
||||||
|
reviewStatus: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!role) {
|
if (!role) {
|
||||||
return res.status(404).json({ error: '角色不存在' });
|
return res.status(404).json({ error: '角色不存在' });
|
||||||
}
|
}
|
||||||
|
// 角色库仅展示已同步的角色
|
||||||
|
if (role.reviewStatus !== 'synced') {
|
||||||
|
return res.status(404).json({ error: '角色不存在' });
|
||||||
|
}
|
||||||
res.json({ role });
|
res.json({ role });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('获取角色详情失败:', err);
|
console.error('获取角色详情失败:', err);
|
||||||
|
|
@ -144,6 +149,9 @@ router.put('/:id', authMiddleware, async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
|
// 若编辑已同步(synced)的角色,重置为 pending_review,需重新审核 + 同步
|
||||||
|
// 防止 Hermes 端的 profile 与 EternalAI 端的角色数据不一致
|
||||||
|
const shouldResetReview = existing.reviewStatus === 'synced';
|
||||||
const role = await prisma.role.update({
|
const role = await prisma.role.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -171,10 +179,19 @@ router.put('/:id', authMiddleware, async (req, res) => {
|
||||||
desc: data.desc ?? existing.desc,
|
desc: data.desc ?? existing.desc,
|
||||||
price: parseFloat(data.price) || existing.price,
|
price: parseFloat(data.price) || existing.price,
|
||||||
status: data.status ?? existing.status,
|
status: data.status ?? existing.status,
|
||||||
|
// 编辑后重置审核状态(仅当当前为 synced 时)
|
||||||
|
...(shouldResetReview
|
||||||
|
? { reviewStatus: 'pending_review', reviewNote: null, qrCodeUrl: null, syncedAt: null }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ role });
|
res.json({
|
||||||
|
role,
|
||||||
|
reviewReset: shouldResetReview
|
||||||
|
? '角色已编辑,审核状态已重置为 pending_review,需重新审核与同步'
|
||||||
|
: null,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('编辑角色失败:', err);
|
console.error('编辑角色失败:', err);
|
||||||
res.status(500).json({ error: '编辑失败' });
|
res.status(500).json({ error: '编辑失败' });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue