Compare commits
6 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
d6f3355e82 | |
|
|
cc0ced9858 | |
|
|
c470ef8f3a | |
|
|
561a680771 | |
|
|
a921f64ee0 | |
|
|
848939dc21 |
537
app.js
537
app.js
|
|
@ -71,6 +71,265 @@
|
||||||
|
|
||||||
const state = loadState();
|
const state = loadState();
|
||||||
|
|
||||||
|
// --- 管理员认证状态管理 ---
|
||||||
|
const ADMIN_TOKEN_KEY = 'eternal_ai_admin_token';
|
||||||
|
const ADMIN_STATE_KEY = 'eternal_ai_admin_state';
|
||||||
|
|
||||||
|
function getAdminToken() {
|
||||||
|
return localStorage.getItem(ADMIN_TOKEN_KEY) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAdminToken(token) {
|
||||||
|
if (token) localStorage.setItem(ADMIN_TOKEN_KEY, token);
|
||||||
|
else localStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAdminState = { isLoggedIn: false, account: null };
|
||||||
|
function loadAdminState() {
|
||||||
|
try {
|
||||||
|
return { ...defaultAdminState, ...JSON.parse(localStorage.getItem(ADMIN_STATE_KEY)) };
|
||||||
|
} catch { return { ...defaultAdminState }; }
|
||||||
|
}
|
||||||
|
function saveAdminState() { localStorage.setItem(ADMIN_STATE_KEY, JSON.stringify(adminState)); }
|
||||||
|
const adminState = loadAdminState();
|
||||||
|
|
||||||
|
// 管理员 API 封装(使用管理员 token)
|
||||||
|
async function adminApi(path, options = {}) {
|
||||||
|
const token = getAdminToken();
|
||||||
|
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || `请求失败 (${res.status})`);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message === 'Failed to fetch') throw new Error('无法连接服务器,请检查网络');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 管理员审核列表 ---
|
||||||
|
let adminCurrentFilter = 'pending_review';
|
||||||
|
|
||||||
|
async function renderAdminReviews() {
|
||||||
|
const listEl = document.getElementById('admin-review-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">加载中…</p>';
|
||||||
|
try {
|
||||||
|
const query = adminCurrentFilter ? `?status=${adminCurrentFilter}` : '';
|
||||||
|
const { roles } = await adminApi(`/admin/reviews${query}`);
|
||||||
|
if (roles.length === 0) {
|
||||||
|
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">暂无角色</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.innerHTML = roles.map((role) => {
|
||||||
|
const id = escapeHtml(role.id);
|
||||||
|
const name = escapeHtml(role.displayName);
|
||||||
|
const avatar = escapeHtml(role.avatar || '');
|
||||||
|
const status = escapeHtml(role.reviewStatus);
|
||||||
|
const statusText = getReviewStatusText(role.reviewStatus);
|
||||||
|
const statusClass = getReviewStatusClass(role.reviewStatus);
|
||||||
|
const created = new Date(role.createdAt).toLocaleString('zh-CN');
|
||||||
|
return `
|
||||||
|
<article class="role-card admin-review-card" data-role-id="${id}" role="button" tabindex="0">
|
||||||
|
<img class="role-card__avatar" src="${avatar}" alt="${name}" />
|
||||||
|
<div class="role-card__info">
|
||||||
|
<h3 class="role-card__name">${name}</h3>
|
||||||
|
<span class="role-card__status role-card__status--${statusClass}">${statusText}</span>
|
||||||
|
<span style="font-size:0.75rem;color:var(--text-muted);display:block;margin-top:0.25rem;">${created}</span>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (err) {
|
||||||
|
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReviewStatusText(status) {
|
||||||
|
const map = {
|
||||||
|
pending_review: '待审核', approved: '已通过', rejected: '已驳回',
|
||||||
|
syncing: '同步中', synced: '已同步', failed: '同步失败',
|
||||||
|
};
|
||||||
|
return map[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReviewStatusClass(status) {
|
||||||
|
const map = {
|
||||||
|
pending_review: 'pending', approved: 'approved', rejected: 'rejected',
|
||||||
|
syncing: 'syncing', synced: 'synced', failed: 'failed',
|
||||||
|
};
|
||||||
|
return map[status] || 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 管理员审核详情 ---
|
||||||
|
let adminCurrentRoleId = null;
|
||||||
|
|
||||||
|
async function renderAdminReviewDetail(roleId) {
|
||||||
|
adminCurrentRoleId = roleId;
|
||||||
|
const contentEl = document.getElementById('admin-detail-content');
|
||||||
|
if (!contentEl) return;
|
||||||
|
contentEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">加载中…</p>';
|
||||||
|
try {
|
||||||
|
const { role } = await adminApi(`/admin/reviews/${roleId}`);
|
||||||
|
const status = role.reviewStatus;
|
||||||
|
let actionsHtml = '';
|
||||||
|
if (status === 'pending_review') {
|
||||||
|
actionsHtml = `
|
||||||
|
<div style="display:flex;gap:0.5rem;margin-top:1rem;">
|
||||||
|
<button class="btn btn--primary" type="button" data-action="admin-approve" data-role-id="${escapeHtml(roleId)}">通过审核</button>
|
||||||
|
<button class="btn btn--outline" type="button" data-action="admin-reject" data-role-id="${escapeHtml(roleId)}">驳回</button>
|
||||||
|
</div>`;
|
||||||
|
} else if (status === 'approved') {
|
||||||
|
actionsHtml = `<h3 style="margin-top:1.5rem;">发起 Hermes 同步</h3>
|
||||||
|
<form id="admin-sync-form" style="margin-top:1rem;">
|
||||||
|
<div class="field"><label class="field__label">Profile 名称</label><input class="field__input" name="profileName" type="text" placeholder="如 star-spirit" required /></div>
|
||||||
|
<div class="field"><label class="field__label">主 Model Key</label><input class="field__input" name="modelKey" type="text" placeholder="sk-xxx" required /></div>
|
||||||
|
<div class="field"><label class="field__label">主服务商</label><select class="field__input" name="provider"><option value="openrouter">OpenRouter</option><option value="siliconflow">SiliconFlow</option></select></div>
|
||||||
|
<div class="field"><label class="field__label">多媒体 Model Key</label><input class="field__input" name="multimediaModelKey" type="text" placeholder="sk-xxx" /></div>
|
||||||
|
<div class="field"><label class="field__label">多媒体服务商</label><select class="field__input" name="multimediaProvider"><option value="openrouter">OpenRouter</option><option value="siliconflow">SiliconFlow</option></select></div>
|
||||||
|
<div class="field"><label class="field__label"><input type="checkbox" name="enableSchedule" /> 启用定时任务</label></div>
|
||||||
|
<div class="field"><label class="field__label">Webhook URL(可选,留空用全局配置)</label><input class="field__input" name="webhookUrl" type="url" placeholder="https://hermes.example.com/api/sync" /></div>
|
||||||
|
<button class="btn btn--primary" type="submit" style="width:100%;">发起同步</button>
|
||||||
|
</form>`;
|
||||||
|
} else if (status === 'synced') {
|
||||||
|
actionsHtml = `
|
||||||
|
<div style="margin-top:1.5rem;text-align:center;">
|
||||||
|
<h3>同步成功</h3>
|
||||||
|
${role.qrCodeUrl ? `<img src="${escapeHtml(role.qrCodeUrl)}" alt="二维码" style="max-width:200px;margin:1rem auto;display:block;" />` : ''}
|
||||||
|
<p>Profile ID: ${escapeHtml(role.id)}</p>
|
||||||
|
<p>同步时间: ${role.syncedAt ? new Date(role.syncedAt).toLocaleString('zh-CN') : '未知'}</p>
|
||||||
|
</div>`;
|
||||||
|
} else if (status === 'rejected') {
|
||||||
|
actionsHtml = `<div style="margin-top:1.5rem;padding:1rem;background:#fef2f2;border-radius:8px;"><strong>驳回原因:</strong>${escapeHtml(role.reviewNote || '未提供')}</div>`;
|
||||||
|
} else if (status === 'failed') {
|
||||||
|
actionsHtml = `<div style="margin-top:1.5rem;padding:1rem;background:#fff7ed;border-radius:8px;"><strong>同步失败</strong><p>角色状态已标记为失败,可重新发起同步。</p></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
contentEl.innerHTML = `
|
||||||
|
<div style="display:flex;gap:1rem;align-items:center;margin-bottom:1.5rem;">
|
||||||
|
<img src="${escapeHtml(role.avatar || '')}" alt="${escapeHtml(role.displayName)}" style="width:64px;height:64px;border-radius:50%;object-fit:cover;" />
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0;">${escapeHtml(role.displayName)}</h2>
|
||||||
|
<span class="role-card__status role-card__status--${getReviewStatusClass(status)}">${getReviewStatusText(status)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:1rem;">
|
||||||
|
<p><strong>描述:</strong>${escapeHtml(role.desc || role.personality || '无')}</p>
|
||||||
|
<p><strong>性格:</strong>${escapeHtml(role.personality || '无')}</p>
|
||||||
|
<p><strong>背景:</strong>${escapeHtml(role.background || '无')}</p>
|
||||||
|
<p><strong>问候语:</strong>${escapeHtml(role.greeting || '无')}</p>
|
||||||
|
<p><strong>价格:</strong>¥${escapeHtml(String(role.price || 0))}</p>
|
||||||
|
</div>
|
||||||
|
${actionsHtml}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 绑定同步表单提交
|
||||||
|
const syncForm = document.getElementById('admin-sync-form');
|
||||||
|
if (syncForm) {
|
||||||
|
syncForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(syncForm);
|
||||||
|
const data = Object.fromEntries(formData.entries());
|
||||||
|
data.enableSchedule = !!formData.get('enableSchedule');
|
||||||
|
if (!data.webhookUrl) delete data.webhookUrl;
|
||||||
|
const submitBtn = syncForm.querySelector('button[type="submit"]');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = '同步中…';
|
||||||
|
try {
|
||||||
|
const result = await adminApi(`/admin/sync/${roleId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
alert('同步成功!Profile ID: ' + result.profileId);
|
||||||
|
renderAdminReviewDetail(roleId);
|
||||||
|
} catch (err) {
|
||||||
|
alert('同步失败:' + err.message);
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = '发起同步';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
contentEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 管理员同步状态列表 ---
|
||||||
|
async function renderAdminSyncStatus() {
|
||||||
|
const listEl = document.getElementById('admin-sync-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">加载中…</p>';
|
||||||
|
try {
|
||||||
|
const { roles } = await adminApi('/admin/sync-status');
|
||||||
|
if (roles.length === 0) {
|
||||||
|
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">暂无同步记录</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.innerHTML = roles.map((role) => {
|
||||||
|
const id = escapeHtml(role.id);
|
||||||
|
const name = escapeHtml(role.displayName);
|
||||||
|
const status = escapeHtml(role.reviewStatus);
|
||||||
|
const statusText = getReviewStatusText(role.reviewStatus);
|
||||||
|
const statusClass = getReviewStatusClass(role.reviewStatus);
|
||||||
|
const syncedAt = role.syncedAt ? new Date(role.syncedAt).toLocaleString('zh-CN') : '未同步';
|
||||||
|
return `
|
||||||
|
<article class="role-card" data-role-id="${id}" role="button" tabindex="0">
|
||||||
|
<div class="role-card__info">
|
||||||
|
<h3 class="role-card__name">${name}</h3>
|
||||||
|
<span class="role-card__status role-card__status--${statusClass}">${statusText}</span>
|
||||||
|
<span style="font-size:0.75rem;color:var(--text-muted);display:block;margin-top:0.25rem;">同步时间:${syncedAt}</span>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (err) {
|
||||||
|
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 管理员系统配置 ---
|
||||||
|
async function renderAdminConfig() {
|
||||||
|
const listEl = document.getElementById('admin-config-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">加载中…</p>';
|
||||||
|
try {
|
||||||
|
const { configs } = await adminApi('/admin/config');
|
||||||
|
listEl.innerHTML = configs.map((c) => {
|
||||||
|
const key = escapeHtml(c.key);
|
||||||
|
const value = escapeHtml(c.value);
|
||||||
|
const updated = c.updatedAt ? new Date(c.updatedAt).toLocaleString('zh-CN') : '';
|
||||||
|
const isProtected = key === 'SYNC_SECRET';
|
||||||
|
return `
|
||||||
|
<div class="field" style="margin-bottom:1rem;">
|
||||||
|
<label class="field__label">${key}</label>
|
||||||
|
<div style="display:flex;gap:0.5rem;">
|
||||||
|
<input class="field__input" type="text" value="${value}" data-config-key="${key}" ${isProtected ? 'disabled' : ''} />
|
||||||
|
${isProtected ? '' : `<button class="btn btn--small btn--primary" type="button" data-action="admin-save-config" data-config-key="${key}">保存</button>`}
|
||||||
|
</div>
|
||||||
|
${updated ? `<span style="font-size:0.75rem;color:var(--text-muted);">更新于 ${updated}</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (err) {
|
||||||
|
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 管理员 Tab 切换 ---
|
||||||
|
function switchAdminTab(tab) {
|
||||||
|
document.querySelectorAll('.center-tab').forEach((t) => {
|
||||||
|
if (t.closest('#admin-reviews')) {
|
||||||
|
const isActive = t.dataset.centerTab === tab;
|
||||||
|
t.classList.toggle('active', isActive);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('admin-reviews-panel').hidden = tab !== 'admin-reviews';
|
||||||
|
document.getElementById('admin-sync-panel').hidden = tab !== 'admin-sync';
|
||||||
|
document.getElementById('admin-config-panel').hidden = tab !== 'admin-config';
|
||||||
|
if (tab === 'admin-reviews') renderAdminReviews();
|
||||||
|
else if (tab === 'admin-sync') renderAdminSyncStatus();
|
||||||
|
else if (tab === 'admin-config') renderAdminConfig();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Mock data for role library (U2) ---
|
// --- Mock data for role library (U2) ---
|
||||||
const mockRoles = [
|
const mockRoles = [
|
||||||
{
|
{
|
||||||
|
|
@ -119,6 +378,9 @@
|
||||||
onboarding: document.getElementById('onboarding'),
|
onboarding: document.getElementById('onboarding'),
|
||||||
'creator-center': document.getElementById('creator-center'),
|
'creator-center': document.getElementById('creator-center'),
|
||||||
creator: document.getElementById('creator'),
|
creator: document.getElementById('creator'),
|
||||||
|
'admin-login': document.getElementById('admin-login'),
|
||||||
|
'admin-reviews': document.getElementById('admin-reviews'),
|
||||||
|
'admin-review-detail': document.getElementById('admin-review-detail'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const form = document.getElementById('character-form');
|
const form = document.getElementById('character-form');
|
||||||
|
|
@ -149,6 +411,9 @@
|
||||||
onboarding: '创作者入驻',
|
onboarding: '创作者入驻',
|
||||||
'creator-center': '创作者管理中心',
|
'creator-center': '创作者管理中心',
|
||||||
creator: '角色编辑',
|
creator: '角色编辑',
|
||||||
|
'admin-login': '管理员登录',
|
||||||
|
'admin-reviews': '管理员后台',
|
||||||
|
'admin-review-detail': '角色审核详情',
|
||||||
};
|
};
|
||||||
|
|
||||||
function showView(name, trackHistory = true) {
|
function showView(name, trackHistory = true) {
|
||||||
|
|
@ -358,12 +623,36 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function payRole() {
|
async function payRole() {
|
||||||
|
if (!currentRole) return;
|
||||||
|
const btn = document.querySelector('[data-action="pay"]');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '处理中…';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await api('/orders', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ roleId: currentRole.id, amount: currentRole.price }),
|
||||||
|
});
|
||||||
|
// 付款成功,展示二维码
|
||||||
document.getElementById('detail-actions-pre').hidden = true;
|
document.getElementById('detail-actions-pre').hidden = true;
|
||||||
const paidEl = document.getElementById('detail-paid');
|
const paidEl = document.getElementById('detail-paid');
|
||||||
paidEl.hidden = false;
|
paidEl.hidden = false;
|
||||||
document.getElementById('detail-qr').innerHTML = '<div class="qr-placeholder">扫码连接<br/>AI 角色</div>';
|
const qrEl = document.getElementById('detail-qr');
|
||||||
|
if (result.role && result.role.qrCodeUrl) {
|
||||||
|
qrEl.innerHTML = `<img src="${escapeHtml(result.role.qrCodeUrl)}" alt="角色二维码" style="max-width:200px;width:100%;border-radius:8px;" />`;
|
||||||
|
} else {
|
||||||
|
qrEl.innerHTML = '<div class="qr-placeholder">扫码连接<br/>AI 角色</div>';
|
||||||
|
}
|
||||||
document.getElementById('detail-avatar').style.backgroundImage = `url(${currentRole.avatar})`;
|
document.getElementById('detail-avatar').style.backgroundImage = `url(${currentRole.avatar})`;
|
||||||
|
} catch (err) {
|
||||||
|
alert('付款失败:' + err.message);
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '立即订阅';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- U4: About FAQ ---
|
// --- U4: About FAQ ---
|
||||||
|
|
@ -423,27 +712,59 @@
|
||||||
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">还没有创建角色,点击「新建角色」开始</p>';
|
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">还没有创建角色,点击「新建角色」开始</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
listEl.innerHTML = roles
|
listEl.innerHTML = roles.map((role) => {
|
||||||
.map((role) => {
|
|
||||||
const id = escapeHtml(role.id);
|
const id = escapeHtml(role.id);
|
||||||
const name = escapeHtml(role.displayName);
|
const name = escapeHtml(role.displayName);
|
||||||
const avatar = escapeHtml(role.avatar || 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20character%20portrait&image_size=square');
|
const avatar = escapeHtml(role.avatar || 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20character%20portrait&image_size=square');
|
||||||
const statusText = role.status === 'running' ? '运行中' : '已停止';
|
const reviewStatus = role.reviewStatus || 'pending_review';
|
||||||
const statusClass = escapeHtml(role.status);
|
const statusText = getReviewStatusText(reviewStatus);
|
||||||
|
const statusClass = getReviewStatusClass(reviewStatus);
|
||||||
|
|
||||||
|
// 审核状态标签
|
||||||
|
let statusBadge = `<span class="role-card__status role-card__status--${statusClass}">${statusText}</span>`;
|
||||||
|
|
||||||
|
// 已同步角色额外显示运行状态
|
||||||
|
if (reviewStatus === 'synced') {
|
||||||
|
const runStatus = role.status === 'running' ? '运行中' : '已停止';
|
||||||
|
statusBadge += ` <span class="role-card__status role-card__status--${escapeHtml(role.status)}" style="margin-left:0.25rem;">${runStatus}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 驳回原因
|
||||||
|
let rejectNote = '';
|
||||||
|
if (reviewStatus === 'rejected' && role.reviewNote) {
|
||||||
|
rejectNote = `<p style="font-size:0.75rem;color:#dc2626;margin-top:0.25rem;">驳回原因:${escapeHtml(role.reviewNote)}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二维码展示(已同步角色)
|
||||||
|
let qrCodeHtml = '';
|
||||||
|
if (reviewStatus === 'synced' && role.qrCodeUrl) {
|
||||||
|
qrCodeHtml = `
|
||||||
|
<div style="margin-top:0.5rem;">
|
||||||
|
<img src="${escapeHtml(role.qrCodeUrl)}" alt="二维码" style="width:48px;height:48px;cursor:pointer;" data-action="show-qrcode" data-qr-url="${escapeHtml(role.qrCodeUrl)}" />
|
||||||
|
<button class="btn btn--small btn--outline" type="button" data-action="copy-qrcode" data-qr-url="${escapeHtml(role.qrCodeUrl)}" style="margin-left:0.5rem;">复制链接</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 操作按钮
|
||||||
|
let actionButtons = '';
|
||||||
|
if (reviewStatus === 'rejected' || reviewStatus === 'pending_review') {
|
||||||
|
actionButtons = `<button class="btn btn--small btn--outline" type="button" data-action="edit-role" data-role-id="${id}">编辑</button>`;
|
||||||
|
} else if (reviewStatus === 'synced') {
|
||||||
|
actionButtons = `<button class="btn btn--small btn--outline" type="button" data-action="edit-role" data-role-id="${id}">编辑</button> <button class="btn btn--small btn--outline" type="button" data-action="hermes-deploy" data-role-id="${id}" data-role-name="${name}">Hermes 部署</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<article class="role-card" data-role-id="${id}" role="button" tabindex="0" aria-label="${name},${statusText}">
|
<article class="role-card" data-role-id="${id}" role="button" tabindex="0" aria-label="${name},${statusText}">
|
||||||
<img class="role-card__avatar" src="${avatar}" alt="${name}" />
|
<img class="role-card__avatar" src="${avatar}" alt="${name}" />
|
||||||
<div class="role-card__info">
|
<div class="role-card__info">
|
||||||
<h3 class="role-card__name">${name}</h3>
|
<h3 class="role-card__name">${name}</h3>
|
||||||
<span class="role-card__status role-card__status--${statusClass}">${statusText}</span>
|
<div>${statusBadge}</div>
|
||||||
</div>
|
${rejectNote}
|
||||||
<div style="display:flex;gap:0.5rem;">
|
${qrCodeHtml}
|
||||||
<button class="btn btn--small btn--outline" type="button" data-action="edit-role" data-role-id="${id}">编辑</button>
|
|
||||||
<button class="btn btn--small btn--outline" type="button" data-action="hermes-deploy" data-role-id="${id}" data-role-name="${name}">Hermes 部署</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;">${actionButtons}</div>
|
||||||
</article>`;
|
</article>`;
|
||||||
})
|
}).join('');
|
||||||
.join('');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
|
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
|
||||||
}
|
}
|
||||||
|
|
@ -616,6 +937,22 @@
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 二维码弹窗 ---
|
||||||
|
function showQrCodeModal(qrUrl) {
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = 'qr-modal';
|
||||||
|
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000;';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div style="background:#fff;border-radius:16px;padding:2rem;text-align:center;max-width:90vw;">
|
||||||
|
<h3 style="margin-bottom:1rem;">角色二维码</h3>
|
||||||
|
<img src="${escapeHtml(qrUrl)}" alt="二维码" style="max-width:300px;width:100%;" />
|
||||||
|
<div style="margin-top:1rem;">
|
||||||
|
<button class="btn btn--outline" type="button" data-action="close-qr-modal">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
function switchCenterTab(tab) {
|
function switchCenterTab(tab) {
|
||||||
activeCenterTab = tab;
|
activeCenterTab = tab;
|
||||||
document.querySelectorAll('.center-tab').forEach((t) => {
|
document.querySelectorAll('.center-tab').forEach((t) => {
|
||||||
|
|
@ -957,6 +1294,16 @@ ${data.secrets || '无'}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 管理员审核列表卡片点击(需在通用 role-card 之前处理)
|
||||||
|
const adminReviewCard = e.target.closest('.admin-review-card');
|
||||||
|
if (adminReviewCard && !e.target.closest('[data-action]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const roleId = adminReviewCard.dataset.roleId;
|
||||||
|
renderAdminReviewDetail(roleId);
|
||||||
|
showView('admin-review-detail');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Role card click (U2/U3)
|
// Role card click (U2/U3)
|
||||||
const roleCard = e.target.closest('.role-card');
|
const roleCard = e.target.closest('.role-card');
|
||||||
if (roleCard && !e.target.closest('[data-action]')) {
|
if (roleCard && !e.target.closest('[data-action]')) {
|
||||||
|
|
@ -975,11 +1322,30 @@ ${data.secrets || '无'}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 管理员筛选 Tab
|
||||||
|
const adminFilter = e.target.closest('.admin-filter');
|
||||||
|
if (adminFilter) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.querySelectorAll('.admin-filter').forEach((f) => f.classList.remove('active'));
|
||||||
|
adminFilter.classList.add('active');
|
||||||
|
adminCurrentFilter = adminFilter.dataset.filter;
|
||||||
|
renderAdminReviews();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const target = e.target.closest('[data-action], [data-tab], [data-download], [data-center-tab]');
|
const target = e.target.closest('[data-action], [data-tab], [data-download], [data-center-tab]');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|
||||||
const action = target.dataset.action;
|
const action = target.dataset.action;
|
||||||
|
|
||||||
|
// 管理员 Tab 切换(需在通用 center tab 之前处理)
|
||||||
|
const adminCenterTab = target.dataset.centerTab;
|
||||||
|
if (adminCenterTab && adminCenterTab.startsWith('admin-')) {
|
||||||
|
e.preventDefault();
|
||||||
|
switchAdminTab(adminCenterTab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Center tab switching (U7)
|
// Center tab switching (U7)
|
||||||
if (target.dataset.centerTab) {
|
if (target.dataset.centerTab) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -1115,6 +1481,102 @@ ${data.secrets || '无'}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 管理员操作 ---
|
||||||
|
if (action === 'admin-logout') {
|
||||||
|
e.preventDefault();
|
||||||
|
setAdminToken('');
|
||||||
|
adminState.isLoggedIn = false;
|
||||||
|
saveAdminState();
|
||||||
|
window.location.hash = '';
|
||||||
|
showView('landing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'back-to-admin') {
|
||||||
|
e.preventDefault();
|
||||||
|
showView('admin-reviews');
|
||||||
|
renderAdminReviews();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员审核操作
|
||||||
|
if (action === 'admin-approve') {
|
||||||
|
e.preventDefault();
|
||||||
|
const roleId = target.dataset.roleId;
|
||||||
|
try {
|
||||||
|
await adminApi(`/admin/reviews/${roleId}/approve`, { method: 'POST' });
|
||||||
|
alert('审核通过');
|
||||||
|
renderAdminReviewDetail(roleId);
|
||||||
|
} catch (err) {
|
||||||
|
alert('操作失败:' + err.message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'admin-reject') {
|
||||||
|
e.preventDefault();
|
||||||
|
const roleId = target.dataset.roleId;
|
||||||
|
const reason = prompt('请输入驳回原因:');
|
||||||
|
if (!reason) return;
|
||||||
|
try {
|
||||||
|
await adminApi(`/admin/reviews/${roleId}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ reviewNote: reason }),
|
||||||
|
});
|
||||||
|
alert('已驳回');
|
||||||
|
renderAdminReviewDetail(roleId);
|
||||||
|
} catch (err) {
|
||||||
|
alert('操作失败:' + err.message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员保存配置
|
||||||
|
if (action === 'admin-save-config') {
|
||||||
|
e.preventDefault();
|
||||||
|
const key = target.dataset.configKey;
|
||||||
|
const input = document.querySelector(`input[data-config-key="${key}"]`);
|
||||||
|
if (!input) return;
|
||||||
|
try {
|
||||||
|
await adminApi(`/admin/config/${key}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ value: input.value }),
|
||||||
|
});
|
||||||
|
alert('配置已保存');
|
||||||
|
renderAdminConfig();
|
||||||
|
} catch (err) {
|
||||||
|
alert('保存失败:' + err.message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 二维码操作 ---
|
||||||
|
if (action === 'show-qrcode') {
|
||||||
|
e.preventDefault();
|
||||||
|
const qrUrl = target.dataset.qrUrl;
|
||||||
|
showQrCodeModal(qrUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'copy-qrcode') {
|
||||||
|
e.preventDefault();
|
||||||
|
const qrUrl = target.dataset.qrUrl;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(qrUrl);
|
||||||
|
alert('二维码链接已复制');
|
||||||
|
} catch {
|
||||||
|
alert('复制失败:' + qrUrl);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'close-qr-modal') {
|
||||||
|
e.preventDefault();
|
||||||
|
const modal = document.getElementById('qr-modal');
|
||||||
|
if (modal) modal.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'logout') {
|
if (action === 'logout') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
logout();
|
logout();
|
||||||
|
|
@ -1229,6 +1691,30 @@ ${data.secrets || '无'}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 管理员登录表单 ---
|
||||||
|
const adminLoginForm = document.getElementById('admin-login-form');
|
||||||
|
if (adminLoginForm) {
|
||||||
|
adminLoginForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const data = Object.fromEntries(formData.entries());
|
||||||
|
try {
|
||||||
|
const result = await adminApi('/admin-auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ account: data.account, password: data.password }),
|
||||||
|
});
|
||||||
|
setAdminToken(result.token);
|
||||||
|
adminState.isLoggedIn = true;
|
||||||
|
adminState.account = result.admin.account;
|
||||||
|
saveAdminState();
|
||||||
|
showView('admin-reviews');
|
||||||
|
renderAdminReviews();
|
||||||
|
} catch (err) {
|
||||||
|
alert('登录失败:' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Settings form (U7) ---
|
// --- Settings form (U7) ---
|
||||||
document.getElementById('settings-form').addEventListener('submit', async (e) => {
|
document.getElementById('settings-form').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -1291,4 +1777,29 @@ ${data.secrets || '无'}
|
||||||
setToken('');
|
setToken('');
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// --- 管理员后台路由 ---
|
||||||
|
async function handleAdminRoute() {
|
||||||
|
if (window.location.hash !== '#admin') return;
|
||||||
|
const token = getAdminToken();
|
||||||
|
if (!token) {
|
||||||
|
showView('admin-login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 验证 token 有效性
|
||||||
|
try {
|
||||||
|
await adminApi('/admin-auth/me');
|
||||||
|
showView('admin-reviews');
|
||||||
|
renderAdminReviews();
|
||||||
|
} catch {
|
||||||
|
setAdminToken('');
|
||||||
|
adminState.isLoggedIn = false;
|
||||||
|
saveAdminState();
|
||||||
|
showView('admin-login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', handleAdminRoute);
|
||||||
|
// 页面加载时检查 hash
|
||||||
|
handleAdminRoute();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,517 @@
|
||||||
|
# Plan: 管理员审核 + Hermes 同步工作流
|
||||||
|
|
||||||
|
**Status:** active
|
||||||
|
**Created:** 2026-06-21
|
||||||
|
**Origin:** docs/brainstorms/2026-06-20-hermes-cross-machine-deploy-requirements.md (演进)
|
||||||
|
**Plan depth:** Standard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
实现「管理员审核 + 管理员发起 Hermes 同步」的新工作流。角色创建后进入待审核状态,管理员后台审核通过后填写同步参数(profile 名、主 model key/服务商、多媒体 model key/服务商、定时任务开关),EternalAI 用一次性 sync_token 向 Hermes 发起同步请求,Hermes 回调拉取文件并创建 profile,返回绑定二维码。测试范围限定为鉴权和文件拉取(无真实 Hermes,用 mock 端点验证)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
当前流程:创作者创建角色后直接上架,用户自行用 API Key + curl 拉取配置到 Hermes。存在以下问题:
|
||||||
|
1. 缺少内容审核环节,任何人设都可直接发布
|
||||||
|
2. 同步由用户手动操作,无法集中管控
|
||||||
|
3. Hermes 配置参数(model key、服务商等)分散在用户侧,不一致
|
||||||
|
|
||||||
|
新流程:管理员集中审核 + 管理员发起同步 + Hermes 回调拉取 + 二维码绑定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### 功能需求
|
||||||
|
|
||||||
|
- **R1**: 角色创建后状态为 `pending_review`,需管理员审核才能上架
|
||||||
|
- **R2**: 独立 Admin 表,管理员有独立登录入口
|
||||||
|
- **R3**: 管理员后台可查看待审核列表、角色详情,通过/驳回
|
||||||
|
- **R4**: 审核通过后管理员填写同步参数并发起同步
|
||||||
|
- **R5**: EternalAI 用一次性 sync_token(HMAC SHA-256 对称密钥)向 Hermes POST 同步请求
|
||||||
|
- **R6**: Hermes 用 sync_token 回调 EternalAI 拉取 SOUL.md 和 config.yaml
|
||||||
|
- **R7**: Hermes 返回二维码 URL,EternalAI 存储并展示给管理员和创作者
|
||||||
|
- **R8**: Hermes webhook URL 全局配置,同步时可覆盖
|
||||||
|
- **R9**: 测试范围:鉴权(admin 登录、sync_token 验证)+ 文件拉取(mock Hermes 回调)
|
||||||
|
|
||||||
|
### 非功能需求
|
||||||
|
|
||||||
|
- sync_token 5 分钟过期,一次性消费
|
||||||
|
- Hermes 回调拉取端点同时支持 sync_token 和现有 API Key 认证
|
||||||
|
- 现有用户侧 API Key 拉取流程保留不变
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
### KTD1: 独立 Admin 表
|
||||||
|
|
||||||
|
Admin 与 User 分离,独立登录接口,不混用 JWT。
|
||||||
|
|
||||||
|
**理由**: 用户选择。管理员权限边界清晰,避免 User 表 isAdmin 字段的权限提升风险。
|
||||||
|
|
||||||
|
### KTD2: sync_token 用 HMAC SHA-256 对称密钥
|
||||||
|
|
||||||
|
EternalAI 和 Hermes 预共享 `SYNC_SECRET`,sync_token 是 JWT(HS256),payload 含 `roleId`、`adminId`、`iat`、`exp`(5 分钟)。
|
||||||
|
|
||||||
|
**理由**: 用户选择。对称密钥实现简单,双方预共享即可。
|
||||||
|
|
||||||
|
### KTD3: Hermes webhook URL 全局配置 + 可覆盖
|
||||||
|
|
||||||
|
系统设置表存储 `HERMES_WEBHOOK_URL`,管理员发起同步时可在表单中覆盖。
|
||||||
|
|
||||||
|
**理由**: 用户选择。大多数情况用全局配置,特殊场景可覆盖。
|
||||||
|
|
||||||
|
### KTD4: 二维码由 Hermes 生成返回
|
||||||
|
|
||||||
|
Hermes 创建 profile 后生成二维码 URL 返回给 EternalAI,EternalAI 存储到 Role 记录并展示。
|
||||||
|
|
||||||
|
**理由**: 用户选择。二维码内容(微信绑定链接)由 Hermes 侧定义。
|
||||||
|
|
||||||
|
### KTD5: 角色审核状态机
|
||||||
|
|
||||||
|
```
|
||||||
|
pending_review → approved → syncing → synced
|
||||||
|
pending_review → rejected
|
||||||
|
syncing → failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Role 模型新增 `reviewStatus` 字段(默认 `pending_review`),新增 `qrCodeUrl`、`syncedAt` 字段。
|
||||||
|
|
||||||
|
### KTD6: Mock Hermes 测试端点
|
||||||
|
|
||||||
|
在 EternalAI 内部创建 `/api/mock-hermes/*` 端点模拟 Hermes 行为:接收同步请求、用 sync_token 回调拉取文件、返回 mock 二维码。仅测试环境启用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Level Technical Design
|
||||||
|
|
||||||
|
### 同步流程时序图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Admin as 管理员
|
||||||
|
participant EAI as EternalAI
|
||||||
|
participant Hermes as Hermes (Mock)
|
||||||
|
|
||||||
|
Admin->>EAI: POST /api/admin/sync/:roleId
|
||||||
|
Note over EAI: 生成 sync_token (JWT, 5min)
|
||||||
|
EAI->>Hermes: POST {webhook_url} /api/sync
|
||||||
|
Note over EAI: Body: { profileName, modelKey,<br/>provider, multimediaModelKey,<br/>multimediaProvider, enableSchedule,<br/>sync_token, file_pull_base_url }
|
||||||
|
Hermes->>Hermes: 验证 sync_token 签名
|
||||||
|
Hermes->>EAI: GET /api/hermes/roles/:id/SOUL.md
|
||||||
|
Note over Hermes: Header: X-Sync-Token
|
||||||
|
EAI->>EAI: 验证 sync_token (签名+过期+未消费)
|
||||||
|
EAI-->>Hermes: SOUL.md content
|
||||||
|
Hermes->>EAI: GET /api/hermes/roles/:id/config.yaml
|
||||||
|
Note over Hermes: Header: X-Sync-Token
|
||||||
|
EAI-->>Hermes: config.yaml content
|
||||||
|
Note over Hermes: 创建 profile, 生成二维码
|
||||||
|
Hermes-->>EAI: 200 { qrCodeUrl, profileId }
|
||||||
|
Note over EAI: 存储 qrCodeUrl, 更新 reviewStatus=synced
|
||||||
|
EAI-->>Admin: 200 { qrCodeUrl, reviewStatus }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 角色审核状态机
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> pending_review : 创建角色
|
||||||
|
pending_review --> approved : 管理员通过
|
||||||
|
pending_review --> rejected : 管理员驳回
|
||||||
|
approved --> syncing : 管理员发起同步
|
||||||
|
syncing --> synced : Hermes 返回成功
|
||||||
|
syncing --> failed : 同步失败/超时
|
||||||
|
failed --> syncing : 重新同步
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### U1. Admin 数据模型与认证
|
||||||
|
|
||||||
|
**Goal:** 创建独立 Admin 表,实现管理员注册/登录/中间件。
|
||||||
|
|
||||||
|
**Requirements:** R2
|
||||||
|
|
||||||
|
**Dependencies:** 无
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `prisma/schema.prisma` — 新增 Admin 模型
|
||||||
|
- `src/lib/auth.js` — 新增 `adminSignToken`、`adminVerifyToken`、`adminAuthMiddleware`
|
||||||
|
- `src/routes/admin-auth.js` — 新建,管理员登录路由
|
||||||
|
- `server.js` — 注册 `/api/admin-auth` 路由
|
||||||
|
- `e2e/admin-auth.spec.js` — 新建,管理员认证测试
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Admin 模型:`id`、`account`(唯一)、`password`(bcrypt)、`createdAt`
|
||||||
|
- Admin JWT 与用户 JWT 使用不同 secret(`ADMIN_JWT_SECRET`),防止跨角色伪造
|
||||||
|
- `adminAuthMiddleware` 验证 Admin JWT,设置 `req.adminId`
|
||||||
|
- 管理员账号通过 `prisma db seed` 或 CLI 脚本创建,不开放注册 API
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 管理员登录成功,返回 admin JWT
|
||||||
|
- 管理员登录密码错误,返回 401
|
||||||
|
- 无 token 访问管理员接口,返回 401
|
||||||
|
- 用户 JWT 访问管理员接口,返回 403(secret 不同,验证失败)
|
||||||
|
- 管理员 JWT 访问用户接口,返回 401(用户中间件不识别 admin token)
|
||||||
|
|
||||||
|
**Verification:** 管理员可登录,admin JWT 可访问管理员接口,用户 JWT 不可访问管理员接口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U2. 角色审核状态机与审核 API
|
||||||
|
|
||||||
|
**Goal:** Role 模型新增审核状态字段,实现审核 API。
|
||||||
|
|
||||||
|
**Requirements:** R1, R3
|
||||||
|
|
||||||
|
**Dependencies:** U1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `prisma/schema.prisma` — Role 模型新增 `reviewStatus`、`qrCodeUrl`、`syncedAt`、`reviewNote` 字段
|
||||||
|
- `src/routes/admin.js` — 新建,管理员审核路由
|
||||||
|
- `server.js` — 注册 `/api/admin` 路由
|
||||||
|
- `e2e/admin-review.spec.js` — 新建,审核流程测试
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- `reviewStatus` 枚举值:`pending_review`(默认)、`approved`、`rejected`、`syncing`、`synced`、`failed`
|
||||||
|
- 现有 `POST /api/roles` 创建角色时自动设置 `reviewStatus = 'pending_review'`
|
||||||
|
- `GET /api/admin/reviews` — 待审核列表(分页,按 createdAt desc)
|
||||||
|
- `GET /api/admin/reviews/:roleId` — 角色详情(含所有字段)
|
||||||
|
- `POST /api/admin/reviews/:roleId/approve` — 通过审核,状态 → `approved`
|
||||||
|
- `POST /api/admin/reviews/:roleId/reject` — 驳回,状态 → `rejected`,body 含 `reviewNote`
|
||||||
|
- 角色库 `GET /api/roles` 只返回 `reviewStatus = 'synced'` 的角色(已同步完成才上架)
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 创建角色后 reviewStatus 为 pending_review
|
||||||
|
- 管理员获取待审核列表,包含 pending_review 角色
|
||||||
|
- 管理员通过审核,状态变为 approved
|
||||||
|
- 管理员驳回审核,状态变为 rejected,reviewNote 有值
|
||||||
|
- 非管理员调用审核接口,返回 401
|
||||||
|
- 角色库不显示 pending_review / approved / rejected 状态的角色
|
||||||
|
|
||||||
|
**Verification:** 审核状态流转正确,非管理员无法操作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U3. sync_token 机制与系统配置
|
||||||
|
|
||||||
|
**Goal:** 实现 sync_token 生成/验证,系统配置存储 Hermes webhook URL。
|
||||||
|
|
||||||
|
**Requirements:** R5, R8
|
||||||
|
|
||||||
|
**Dependencies:** U1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `prisma/schema.prisma` — 新增 SystemConfig 模型(key-value 存储)
|
||||||
|
- `src/lib/sync-token.js` — 新建,sync_token 生成与验证
|
||||||
|
- `src/routes/admin-config.js` — 新建,系统配置管理路由
|
||||||
|
- `server.js` — 注册 `/api/admin/config` 路由
|
||||||
|
- `e2e/sync-token.spec.js` — 新建,sync_token 测试
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- SystemConfig 模型:`key`(唯一)、`value`、`updatedAt`
|
||||||
|
- 预置配置项:`HERMES_WEBHOOK_URL`、`SYNC_SECRET`
|
||||||
|
- `sync-token.js`:
|
||||||
|
- `generateSyncToken(roleId, adminId)` — 生成 JWT(HS256),payload `{ roleId, adminId, iat, exp }`,5 分钟过期
|
||||||
|
- `verifySyncToken(token)` — 验证签名 + 过期,返回 payload 或 null
|
||||||
|
- 使用 `SYNC_SECRET` 从 SystemConfig 读取(首次启动自动生成)
|
||||||
|
- `PUT /api/admin/config/:key` — 更新配置项(仅管理员)
|
||||||
|
- `GET /api/admin/config` — 获取所有配置(仅管理员,敏感值脱敏)
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 生成 sync_token,验证签名通过,payload 正确
|
||||||
|
- 过期 token(>5min)验证失败
|
||||||
|
- 篡改 payload 后验证失败(签名不匹配)
|
||||||
|
- 管理员更新 HERMES_WEBHOOK_URL,再次读取值正确
|
||||||
|
- 非管理员访问配置接口,返回 401
|
||||||
|
|
||||||
|
**Verification:** sync_token 生成/验证正确,系统配置可读写。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U4. 同步发起 API
|
||||||
|
|
||||||
|
**Goal:** 管理员发起同步,EternalAI 向 Hermes POST 请求。
|
||||||
|
|
||||||
|
**Requirements:** R4, R5, R6
|
||||||
|
|
||||||
|
**Dependencies:** U2, U3
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/routes/admin-sync.js` — 新建,同步发起路由
|
||||||
|
- `src/lib/hermes-client.js` — 新建,Hermes HTTP 客户端
|
||||||
|
- `server.js` — 注册 `/api/admin/sync` 路由
|
||||||
|
- `e2e/admin-sync.spec.js` — 新建,同步发起测试
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- `POST /api/admin/sync/:roleId` — 发起同步
|
||||||
|
- Body: `{ profileName, modelKey, provider, multimediaModelKey, multimediaProvider, enableSchedule, webhookUrl? }`
|
||||||
|
- `webhookUrl` 可选,未提供则用 SystemConfig 中的 `HERMES_WEBHOOK_URL`
|
||||||
|
- 前置检查:`reviewStatus` 必须为 `approved` 或 `failed`
|
||||||
|
- 生成 sync_token,更新 `reviewStatus = 'syncing'`
|
||||||
|
- 调用 `hermes-client.js` 的 `postSync(webhookUrl, payload)` 向 Hermes POST
|
||||||
|
- Hermes 返回 `{ qrCodeUrl, profileId }` → 存储 `qrCodeUrl`,更新 `reviewStatus = 'synced'`,记录 `syncedAt`
|
||||||
|
- Hermes 返回错误 → 更新 `reviewStatus = 'failed'`
|
||||||
|
- 请求超时(30s)→ 更新 `reviewStatus = 'failed'`
|
||||||
|
- `hermes-client.js`:
|
||||||
|
- `postSync(webhookUrl, payload)` — 用 `fetch` POST 到 Hermes,payload 含 sync_token 和 file_pull_base_url
|
||||||
|
- `file_pull_base_url` = EternalAI 自身的基础 URL(从 SystemConfig 读取 `ETERNALAI_BASE_URL`)
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 管理员对 approved 角色发起同步,reviewStatus 变为 syncing
|
||||||
|
- Mock Hermes 返回成功,reviewStatus 变为 synced,qrCodeUrl 有值
|
||||||
|
- Mock Hermes 返回错误,reviewStatus 变为 failed
|
||||||
|
- 对 pending_review 角色发起同步,返回 400
|
||||||
|
- 对 syncing 角色发起同步,返回 409(重复同步)
|
||||||
|
- 非管理员发起同步,返回 401
|
||||||
|
|
||||||
|
**Verification:** 同步发起后状态流转正确,成功时存储二维码 URL。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U5. Hermes 回调拉取端点改造
|
||||||
|
|
||||||
|
**Goal:** 改造现有 `/api/hermes/` 端点,支持 sync_token 认证。
|
||||||
|
|
||||||
|
**Requirements:** R6
|
||||||
|
|
||||||
|
**Dependencies:** U3
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/routes/hermes.js` — 改造,新增 sync_token 认证路径
|
||||||
|
- `src/lib/auth.js` — 新增 `syncTokenMiddleware`
|
||||||
|
- `e2e/hermes-callback.spec.js` — 新建,回调拉取测试
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 新增 `syncTokenMiddleware`:
|
||||||
|
- 读取 `X-Sync-Token` header
|
||||||
|
- 验证 sync_token 签名 + 过期
|
||||||
|
- 从 payload 提取 `roleId`,与 URL 中的 `:id` 比对,不一致返回 403
|
||||||
|
- 验证通过后设置 `req.userId`(通过 Role.creatorId 反查)和 `req.syncTokenPayload`
|
||||||
|
- 改造 `apiKeyMiddleware` 逻辑:
|
||||||
|
- 优先检查 `X-Sync-Token` header → 走 sync_token 路径
|
||||||
|
- 否则检查 `Authorization: Bearer eak_` → 走 API Key 路径
|
||||||
|
- 否则检查 `Authorization: Bearer <jwt>` → 走 JWT 路径
|
||||||
|
- sync_token 消费后标记为已使用(内存 Set,5 分钟后自动清理;或用 token jti + 短期缓存)
|
||||||
|
- SOUL.md 和 config.yaml 端点保持 text/plain 响应不变
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 用有效 sync_token 拉取 SOUL.md,返回 200 + 文件内容
|
||||||
|
- 用有效 sync_token 拉取 config.yaml,返回 200 + 文件内容
|
||||||
|
- sync_token 中的 roleId 与 URL :id 不匹配,返回 403
|
||||||
|
- 过期 sync_token,返回 401
|
||||||
|
- 已消费的 sync_token 再次使用,返回 401
|
||||||
|
- 无 token 访问,返回 401
|
||||||
|
- 用 API Key(eak_)访问仍正常工作(向后兼容)
|
||||||
|
|
||||||
|
**Verification:** sync_token 可拉取文件,向后兼容 API Key 认证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U6. 二维码存储与状态展示
|
||||||
|
|
||||||
|
**Goal:** 存储 Hermes 返回的二维码 URL,展示给管理员和创作者。
|
||||||
|
|
||||||
|
**Requirements:** R7
|
||||||
|
|
||||||
|
**Dependencies:** U4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/routes/roles.js` — 改造,`GET /api/roles/my/roles` 返回 reviewStatus 和 qrCodeUrl
|
||||||
|
- `src/routes/admin.js` — 改造,管理员可查看所有角色的同步状态
|
||||||
|
- `app.js` — 改造,创作者角色卡片显示审核状态和二维码
|
||||||
|
- `e2e/qr-display.spec.js` — 新建,二维码展示测试
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- `GET /api/roles/my/roles` 返回字段新增 `reviewStatus`、`qrCodeUrl`、`syncedAt`
|
||||||
|
- 创作者角色卡片根据 reviewStatus 显示状态标签:
|
||||||
|
- `pending_review` → "待审核"(灰色)
|
||||||
|
- `approved` → "已通过,等待同步"(蓝色)
|
||||||
|
- `syncing` → "同步中"(黄色)
|
||||||
|
- `synced` → "已同步"(绿色)+ 显示二维码
|
||||||
|
- `failed` → "同步失败"(红色)
|
||||||
|
- `rejected` → "已驳回"(红色)
|
||||||
|
- synced 状态的角色卡片显示二维码图片(`qrCodeUrl`)和"转发二维码"按钮
|
||||||
|
- 管理员后台有"同步状态"页面,显示所有角色的审核+同步状态
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 创作者查看自己的角色列表,pending_review 角色显示"待审核"标签
|
||||||
|
- 同步成功后,创作者角色卡片显示二维码图片
|
||||||
|
- 驳回角色显示"已驳回"标签
|
||||||
|
- 管理员查看同步状态列表,包含所有角色的 reviewStatus
|
||||||
|
|
||||||
|
**Verification:** 创作者和管理员都能看到正确的审核状态和二维码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U7. 管理员后台 UI
|
||||||
|
|
||||||
|
**Goal:** 管理员登录页 + 审核列表 + 角色详情审核页 + 同步表单。
|
||||||
|
|
||||||
|
**Requirements:** R2, R3, R4
|
||||||
|
|
||||||
|
**Dependencies:** U1, U2, U4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html` — 新增管理员视图(admin-login、admin-reviews、admin-sync)
|
||||||
|
- `app.js` — 新增管理员路由、审核交互、同步表单
|
||||||
|
- `styles.css` — 管理员后台样式
|
||||||
|
- `e2e/admin-ui.spec.js` — 新建,管理员 UI 测试
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 管理员入口:首页底部隐藏链接 `/admin`,或直接访问 `#admin-login`
|
||||||
|
- 管理员登录页:账号 + 密码,登录后跳转 `#admin-reviews`
|
||||||
|
- 审核列表页:表格显示角色名、创作者、创建时间、状态;点击进入详情
|
||||||
|
- 角色详情审核页:显示所有角色字段,"通过"和"驳回"按钮
|
||||||
|
- 同步表单(审核通过后显示):
|
||||||
|
- profile 名字(text,默认角色名)
|
||||||
|
- 主 model 服务商(select:openrouter / together / local)
|
||||||
|
- 主 model key(password input)
|
||||||
|
- 多媒体 model 服务商(select)
|
||||||
|
- 多媒体 model key(password input)
|
||||||
|
- 是否开启定时任务(checkbox)
|
||||||
|
- Hermes webhook URL(text,默认全局配置值,可覆盖)
|
||||||
|
- "发起同步"按钮
|
||||||
|
- 同步成功后显示二维码图片和"复制链接"按钮
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 管理员登录后跳转审核列表
|
||||||
|
- 审核列表显示待审核角色
|
||||||
|
- 点击角色进入详情,显示完整信息
|
||||||
|
- 通过审核后显示同步表单
|
||||||
|
- 填写同步参数并提交,显示同步中状态
|
||||||
|
- 同步成功后显示二维码
|
||||||
|
|
||||||
|
**Verification:** 管理员可完成登录→审核→同步的完整 UI 流程。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U8. Mock Hermes 测试端点
|
||||||
|
|
||||||
|
**Goal:** 在 EternalAI 内部创建 mock Hermes 端点,用于测试同步流程。
|
||||||
|
|
||||||
|
**Requirements:** R9
|
||||||
|
|
||||||
|
**Dependencies:** U3, U5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/routes/mock-hermes.js` — 新建,mock Hermes 端点
|
||||||
|
- `server.js` — 注册 `/api/mock-hermes` 路由(仅非 production 环境)
|
||||||
|
- `e2e/mock-hermes.spec.js` — 新建,mock Hermes 集成测试
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- `POST /api/mock-hermes/sync` — 模拟 Hermes 接收同步请求
|
||||||
|
- 验证 sync_token 签名(用同一 SYNC_SECRET)
|
||||||
|
- 用 sync_token 回调 EternalAI 拉取 SOUL.md 和 config.yaml
|
||||||
|
- 返回 mock 二维码 URL:`https://mock.hermes.local/qr/<roleId>`
|
||||||
|
- 返回 mock profileId:`mock-profile-<roleId>`
|
||||||
|
- Mock 端点用 `fetch` 回调 EternalAI 自身的 `/api/hermes/roles/:id/SOUL.md` 和 `/api/hermes/roles/:id/config.yaml`
|
||||||
|
- 回调时携带 `X-Sync-Token` header
|
||||||
|
- 仅在 `NODE_ENV !== 'production'` 时注册路由
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- POST /api/mock-hermes/sync 收到请求后,回调拉取 SOUL.md 成功
|
||||||
|
- POST /api/mock-hermes/sync 回调拉取 config.yaml 成功
|
||||||
|
- 返回 mock 二维码 URL 和 profileId
|
||||||
|
- 无效 sync_token,mock Hermes 返回 401
|
||||||
|
- production 环境下 /api/mock-hermes 路由不存在(404)
|
||||||
|
|
||||||
|
**Verification:** Mock Hermes 完整模拟同步流程,可用于 E2E 测试。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U9. E2E 集成测试:完整审核+同步流程
|
||||||
|
|
||||||
|
**Goal:** 端到端测试从创建角色到同步成功的完整流程。
|
||||||
|
|
||||||
|
**Requirements:** R1-R9
|
||||||
|
|
||||||
|
**Dependencies:** U1-U8
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `e2e/full-sync-flow.spec.js` — 新建,完整流程测试
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
测试完整流程:
|
||||||
|
1. 创作者注册 → 登录 → 创建角色(状态 pending_review)
|
||||||
|
2. 管理员登录 → 查看待审核列表 → 查看详情 → 通过审核
|
||||||
|
3. 管理员填写同步参数 → 发起同步
|
||||||
|
4. Mock Hermes 接收请求 → 回调拉取文件 → 返回二维码
|
||||||
|
5. 创作者查看角色列表 → 看到已同步状态 + 二维码
|
||||||
|
6. 管理员查看同步状态 → 看到已同步
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 完整流程:创建 → 审核 → 同步 → 二维码展示
|
||||||
|
- 驳回流程:创建 → 审核 → 驳回 → 创作者看到"已驳回"
|
||||||
|
- 同步失败流程:创建 → 审核 → 同步(mock 返回错误)→ 状态 failed → 重新同步
|
||||||
|
- 向后兼容:现有 API Key 拉取流程仍正常工作
|
||||||
|
|
||||||
|
**Verification:** E2E 测试覆盖所有核心路径,全部通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
- Admin 表与认证
|
||||||
|
- 角色审核状态机
|
||||||
|
- sync_token 生成/验证/消费
|
||||||
|
- 同步发起 API
|
||||||
|
- Hermes 回调拉取端点改造(sync_token 认证)
|
||||||
|
- 二维码存储与展示
|
||||||
|
- 管理员后台 UI
|
||||||
|
- Mock Hermes 测试端点
|
||||||
|
- E2E 测试(鉴权 + 文件拉取)
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
- 真实 Hermes 服务器对接(用 mock 代替)
|
||||||
|
- 微信公众号/小程序绑定实现(二维码内容由 Hermes 侧定义)
|
||||||
|
- 管理员注册 UI(通过 seed 脚本创建)
|
||||||
|
- 速率限制(已有 P2 待修复,不在本次范围)
|
||||||
|
- 现有 API Key 拉取流程的改造(保持向后兼容)
|
||||||
|
|
||||||
|
### Deferred to Follow-Up Work
|
||||||
|
|
||||||
|
- 真实 Hermes 服务器对接(需 Hermes 侧实现 `/api/sync` 端点)
|
||||||
|
- 微信绑定流程实现
|
||||||
|
- 同步重试机制(指数退避)
|
||||||
|
- 同步日志审计
|
||||||
|
- 多 Hermes 实例支持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解 |
|
||||||
|
|------|------|------|
|
||||||
|
| sync_token 内存消费记录在多实例部署下失效 | 重复消费风险 | 后续可改用 Redis;当前单实例够用 |
|
||||||
|
| Mock Hermes 端点误暴露到生产环境 | 安全风险 | 仅 `NODE_ENV !== 'production'` 注册路由 |
|
||||||
|
| Hermes 回调时 EternalAI 不可达 | 同步失败 | 同步状态设为 failed,管理员可重试 |
|
||||||
|
| sync_token 在传输中被截获 | 5 分钟内可滥用 | HTTPS + 一次性消费 + 短过期 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **OQ1**: sync_token 一次性消费用内存 Set 还是数据库表?(计划用内存 Set,单实例部署够用;多实例时改 Redis)
|
||||||
|
- **OQ2**: 管理员账号初始创建方式?(计划用 `prisma db seed` 脚本,账号密码从环境变量读取)
|
||||||
|
- **OQ3**: EternalAI 自身的基础 URL(`ETERNALAI_BASE_URL`)如何获取?(计划从 SystemConfig 读取,首次部署时配置)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System-Wide Impact
|
||||||
|
|
||||||
|
- **数据库**: 新增 Admin、SystemConfig 表;Role 表新增 4 个字段
|
||||||
|
- **API**: 新增 `/api/admin-auth`、`/api/admin`、`/api/admin/config`、`/api/admin/sync`、`/api/mock-hermes` 路由组
|
||||||
|
- **前端**: 新增 3 个管理员视图,改造创作者角色卡片
|
||||||
|
- **认证**: 新增 Admin JWT(独立 secret)和 sync_token(HMAC SHA-256)两套机制
|
||||||
|
- **向后兼容**: 现有用户 API Key 拉取流程不变
|
||||||
|
|
@ -0,0 +1,461 @@
|
||||||
|
---
|
||||||
|
title: "feat: Admin UI + Creator Review Status + Order Payment"
|
||||||
|
status: active
|
||||||
|
created: 2026-06-21
|
||||||
|
origin: docs/plans/2026-06-21-001-feat-admin-review-hermes-sync-plan.md (U7 前端缺失)
|
||||||
|
plan_depth: standard
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plan: 管理员后台 UI + 创作者审核状态展示 + 订单付款流程
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
补齐 EternalAI 平台的三个核心功能缺口:(1) 管理员后台 UI,让管理员可通过浏览器完成角色审核和 Hermes 同步发起;(2) 创作者角色卡片审核状态展示,让创作者看到自己角色的审核进度和二维码;(3) Order 订单后端 API + 前端付款流程对接,替换当前的纯前端 Mock。微信 OAuth 和收入/提现后端不在本次范围内。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
当前平台存在三个阻断性缺口:
|
||||||
|
|
||||||
|
1. **管理员无法通过浏览器操作**:后端审核 API(`/api/admin/reviews/*`)、同步 API(`/api/admin/sync/*`)、配置 API(`/api/admin/config/*`)均已就绪,但 `index.html` 中无任何 admin 视图,`app.js` 中无 admin 相关代码。管理员只能通过 curl/Postman 操作,无法实际使用。
|
||||||
|
|
||||||
|
2. **创作者看不到审核状态**:后端 `GET /api/roles/my/roles` 已返回 `reviewStatus`、`qrCodeUrl`、`syncedAt`、`reviewNote`,但前端 `renderCreatorRoles()` 只显示 `status`(running/stopped),创作者无法知道自己角色处于待审核/已通过/已同步/被驳回哪个状态,也看不到驳回原因和同步后的二维码。
|
||||||
|
|
||||||
|
3. **付款流程是前端 Mock**:`prisma/schema.prisma` 定义了 Order 模型但无任何路由,`app.js` 的 `payRole()` 仅切换 DOM 显示,无 API 调用、无订单创建。用户"付款"后看不到真实二维码,平台无法记录订阅关系。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### 功能需求
|
||||||
|
|
||||||
|
- **R1**: 管理员可通过浏览器登录后台(独立登录入口,与用户登录隔离)
|
||||||
|
- **R2**: 管理员可查看待审核角色列表,支持按状态筛选
|
||||||
|
- **R3**: 管理员可查看角色详情,通过或驳回(驳回需填写原因)
|
||||||
|
- **R4**: 管理员可对已通过角色发起 Hermes 同步,填写 profile 名、model key、服务商等参数
|
||||||
|
- **R5**: 同步成功后管理员可看到二维码和 profileId
|
||||||
|
- **R6**: 管理员可查看同步状态列表(approved/syncing/synced/failed)
|
||||||
|
- **R7**: 管理员可配置系统参数(HERMES_WEBHOOK_URL 等)
|
||||||
|
- **R8**: 创作者角色卡片显示审核状态标签(待审核/已通过/同步中/已同步/同步失败/已驳回)
|
||||||
|
- **R9**: 被驳回角色卡片显示驳回原因
|
||||||
|
- **R10**: 已同步角色卡片显示二维码图片和「转发二维码」按钮
|
||||||
|
- **R11**: 后端提供 Order CRUD API(创建/查询)
|
||||||
|
- **R12**: 前端付款流程调用后端 API 创建订单,付款成功后展示真实二维码
|
||||||
|
|
||||||
|
### 非功能需求
|
||||||
|
|
||||||
|
- 管理员 token 与用户 token 隔离存储,不互相干扰
|
||||||
|
- 管理员 UI 遵循现有视图路由模式(showView + views 注册)
|
||||||
|
- Order API 需用户认证(authMiddleware)
|
||||||
|
- 付款流程暂不对接真实支付网关,创建订单即视为已付款(status=paid),返回角色二维码
|
||||||
|
|
||||||
|
### 范围边界
|
||||||
|
|
||||||
|
**在范围内**:
|
||||||
|
- 管理员后台 UI(登录、审核列表、审核详情、同步表单、同步状态、系统配置)
|
||||||
|
- 创作者角色卡片审核状态展示
|
||||||
|
- Order 后端 API + 前端付款流程对接
|
||||||
|
|
||||||
|
**不在范围内**(Deferred):
|
||||||
|
- 微信 OAuth 真实接入(hermes-server/src/routes/bind.js 仍为占位符)
|
||||||
|
- 收入/提现后端 API(仍为前端 Mock)
|
||||||
|
- 真实支付网关对接(本次创建订单即视为已付款)
|
||||||
|
- 角色删除/上下架切换
|
||||||
|
- 定时任务调度逻辑(Hermes Agent 侧实现)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
### KTD1: 管理员 token 独立存储
|
||||||
|
|
||||||
|
管理员 token 存储在 `localStorage` 的 `eternal_ai_admin_token`,与用户 token(`eternal_ai_token`)隔离。管理员 state 存储在 `eternal_ai_admin_state`,与用户 state(`eternal_ai_state`)隔离。
|
||||||
|
|
||||||
|
**理由**: 管理员和用户可能是同一人,token 隔离避免互相覆盖。管理员 API 使用独立的 `ADMIN_JWT_SECRET`,token 不可混用。
|
||||||
|
|
||||||
|
### KTD2: 管理员入口通过 URL hash 访问
|
||||||
|
|
||||||
|
管理员后台不显示在底部 TabBar 中(普通用户不可见)。入口通过 URL hash `#admin` 直达,或在首页添加隐藏入口(如长按 logo)。
|
||||||
|
|
||||||
|
**理由**: 管理员后台是内部工具,不应暴露给普通用户。URL hash 方式简单直接,管理员可收藏书签。
|
||||||
|
|
||||||
|
### KTD3: Order 状态简化为 pending → paid
|
||||||
|
|
||||||
|
本次 Order 状态仅 `pending`(待付款)和 `paid`(已付款)。不对接真实支付网关,创建订单时前端直接标记为 `paid`。后续对接真实支付时扩展 `failed`/`refunded` 状态。
|
||||||
|
|
||||||
|
**理由**: 本次目标是让付款流程有后端记录,而非实现完整支付系统。简化状态机避免过度设计。
|
||||||
|
|
||||||
|
### KTD4: 付款后返回角色 qrCodeUrl
|
||||||
|
|
||||||
|
Order 创建(付款)成功后,后端返回角色的 `qrCodeUrl`(来自 Role 表的 `qrCodeUrl` 字段,由 Hermes 同步时写入)。前端展示该二维码供用户扫码绑定微信。
|
||||||
|
|
||||||
|
**理由**: 用户付款的目的是获取角色二维码以扫码绑定。`qrCodeUrl` 已在同步流程中存储到 Role 表,直接返回即可。若角色未同步(无 qrCodeUrl),则返回错误提示。
|
||||||
|
|
||||||
|
### KTD5: 创作者角色卡片状态基于 reviewStatus 优先
|
||||||
|
|
||||||
|
角色卡片状态标签优先显示 `reviewStatus`(pending_review/approved/rejected/syncing/synced/failed),仅当 `reviewStatus` 为 `synced` 时才显示 `status`(running/stopped)作为运行状态。
|
||||||
|
|
||||||
|
**理由**: `reviewStatus` 是角色的生命周期主状态,`status` 仅在同步完成后才有意义。避免两个状态标签混淆。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Level Technical Design
|
||||||
|
|
||||||
|
### 管理员后台视图架构
|
||||||
|
|
||||||
|
```
|
||||||
|
用户访问 #admin
|
||||||
|
↓
|
||||||
|
检查 adminToken 是否有效(GET /api/admin-auth/me)
|
||||||
|
↓
|
||||||
|
有效 → 显示审核列表页 无效 → 显示管理员登录页
|
||||||
|
↓ ↓
|
||||||
|
点击角色 → 审核详情页 登录成功 → 存储 adminToken → 显示审核列表页
|
||||||
|
↓
|
||||||
|
通过 → 返回列表页(状态更新)
|
||||||
|
驳回 → 弹窗填写原因 → 返回列表页
|
||||||
|
同步 → 同步表单页 → 提交 → 显示二维码 → 返回列表页
|
||||||
|
```
|
||||||
|
|
||||||
|
### Order 付款流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户在角色详情页点击「立即订阅」
|
||||||
|
↓
|
||||||
|
POST /api/orders { roleId, amount }
|
||||||
|
↓
|
||||||
|
后端校验角色已同步(reviewStatus=synced, qrCodeUrl 非空)
|
||||||
|
↓
|
||||||
|
创建 Order 记录(status=paid)
|
||||||
|
↓
|
||||||
|
返回 { order, role: { qrCodeUrl } }
|
||||||
|
↓
|
||||||
|
前端展示二维码
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### U1. 管理员登录页 + 认证状态管理
|
||||||
|
|
||||||
|
**Goal**: 实现管理员登录页面和独立的 token/state 管理机制。
|
||||||
|
|
||||||
|
**Requirements**: R1
|
||||||
|
|
||||||
|
**Dependencies**: 无(后端 API 已就绪)
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `index.html` — 新增 `#admin-login` 视图和 `#admin-reviews` 视图骨架
|
||||||
|
- `app.js` — 新增管理员 state 管理、adminApi 封装、登录逻辑、hash 路由
|
||||||
|
|
||||||
|
**Approach**:
|
||||||
|
1. 在 `index.html` 中新增两个视图:
|
||||||
|
- `#admin-login`:管理员登录表单(账号、密码、登录按钮)
|
||||||
|
- `#admin-reviews`:管理员后台主视图(包含审核列表、详情、同步等子面板,通过内部 Tab 切换)
|
||||||
|
2. 在 `app.js` 中新增:
|
||||||
|
- `adminState` 对象和 `loadAdminState()`/`saveAdminState()` 函数(key: `eternal_ai_admin_state`)
|
||||||
|
- `getAdminToken()`/`setAdminToken()` 函数(key: `eternal_ai_admin_token`)
|
||||||
|
- `adminApi(path, options)` 封装(自动添加管理员 Authorization 头)
|
||||||
|
- `handleHashRoute()` 函数:监听 `hashchange`,当 hash 为 `#admin` 时检查管理员登录状态并显示对应视图
|
||||||
|
- `handleAdminLogin(formData)` 函数:调用 `POST /api/admin-auth/login`,成功后存储 token 并跳转审核列表
|
||||||
|
3. 在 `views` 对象中注册 `'admin-login'` 和 `'admin-reviews'`
|
||||||
|
4. 在 `viewLabels` 中添加对应标签
|
||||||
|
|
||||||
|
**Patterns to follow**:
|
||||||
|
- 用户登录表单模式(`app.js:1189-1232` 行的 auth 表单提交)
|
||||||
|
- `api()` 封装模式(`app.js:4-42` 行),adminApi 镜照此模式但读取 adminToken
|
||||||
|
- 视图注册模式(`app.js:112-122` 行 views 对象)
|
||||||
|
|
||||||
|
**Test scenarios**:
|
||||||
|
- **Happy path**: 管理员访问 `#admin`,输入正确账号密码,登录成功跳转审核列表页
|
||||||
|
- **Error path**: 管理员输入错误密码,显示错误提示
|
||||||
|
- **Edge case**: 管理员已登录时访问 `#admin`,直接跳转审核列表页(不显示登录表单)
|
||||||
|
- **Edge case**: 非管理员用户访问 `#admin`,显示登录页(用户 token 不能用于管理员 API)
|
||||||
|
- **Integration**: 登录后调用 `GET /api/admin-auth/me` 验证 token 有效
|
||||||
|
|
||||||
|
**Verification**: 管理员可通过 `#admin` URL 访问登录页,登录成功后进入审核列表页,刷新页面后仍保持登录状态。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U2. 管理员审核列表页 + 角色详情审核页
|
||||||
|
|
||||||
|
**Goal**: 实现审核列表(支持状态筛选)和角色详情审核页(通过/驳回)。
|
||||||
|
|
||||||
|
**Requirements**: R2, R3, R6
|
||||||
|
|
||||||
|
**Dependencies**: U1
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `index.html` — 在 `#admin-reviews` 视图中添加审核列表和详情面板的 HTML 结构
|
||||||
|
- `app.js` — 新增 `renderAdminReviews()`、`renderAdminReviewDetail()`、`handleApprove()`、`handleReject()` 函数
|
||||||
|
- `e2e/admin-sync-flow.spec.js` — 扩展 E2E 测试覆盖管理员审核 UI 流程(可选,已有 API 级测试)
|
||||||
|
|
||||||
|
**Approach**:
|
||||||
|
1. 审核列表页结构:
|
||||||
|
- 顶部状态筛选 Tab(全部/待审核/已通过/已同步/同步失败/已驳回)
|
||||||
|
- 角色卡片列表(头像、名称、创作者、审核状态标签、创建时间)
|
||||||
|
- 点击卡片进入详情页
|
||||||
|
2. 详情审核页结构:
|
||||||
|
- 角色完整信息(头像、名称、描述、人设字段)
|
||||||
|
- 当前审核状态
|
||||||
|
- 操作按钮区:
|
||||||
|
- `pending_review` 状态:显示「通过」和「驳回」按钮
|
||||||
|
- `approved` 状态:显示「发起同步」按钮(跳转 U3)
|
||||||
|
- `synced` 状态:显示二维码图片和 profileId
|
||||||
|
- `rejected` 状态:显示驳回原因
|
||||||
|
- `failed` 状态:显示「重新同步」按钮
|
||||||
|
3. 驳回流程:点击「驳回」→ 弹窗输入原因 → 调用 `POST /api/admin/reviews/:roleId/reject`
|
||||||
|
4. 列表数据通过 `GET /api/admin/reviews?status=xxx` 获取,支持分页
|
||||||
|
|
||||||
|
**Patterns to follow**:
|
||||||
|
- 角色库列表渲染模式(`app.js:302-339` 行 `renderRoleLibrary()`)
|
||||||
|
- 创作者角色卡片渲染模式(`app.js:415-450` 行 `renderCreatorRoles()`)
|
||||||
|
- Tab 切换模式(`app.js:409-431` 行 creator-center 的 center-tabs)
|
||||||
|
|
||||||
|
**Test scenarios**:
|
||||||
|
- **Happy path**: 管理员登录后看到待审核角色列表,点击角色进入详情,点击「通过」后角色状态变为 approved
|
||||||
|
- **Happy path**: 管理员点击「驳回」,输入原因,角色状态变为 rejected,列表中显示驳回原因
|
||||||
|
- **Edge case**: 切换状态筛选 Tab,列表正确过滤
|
||||||
|
- **Error path**: 对已审核角色再次操作,后端返回 400,前端显示错误提示
|
||||||
|
- **Integration**: 审核通过后,角色出现在「已通过」筛选列表中
|
||||||
|
|
||||||
|
**Verification**: 管理员可查看各状态角色列表,可审核通过或驳回角色,操作后列表实时更新。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U3. 管理员同步表单 + 二维码展示 + 系统配置
|
||||||
|
|
||||||
|
**Goal**: 实现同步发起表单、同步成功后二维码展示、系统配置页面。
|
||||||
|
|
||||||
|
**Requirements**: R4, R5, R7
|
||||||
|
|
||||||
|
**Dependencies**: U2
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `index.html` — 在 `#admin-reviews` 视图中添加同步表单面板和配置面板的 HTML
|
||||||
|
- `app.js` — 新增 `renderSyncForm(roleId)`、`handleSyncSubmit()`、`renderSyncResult()`、`renderAdminConfig()`、`handleConfigUpdate()` 函数
|
||||||
|
|
||||||
|
**Approach**:
|
||||||
|
1. 同步表单(在详情页中 `approved` 状态时显示):
|
||||||
|
- 字段:profile 名(必填)、主 model key(必填)、主服务商(必填,下拉选择 openrouter/siliconflow 等)、多媒体 model key、多媒体服务商、定时任务开关、webhook URL 覆盖(可选)
|
||||||
|
- 提交按钮:调用 `POST /api/admin/sync/:roleId`
|
||||||
|
- 提交后显示 loading 状态
|
||||||
|
2. 同步结果展示:
|
||||||
|
- 成功:显示二维码图片(`qrCodeUrl`)、profileId、「复制二维码链接」按钮
|
||||||
|
- 失败:显示错误信息、「重新同步」按钮
|
||||||
|
3. 系统配置页(管理员后台第三个 Tab):
|
||||||
|
- 列出所有系统配置(`GET /api/admin/config`)
|
||||||
|
- 每项配置可编辑(`PUT /api/admin/config/:key`)
|
||||||
|
- 敏感配置(SYNC_SECRET)显示 `***` 且不可编辑
|
||||||
|
- 重点配置:HERMES_WEBHOOK_URL、HERMES_ADMIN_TOKEN 等
|
||||||
|
|
||||||
|
**Patterns to follow**:
|
||||||
|
- 角色创建表单的 stepper 模式(`app.js:633-936` 行)
|
||||||
|
- 设置表单提交模式(`app.js:1233-1255` 行 settings 表单)
|
||||||
|
- Hermes 部署指南弹窗模式(`app.js:546-617` 行 `showHermesDeployGuide()`)
|
||||||
|
|
||||||
|
**Test scenarios**:
|
||||||
|
- **Happy path**: 管理员在 approved 角色详情页填写同步参数,提交后显示二维码和 profileId
|
||||||
|
- **Error path**: 同步失败(Hermes 不可达),显示错误信息
|
||||||
|
- **Edge case**: webhook URL 留空时使用全局配置
|
||||||
|
- **Edge case**: 同步中状态(syncing)时按钮禁用
|
||||||
|
- **Happy path**: 管理员在配置页修改 HERMES_WEBHOOK_URL,保存成功
|
||||||
|
- **Error path**: 尝试修改 SYNC_SECRET,返回 403 禁止操作
|
||||||
|
|
||||||
|
**Verification**: 管理员可发起同步并看到二维码,可修改系统配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U4. 创作者角色卡片审核状态展示
|
||||||
|
|
||||||
|
**Goal**: 修改创作者角色卡片,展示审核状态、驳回原因、二维码。
|
||||||
|
|
||||||
|
**Requirements**: R8, R9, R10
|
||||||
|
|
||||||
|
**Dependencies**: 无(后端已返回所需字段)
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `app.js` — 修改 `renderCreatorRoles()` 函数(约 415-450 行)
|
||||||
|
- `styles.css` — 新增审核状态标签样式
|
||||||
|
- `e2e/creator.spec.js` — 扩展测试覆盖审核状态展示
|
||||||
|
|
||||||
|
**Approach**:
|
||||||
|
1. 修改 `renderCreatorRoles()` 中的卡片渲染逻辑:
|
||||||
|
- 根据 `reviewStatus` 渲染状态标签(优先于 `status`):
|
||||||
|
- `pending_review` → 灰色标签「待审核」
|
||||||
|
- `approved` → 蓝色标签「已通过」
|
||||||
|
- `rejected` → 红色标签「已驳回」+ 显示 `reviewNote`
|
||||||
|
- `syncing` → 黄色标签「同步中」
|
||||||
|
- `synced` → 绿色标签「已同步」+ 显示 `status`(运行中/已停止)
|
||||||
|
- `failed` → 橙色标签「同步失败」
|
||||||
|
- `synced` 状态角色卡片额外显示:
|
||||||
|
- 二维码缩略图(`qrCodeUrl`)
|
||||||
|
- 「查看二维码」按钮(点击放大显示)
|
||||||
|
- 「转发二维码」按钮(复制链接到剪贴板)
|
||||||
|
- `rejected` 状态角色卡片额外显示:
|
||||||
|
- 驳回原因文本(`reviewNote`)
|
||||||
|
- 「编辑」按钮高亮提示修改
|
||||||
|
2. 新增 `showQrCodeModal(qrCodeUrl)` 函数:弹窗显示二维码大图
|
||||||
|
3. 新增 `copyQrCodeLink(qrCodeUrl)` 函数:复制二维码链接到剪贴板
|
||||||
|
|
||||||
|
**Patterns to follow**:
|
||||||
|
- 现有角色卡片状态标签模式(`role-card__status--${statusClass}`)
|
||||||
|
- Hermes 部署指南弹窗模式(`app.js:546-617` 行)
|
||||||
|
- `escapeHtml()` 用于所有用户可控内容
|
||||||
|
|
||||||
|
**Test scenarios**:
|
||||||
|
- **Happy path**: 创作者创建角色后,卡片显示「待审核」标签
|
||||||
|
- **Happy path**: 管理员审核通过后,创作者卡片显示「已通过」标签
|
||||||
|
- **Happy path**: 同步完成后,创作者卡片显示「已同步」+ 二维码缩略图
|
||||||
|
- **Happy path**: 被驳回后,创作者卡片显示「已驳回」+ 驳回原因
|
||||||
|
- **Edge case**: 点击「查看二维码」弹窗显示大图
|
||||||
|
- **Edge case**: 点击「转发二维码」复制链接到剪贴板
|
||||||
|
|
||||||
|
**Verification**: 创作者可在角色列表看到每个角色的审核状态、驳回原因和二维码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U5. Order 订单后端 API
|
||||||
|
|
||||||
|
**Goal**: 实现 Order 模型的 CRUD 后端端点。
|
||||||
|
|
||||||
|
**Requirements**: R11
|
||||||
|
|
||||||
|
**Dependencies**: 无(Order 模型已在 schema 中定义)
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `src/routes/orders.js` — 新建订单路由文件
|
||||||
|
- `server.js` — 挂载 `/api/orders` 路由
|
||||||
|
- `e2e/orders.spec.js` — 新建订单 E2E 测试
|
||||||
|
|
||||||
|
**Approach**:
|
||||||
|
1. 新建 `src/routes/orders.js`,实现以下端点:
|
||||||
|
- `POST /api/orders` — 创建订单(付款)
|
||||||
|
- 认证:`authMiddleware`
|
||||||
|
- 请求体:`{ roleId, amount }`
|
||||||
|
- 逻辑:
|
||||||
|
- 校验 roleId 存在且角色 `reviewStatus='synced'` 且 `qrCodeUrl` 非空
|
||||||
|
- 校验 amount > 0
|
||||||
|
- 创建 Order 记录(status='paid',userId=当前用户)
|
||||||
|
- 返回 `{ order: { id, roleId, amount, status, createdAt }, role: { qrCodeUrl, displayName } }`
|
||||||
|
- `GET /api/orders` — 查询当前用户订单列表
|
||||||
|
- 认证:`authMiddleware`
|
||||||
|
- 返回:`{ orders: [{ id, roleId, role: { displayName, avatar }, amount, status, createdAt }] }`
|
||||||
|
- `GET /api/orders/:id` — 查询订单详情
|
||||||
|
- 认证:`authMiddleware`
|
||||||
|
- 校验:订单属于当前用户
|
||||||
|
- 返回:`{ order: { ...完整字段, role: { ...角色信息 } } }`
|
||||||
|
2. 在 `server.js` 中挂载路由:`app.use('/api/orders', require('./src/routes/orders'));`
|
||||||
|
3. 错误处理:
|
||||||
|
- 角色未同步:返回 400 `{ error: '该角色尚未同步,无法订阅' }`
|
||||||
|
- 角色不存在:返回 404
|
||||||
|
- 订单不属于当前用户:返回 403
|
||||||
|
|
||||||
|
**Patterns to follow**:
|
||||||
|
- 现有路由模式(`src/routes/roles.js` 的 `authMiddleware` 使用)
|
||||||
|
- 现有错误处理模式(`res.status(400).json({ error: '...' })`)
|
||||||
|
- Prisma 查询模式(`prisma.order.findMany({ where: { userId }, include: { role: true } })`)
|
||||||
|
|
||||||
|
**Test scenarios**:
|
||||||
|
- **Happy path**: 用户对已同步角色发起付款,创建订单成功,返回 qrCodeUrl
|
||||||
|
- **Error path**: 用户对未同步角色发起付款,返回 400
|
||||||
|
- **Error path**: 未认证用户发起付款,返回 401
|
||||||
|
- **Edge case**: 用户查询自己的订单列表,只返回自己的订单
|
||||||
|
- **Edge case**: 用户查询他人订单,返回 403
|
||||||
|
- **Integration**: 创建订单后,订单列表中包含该订单
|
||||||
|
|
||||||
|
**Verification**: Order API 可创建、查询订单,角色未同步时拒绝创建。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U6. 前端付款流程对接
|
||||||
|
|
||||||
|
**Goal**: 修改前端 `payRole()` 函数,调用后端 API 创建订单并展示真实二维码。
|
||||||
|
|
||||||
|
**Requirements**: R12
|
||||||
|
|
||||||
|
**Dependencies**: U5
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `app.js` — 修改 `payRole()` 函数(约 361-367 行),新增 `renderPaidState()` 函数
|
||||||
|
- `index.html` — 修改 `#role-detail` 视图中的付款后区域结构
|
||||||
|
- `e2e/roles.spec.js` — 扩展测试覆盖付款流程
|
||||||
|
|
||||||
|
**Approach**:
|
||||||
|
1. 修改 `payRole()` 函数:
|
||||||
|
- 调用 `POST /api/orders` 传入 `{ roleId: currentRole.id, amount: currentRole.price }`
|
||||||
|
- 成功后调用 `renderPaidState(data.role.qrCodeUrl)` 展示二维码
|
||||||
|
- 失败时 `alert` 错误信息
|
||||||
|
- 添加 loading 状态(按钮禁用 + 文字变为「处理中…」)
|
||||||
|
2. 新增 `renderPaidState(qrCodeUrl)` 函数:
|
||||||
|
- 隐藏「立即订阅」按钮
|
||||||
|
- 显示「已订阅」区域
|
||||||
|
- 渲染二维码图片(`<img src="${qrCodeUrl}" alt="角色二维码" />`)
|
||||||
|
- 添加「保存二维码」按钮(长按或下载)
|
||||||
|
3. 修改 `#role-detail` 视图 HTML:
|
||||||
|
- `#detail-qr` 区域改为可容纳 `<img>` 的容器
|
||||||
|
- 移除 `qr-placeholder` 占位符
|
||||||
|
|
||||||
|
**Patterns to follow**:
|
||||||
|
- 现有 `api()` 调用模式(`app.js:4-42` 行)
|
||||||
|
- 现有按钮 loading 模式(禁用 + 文字变化)
|
||||||
|
- `escapeHtml()` 用于所有动态内容
|
||||||
|
|
||||||
|
**Test scenarios**:
|
||||||
|
- **Happy path**: 用户点击「立即订阅」,调用 API 成功,显示真实二维码
|
||||||
|
- **Error path**: 角色未同步,API 返回 400,显示错误提示
|
||||||
|
- **Error path**: 网络错误,显示「无法连接服务器」
|
||||||
|
- **Edge case**: 重复点击「立即订阅」按钮,第二次点击被禁用(loading 状态)
|
||||||
|
- **Integration**: 付款后刷新页面,角色详情页仍显示已订阅状态(基于订单记录)
|
||||||
|
|
||||||
|
**Verification**: 用户付款后看到真实二维码,非占位符。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
### 风险
|
||||||
|
|
||||||
|
1. **管理员 UI 复杂度**: 管理员后台涉及多个面板(审核列表、详情、同步表单、配置),HTML 和 JS 代码量较大。**缓解**: 分三个实现单元(U1/U2/U3)逐步实现,每个单元可独立验证。
|
||||||
|
|
||||||
|
2. **Order 付款流程简化**: 本次创建订单即视为已付款,不对接真实支付。后续对接支付网关时需重构。**缓解**: Order 模型已预留 `status` 字段,后续扩展状态机即可。
|
||||||
|
|
||||||
|
3. **创作者卡片状态展示复杂性**: `reviewStatus` 和 `status` 两个状态字段的展示逻辑需仔细设计,避免混淆。**缓解**: KTD5 明确了优先级规则——`reviewStatus` 优先,仅 `synced` 时显示 `status`。
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
- 后端管理员 API 已全部就绪(U1-U3 依赖)
|
||||||
|
- 后端 `GET /api/roles/my/roles` 已返回所需字段(U4 依赖)
|
||||||
|
- Order 模型已在 Prisma schema 中定义(U5 依赖)
|
||||||
|
- 前端 `payRole()` 函数已存在(U6 修改对象)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred to Follow-Up Work
|
||||||
|
|
||||||
|
- 微信 OAuth 真实接入(hermes-server/src/routes/bind.js 的 `wechat_oauth_placeholder`)
|
||||||
|
- 收入/提现后端 API(当前仍为前端 Mock 数据)
|
||||||
|
- 真实支付网关对接(当前创建订单即视为已付款)
|
||||||
|
- 角色删除/上下架切换
|
||||||
|
- 定时任务调度逻辑(enableSchedule 标志已传递,但 Hermes Server 无实际调度)
|
||||||
|
- 管理员后台移动端适配优化(本次优先功能完整性)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System-Wide Impact
|
||||||
|
|
||||||
|
- **用户**: 普通用户在角色详情页付款后能看到真实二维码(U6)
|
||||||
|
- **创作者**: 创作者可在角色列表看到审核状态和二维码(U4)
|
||||||
|
- **管理员**: 管理员可通过浏览器完成审核和同步操作(U1-U3)
|
||||||
|
- **数据库**: Order 表开始有数据写入(U5)
|
||||||
|
- **API**: 新增 `/api/orders` 路由(U5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Examples
|
||||||
|
|
||||||
|
- **AE1**: 管理员访问 `#admin`,登录后看到待审核角色列表,点击「通过」→ 角色状态变为 approved → 点击「发起同步」→ 填写参数 → 显示二维码
|
||||||
|
- **AE2**: 创作者创建角色后,在角色列表看到「待审核」标签 → 管理员驳回后,看到「已驳回」+ 驳回原因 → 修改后重新提交,看到「待审核」
|
||||||
|
- **AE3**: 用户在角色详情页点击「立即订阅」→ 调用 API 创建订单 → 显示真实二维码 → 用户扫码绑定
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
const { test, expect, request } = require('@playwright/test');
|
||||||
|
const { cleanDatabase, seedExistingUser, seedAdmin, disconnect, prisma } = require('./fixtures/database');
|
||||||
|
|
||||||
|
test.describe('管理员审核 + Hermes 同步流程', () => {
|
||||||
|
let adminToken;
|
||||||
|
let userToken;
|
||||||
|
let roleId;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await seedExistingUser();
|
||||||
|
await seedAdmin();
|
||||||
|
|
||||||
|
// 管理员登录
|
||||||
|
const adminContext = await request.newContext();
|
||||||
|
const adminRes = await adminContext.post('/api/admin-auth/login', {
|
||||||
|
data: { account: 'admin', password: 'admin123' },
|
||||||
|
});
|
||||||
|
const adminData = await adminRes.json();
|
||||||
|
adminToken = adminData.token;
|
||||||
|
|
||||||
|
// 用户登录
|
||||||
|
const userContext = await request.newContext();
|
||||||
|
const userRes = await userContext.post('/api/auth/login', {
|
||||||
|
data: { account: 'e2e_existing', password: 'Test123456' },
|
||||||
|
});
|
||||||
|
const userData = await userRes.json();
|
||||||
|
userToken = userData.token;
|
||||||
|
|
||||||
|
// 创建角色(待审核)
|
||||||
|
const roleRes = await userContext.post('/api/roles', {
|
||||||
|
headers: { Authorization: `Bearer ${userToken}` },
|
||||||
|
data: {
|
||||||
|
displayName: '测试角色',
|
||||||
|
personality: '温柔体贴',
|
||||||
|
background: '来自未来',
|
||||||
|
speechStyle: '轻声细语',
|
||||||
|
greeting: '你好呀',
|
||||||
|
soulMd: '# SOUL\n这是测试角色的灵魂文件',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const roleData = await roleRes.json();
|
||||||
|
roleId = roleData.role.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('管理员登录成功', async () => {
|
||||||
|
expect(adminToken).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('管理员登录密码错误返回 401', async () => {
|
||||||
|
const context = await request.newContext();
|
||||||
|
const res = await context.post('/api/admin-auth/login', {
|
||||||
|
data: { account: 'admin', password: 'wrong' },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('用户 JWT 不能访问管理员接口', async () => {
|
||||||
|
const context = await request.newContext();
|
||||||
|
const res = await context.get('/api/admin/reviews', {
|
||||||
|
headers: { Authorization: `Bearer ${userToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无 token 不能访问管理员接口', async () => {
|
||||||
|
const context = await request.newContext();
|
||||||
|
const res = await context.get('/api/admin/reviews');
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('创建角色后状态为 pending_review', async () => {
|
||||||
|
const role = await prisma.role.findUnique({ where: { id: roleId } });
|
||||||
|
expect(role.reviewStatus).toBe('pending_review');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('管理员获取待审核列表', async () => {
|
||||||
|
const context = await request.newContext();
|
||||||
|
const res = await context.get('/api/admin/reviews', {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.roles.length).toBeGreaterThan(0);
|
||||||
|
expect(data.roles[0].reviewStatus).toBe('pending_review');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('管理员获取角色详情', async () => {
|
||||||
|
const context = await request.newContext();
|
||||||
|
const res = await context.get(`/api/admin/reviews/${roleId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.role.displayName).toBe('测试角色');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('管理员通过审核', async () => {
|
||||||
|
const context = await request.newContext();
|
||||||
|
const res = await context.post(`/api/admin/reviews/${roleId}/approve`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.role.reviewStatus).toBe('approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('角色库不显示未同步的角色', async () => {
|
||||||
|
const context = await request.newContext();
|
||||||
|
const res = await context.get('/api/roles');
|
||||||
|
const data = await res.json();
|
||||||
|
const found = data.roles.find((r) => r.id === roleId);
|
||||||
|
expect(found).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('对非 approved 角色发起同步返回 400', async () => {
|
||||||
|
// 先创建一个新角色(pending_review)
|
||||||
|
const userContext = await request.newContext();
|
||||||
|
await userContext.post('/api/roles', {
|
||||||
|
headers: { Authorization: `Bearer ${userToken}` },
|
||||||
|
data: {
|
||||||
|
displayName: '未审核角色',
|
||||||
|
personality: '测试',
|
||||||
|
background: '测试',
|
||||||
|
speechStyle: '测试',
|
||||||
|
greeting: '测试',
|
||||||
|
soulMd: '# test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = await prisma.role.findMany({ where: { displayName: '未审核角色' } });
|
||||||
|
const pendingRoleId = roles[0].id;
|
||||||
|
|
||||||
|
const adminContext = await request.newContext();
|
||||||
|
const res = await adminContext.post(`/api/admin/sync/${pendingRoleId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
data: { profileName: 'test-profile' },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未配置 Hermes webhook URL 时同步返回 400', async () => {
|
||||||
|
const context = await request.newContext();
|
||||||
|
const res = await context.post(`/api/admin/sync/${roleId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
data: { profileName: 'test-profile' },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('配置 Hermes webhook URL 指向 mock', async () => {
|
||||||
|
const context = await request.newContext();
|
||||||
|
const res = await context.put('/api/admin/config/HERMES_WEBHOOK_URL', {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
data: { value: 'http://localhost:3001/api/mock-hermes/sync' },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('管理员发起同步 → mock Hermes 回调拉取文件 → 返回二维码', async () => {
|
||||||
|
const context = await request.newContext();
|
||||||
|
const res = await context.post(`/api/admin/sync/${roleId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
data: {
|
||||||
|
profileName: 'test-profile',
|
||||||
|
modelKey: 'sk-test-key',
|
||||||
|
provider: 'openrouter',
|
||||||
|
multimediaModelKey: 'sk-multi-key',
|
||||||
|
multimediaProvider: 'openrouter',
|
||||||
|
enableSchedule: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.role.reviewStatus).toBe('synced');
|
||||||
|
expect(data.role.qrCodeUrl).toContain('mock.hermes.local');
|
||||||
|
expect(data.profileId).toContain('mock-profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('同步成功后角色出现在角色库', async () => {
|
||||||
|
const context = await request.newContext();
|
||||||
|
const res = await context.get('/api/roles');
|
||||||
|
const data = await res.json();
|
||||||
|
const found = data.roles.find((r) => r.id === roleId);
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('用户查看自己的角色列表包含审核状态和二维码', async () => {
|
||||||
|
const context = await request.newContext();
|
||||||
|
const res = await context.get('/api/roles/my/roles', {
|
||||||
|
headers: { Authorization: `Bearer ${userToken}` },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const role = data.roles.find((r) => r.id === roleId);
|
||||||
|
expect(role).toBeDefined();
|
||||||
|
expect(role.reviewStatus).toBe('synced');
|
||||||
|
expect(role.qrCodeUrl).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sync_token 拉取文件 — SOUL.md', async () => {
|
||||||
|
// 生成新的 sync_token(通过管理员发起同步流程会自动生成)
|
||||||
|
// 这里直接用 API 测试:先通过审核另一个角色,再同步
|
||||||
|
const userContext = await request.newContext();
|
||||||
|
|
||||||
|
// 创建并审核新角色
|
||||||
|
const roleRes = await userContext.post('/api/roles', {
|
||||||
|
headers: { Authorization: `Bearer ${userToken}` },
|
||||||
|
data: {
|
||||||
|
displayName: '同步测试角色',
|
||||||
|
personality: '测试',
|
||||||
|
background: '测试',
|
||||||
|
speechStyle: '测试',
|
||||||
|
greeting: '测试',
|
||||||
|
soulMd: '# Test SOUL\n测试内容',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const newRoleId = (await roleRes.json()).role.id;
|
||||||
|
|
||||||
|
// 管理员审核通过
|
||||||
|
const adminContext = await request.newContext();
|
||||||
|
await adminContext.post(`/api/admin/reviews/${newRoleId}/approve`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发起同步(mock Hermes 会回调拉取文件)
|
||||||
|
const syncRes = await adminContext.post(`/api/admin/sync/${newRoleId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
data: {
|
||||||
|
profileName: 'test-profile-2',
|
||||||
|
modelKey: 'sk-test',
|
||||||
|
provider: 'openrouter',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(syncRes.status()).toBe(200);
|
||||||
|
const syncData = await syncRes.json();
|
||||||
|
expect(syncData.role.reviewStatus).toBe('synced');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('驳回审核流程', async () => {
|
||||||
|
const userContext = await request.newContext();
|
||||||
|
const roleRes = await userContext.post('/api/roles', {
|
||||||
|
headers: { Authorization: `Bearer ${userToken}` },
|
||||||
|
data: {
|
||||||
|
displayName: '待驳回角色',
|
||||||
|
personality: '测试',
|
||||||
|
background: '测试',
|
||||||
|
speechStyle: '测试',
|
||||||
|
greeting: '测试',
|
||||||
|
soulMd: '# test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rejectRoleId = (await roleRes.json()).role.id;
|
||||||
|
|
||||||
|
const adminContext = await request.newContext();
|
||||||
|
const res = await adminContext.post(`/api/admin/reviews/${rejectRoleId}/reject`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
data: { reviewNote: '内容不符合要求' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.role.reviewStatus).toBe('rejected');
|
||||||
|
expect(data.role.reviewNote).toBe('内容不符合要求');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('向后兼容:API Key 拉取仍正常工作', async () => {
|
||||||
|
// 生成 API Key
|
||||||
|
const userContext = await request.newContext();
|
||||||
|
const keyRes = await userContext.post('/api/apikeys', {
|
||||||
|
headers: { Authorization: `Bearer ${userToken}` },
|
||||||
|
data: { name: 'test-key' },
|
||||||
|
});
|
||||||
|
const apiKey = (await keyRes.json()).apiKey.key;
|
||||||
|
|
||||||
|
// 用 API Key 拉取 SOUL.md
|
||||||
|
const soulRes = await userContext.get(`/api/hermes/roles/${roleId}/SOUL.md`, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
});
|
||||||
|
expect(soulRes.status()).toBe(200);
|
||||||
|
const soulText = await soulRes.text();
|
||||||
|
expect(soulText).toContain('测试角色的灵魂文件');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('管理员获取同步状态列表', async () => {
|
||||||
|
const context = await request.newContext();
|
||||||
|
const res = await context.get('/api/admin/sync-status', {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.roles.length).toBeGreaterThan(0);
|
||||||
|
expect(data.roles.some((r) => r.reviewStatus === 'synced')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -25,6 +25,7 @@ test.describe('创作者中心与角色发布/编辑', () => {
|
||||||
desc: '温柔体贴的角色',
|
desc: '温柔体贴的角色',
|
||||||
price: 19.9,
|
price: 19.9,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
reviewStatus: 'synced',
|
||||||
temperature: 0.8,
|
temperature: 0.8,
|
||||||
maxTokens: 2048,
|
maxTokens: 2048,
|
||||||
enableMemory: true,
|
enableMemory: true,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ const prisma = new PrismaClient();
|
||||||
async function cleanDatabase() {
|
async function cleanDatabase() {
|
||||||
await prisma.order.deleteMany();
|
await prisma.order.deleteMany();
|
||||||
await prisma.role.deleteMany();
|
await prisma.role.deleteMany();
|
||||||
|
await prisma.apiKey.deleteMany();
|
||||||
|
await prisma.systemConfig.deleteMany();
|
||||||
await prisma.user.deleteMany();
|
await prisma.user.deleteMany();
|
||||||
|
// 不删除 Admin 表,保留测试管理员账号
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedExistingUser() {
|
async function seedExistingUser() {
|
||||||
|
|
@ -23,8 +26,21 @@ async function seedExistingUser() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedAdmin() {
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const admin = await prisma.admin.findUnique({ where: { account: 'admin' } });
|
||||||
|
if (!admin) {
|
||||||
|
await prisma.admin.create({
|
||||||
|
data: {
|
||||||
|
account: 'admin',
|
||||||
|
password: bcrypt.hashSync('admin123', 10),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function disconnect() {
|
async function disconnect() {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { cleanDatabase, seedExistingUser, disconnect, prisma };
|
module.exports = { cleanDatabase, seedExistingUser, seedAdmin, disconnect, prisma };
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@ test.describe('导航与可访问性', () => {
|
||||||
desc: '键盘测试',
|
desc: '键盘测试',
|
||||||
price: 9.9,
|
price: 9.9,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
reviewStatus: 'synced',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ test.describe('角色库与角色详情', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 创建一个测试角色(status=running 才会在角色库展示)
|
// 创建一个测试角色(reviewStatus=synced 才会在角色库展示)
|
||||||
testRole = await prisma.role.create({
|
testRole = await prisma.role.create({
|
||||||
data: {
|
data: {
|
||||||
creatorId: existingUser.id,
|
creatorId: existingUser.id,
|
||||||
|
|
@ -45,7 +45,9 @@ test.describe('角色库与角色详情', () => {
|
||||||
desc: '温柔可爱的女友角色',
|
desc: '温柔可爱的女友角色',
|
||||||
price: 29.9,
|
price: 29.9,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
reviewStatus: 'synced',
|
||||||
avatar: 'https://example.com/avatar.png',
|
avatar: 'https://example.com/avatar.png',
|
||||||
|
qrCodeUrl: 'https://example.com/qr.png',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -101,6 +103,7 @@ test.describe('角色库与角色详情', () => {
|
||||||
desc: '阳光开朗的男孩',
|
desc: '阳光开朗的男孩',
|
||||||
price: 19.9,
|
price: 19.9,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
reviewStatus: 'synced',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# ===== Hermes Server 环境变量配置 =====
|
||||||
|
|
||||||
|
# 服务端口
|
||||||
|
PORT=3002
|
||||||
|
|
||||||
|
# Hermes Server 对外访问的基础 URL(用于生成二维码绑定链接)
|
||||||
|
# 必须是外部可访问的地址,二维码内容会基于此生成
|
||||||
|
HERMES_BASE_URL=http://localhost:3002
|
||||||
|
|
||||||
|
# ===== EternalAI 地址(P0 修复:用于回调拉取文件,不信任请求体) =====
|
||||||
|
# 配置为 EternalAI 服务器的可访问地址
|
||||||
|
# hermes-server 会从此地址拉取 SOUL.md 和 config.yaml
|
||||||
|
ETERNALAI_BASE_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# ===== 与 EternalAI 共享的 sync_token 密钥 =====
|
||||||
|
# 必须与 EternalAI 的 SYNC_SECRET 一致(EternalAI 数据库 SystemConfig 表中 key='SYNC_SECRET' 的值)
|
||||||
|
# 获取方式:在 EternalAI 服务器执行
|
||||||
|
# psql -d eternalai -c "SELECT value FROM \"SystemConfig\" WHERE key='SYNC_SECRET'"
|
||||||
|
# 或通过 Prisma Studio 查看
|
||||||
|
SYNC_SECRET=
|
||||||
|
|
||||||
|
# ===== Hermes 管理员认证 =====
|
||||||
|
# 管理 API(查看 profile 列表等)的 Bearer token
|
||||||
|
# 生产环境务必设置为强随机值(可用 openssl rand -hex 32 生成)
|
||||||
|
HERMES_ADMIN_TOKEN=dev_only_admin_token_change_me
|
||||||
|
|
||||||
|
# ===== 可选:EternalAI 地址白名单 =====
|
||||||
|
# 限制同步请求来源 IP(逗号分隔),留空则不限制
|
||||||
|
# 示例:ALLOWED_SOURCE_IPS=192.168.1.100,10.0.0.5
|
||||||
|
ALLOWED_SOURCE_IPS=
|
||||||
|
|
||||||
|
# ===== 可选:CORS 来源限制 =====
|
||||||
|
# 限制跨域请求来源(逗号分隔),留空则非生产环境允许所有来源
|
||||||
|
# 示例:ALLOWED_ORIGINS=https://eternalai.example.com,https://admin.eternalai.example.com
|
||||||
|
ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# ===== Node 环境 =====
|
||||||
|
NODE_ENV=development
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Hermes Server 部署脚本
|
||||||
|
# 在 Hermes 服务器上执行:bash deploy/deploy.sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_DIR="/opt/hermes-server"
|
||||||
|
APP_USER="hermes"
|
||||||
|
NODE_VERSION="20"
|
||||||
|
|
||||||
|
echo "===== Hermes Server 部署脚本 ====="
|
||||||
|
|
||||||
|
# 1. 检查 Node.js
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
echo "安装 Node.js $NODE_VERSION..."
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_$NODE_VERSION.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
fi
|
||||||
|
echo "Node.js 版本: $(node -v)"
|
||||||
|
|
||||||
|
# 2. 创建用户和目录
|
||||||
|
if ! id "$APP_USER" &> /dev/null; then
|
||||||
|
sudo useradd -r -m -d /home/$APP_USER -s /bin/bash $APP_USER
|
||||||
|
echo "创建用户: $APP_USER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo mkdir -p $APP_DIR
|
||||||
|
sudo chown -R $APP_USER:$APP_USER $APP_DIR
|
||||||
|
|
||||||
|
# 3. 复制代码(假设当前目录是项目根目录)
|
||||||
|
echo "复制代码到 $APP_DIR..."
|
||||||
|
sudo -u $APP_USER cp -r package.json package-lock.json server.js src $APP_DIR/
|
||||||
|
sudo -u $APP_USER cp -r deploy $APP_DIR/ 2>/dev/null || true
|
||||||
|
# .env.example 位于项目根目录,而非 deploy/ 子目录
|
||||||
|
sudo -u $APP_USER cp .env.example $APP_DIR/.env.example 2>/dev/null || true
|
||||||
|
|
||||||
|
# 4. 安装依赖
|
||||||
|
echo "安装依赖..."
|
||||||
|
cd $APP_DIR
|
||||||
|
# 优先使用 npm ci(依赖 package-lock.json,可重现构建),失败则回退到 npm install
|
||||||
|
if [ -f package-lock.json ]; then
|
||||||
|
sudo -u $APP_USER npm ci --production || sudo -u $APP_USER npm install --production
|
||||||
|
else
|
||||||
|
sudo -u $APP_USER npm install --production
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. 创建数据目录
|
||||||
|
sudo -u $APP_USER mkdir -p $APP_DIR/data/profiles $APP_DIR/data/qrcodes
|
||||||
|
|
||||||
|
# 6. 配置环境变量
|
||||||
|
if [ ! -f $APP_DIR/.env ]; then
|
||||||
|
echo "创建 .env 配置文件..."
|
||||||
|
# .env.example 已在第 3 步拷贝到 $APP_DIR 根目录
|
||||||
|
if [ -f $APP_DIR/.env.example ]; then
|
||||||
|
sudo -u $APP_USER cp $APP_DIR/.env.example $APP_DIR/.env
|
||||||
|
else
|
||||||
|
# 如果没有 .env.example,创建一个基本的
|
||||||
|
sudo -u $APP_USER bash -c "cat > $APP_DIR/.env << 'ENVEOF'
|
||||||
|
PORT=3002
|
||||||
|
HERMES_BASE_URL=http://localhost:3002
|
||||||
|
ETERNALAI_BASE_URL=http://localhost:3001
|
||||||
|
SYNC_SECRET=
|
||||||
|
HERMES_ADMIN_TOKEN=$(openssl rand -hex 32)
|
||||||
|
NODE_ENV=production
|
||||||
|
ENVEOF"
|
||||||
|
fi
|
||||||
|
echo "⚠️ 请编辑 $APP_DIR/.env 配置 SYNC_SECRET、HERMES_BASE_URL 和 ETERNALAI_BASE_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7. 配置 PM2
|
||||||
|
echo "配置 PM2..."
|
||||||
|
if ! command -v pm2 &> /dev/null; then
|
||||||
|
sudo npm install -g pm2
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo -u $APP_USER bash -c "cat > $APP_DIR/ecosystem.config.js << 'PM2EOF'
|
||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'hermes-server',
|
||||||
|
script: 'server.js',
|
||||||
|
cwd: '$APP_DIR',
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
max_memory_restart: '256M',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
PM2EOF"
|
||||||
|
|
||||||
|
pm2 delete hermes-server 2>/dev/null || true
|
||||||
|
pm2 start $APP_DIR/ecosystem.config.js
|
||||||
|
pm2 save
|
||||||
|
|
||||||
|
# 8. 配置 Nginx(可选)
|
||||||
|
NGINX_CONF="/etc/nginx/sites-available/hermes-server"
|
||||||
|
if command -v nginx &> /dev/null && [ ! -f "$NGINX_CONF" ]; then
|
||||||
|
echo "配置 Nginx..."
|
||||||
|
sudo bash -c "cat > $NGINX_CONF << 'NGINXEOF'
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _; # 替换为你的域名
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3002;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \\\$http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host \\\$host;
|
||||||
|
proxy_set_header X-Real-IP \\\$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \\\$scheme;
|
||||||
|
proxy_cache_bypass \\\$http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGINXEOF"
|
||||||
|
sudo ln -sf $NGINX_CONF /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
echo "Nginx 已配置"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "===== 部署完成 ====="
|
||||||
|
echo ""
|
||||||
|
echo "后续步骤:"
|
||||||
|
echo "1. 编辑配置: sudo -u $APP_USER nano $APP_DIR/.env"
|
||||||
|
echo " - SYNC_SECRET: 从 EternalAI 数据库获取(SELECT value FROM \"SystemConfig\" WHERE key='SYNC_SECRET')"
|
||||||
|
echo " - HERMES_BASE_URL: 设置为外部可访问的地址(如 https://hermes.yourdomain.com)"
|
||||||
|
echo " - ETERNALAI_BASE_URL: EternalAI 服务器地址(如 https://eternalai.yourdomain.com)"
|
||||||
|
echo "2. 重启服务: pm2 restart hermes-server"
|
||||||
|
echo "3. 在 EternalAI 管理后台配置 HERMES_WEBHOOK_URL 为: \${HERMES_BASE_URL}/api/sync"
|
||||||
|
echo "4. 健康检查: curl http://localhost:3002/api/health"
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"name": "hermes-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Hermes Server — 接收 EternalAI 同步请求,创建 profile,生成二维码绑定微信",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"qrcode": "^1.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
// Hermes Server 入口 — 接收 EternalAI 同步请求,创建 profile,生成二维码
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const { ensureDirs } = require('./src/lib/profile-store');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3002;
|
||||||
|
|
||||||
|
// 确保数据目录存在
|
||||||
|
ensureDirs();
|
||||||
|
|
||||||
|
// 中间件
|
||||||
|
// P2 修复:CORS 限制为已知来源
|
||||||
|
const allowedOrigins = process.env.ALLOWED_ORIGINS;
|
||||||
|
if (allowedOrigins) {
|
||||||
|
app.use(cors({ origin: allowedOrigins.split(',').map((o) => o.trim()) }));
|
||||||
|
} else {
|
||||||
|
// 非生产环境默认允许所有来源,生产环境需配置 ALLOWED_ORIGINS
|
||||||
|
app.use(cors());
|
||||||
|
}
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||||
|
|
||||||
|
// P1 修复:trust proxy 仅信任 loopback(Nginx 反向代理场景)
|
||||||
|
// 生产环境应配置为具体代理 IP,如 app.set('trust proxy', '10.0.0.1')
|
||||||
|
app.set('trust proxy', 'loopback');
|
||||||
|
|
||||||
|
// 路由
|
||||||
|
app.use('/api', require('./src/routes/health'));
|
||||||
|
app.use('/api', require('./src/routes/sync'));
|
||||||
|
app.use('/api/profiles', require('./src/routes/profiles'));
|
||||||
|
app.use('/api/bind', require('./src/routes/bind'));
|
||||||
|
|
||||||
|
// 二维码图片静态服务
|
||||||
|
const QRCODES_DIR = path.join(__dirname, 'data', 'qrcodes');
|
||||||
|
app.use('/api/qrcodes', express.static(QRCODES_DIR, {
|
||||||
|
setHeaders: (res) => {
|
||||||
|
res.type('image/png');
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 根路径 — 简单信息页
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
service: 'hermes-server',
|
||||||
|
status: 'running',
|
||||||
|
endpoints: {
|
||||||
|
sync: 'POST /api/sync',
|
||||||
|
profiles: 'GET /api/profiles',
|
||||||
|
bind: 'GET /api/bind/:profileId',
|
||||||
|
health: 'GET /api/health',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ error: '接口不存在' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('[Hermes Server] 未捕获错误:', err);
|
||||||
|
res.status(500).json({ error: '服务器内部错误' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Hermes Server running on http://0.0.0.0:${PORT}`);
|
||||||
|
console.log(`HERMES_BASE_URL: ${process.env.HERMES_BASE_URL || `http://localhost:${PORT}`}`);
|
||||||
|
console.log(`ETERNALAI_BASE_URL: ${process.env.ETERNALAI_BASE_URL || '⚠️ 未配置'}`);
|
||||||
|
console.log(`SYNC_SECRET: ${process.env.SYNC_SECRET ? '已配置' : '⚠️ 未配置'}`);
|
||||||
|
console.log(`HERMES_ADMIN_TOKEN: ${process.env.HERMES_ADMIN_TOKEN ? '已配置' : '⚠️ 使用默认值'}`);
|
||||||
|
console.log(`NODE_ENV: ${process.env.NODE_ENV || 'development'}`);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
// EternalAI 客户端 — 用 sync_token 回调 EternalAI 拉取 SOUL.md 和 config.yaml
|
||||||
|
// P0 修复:使用环境变量 ETERNALAI_BASE_URL,不信任请求体中的 filePullBaseUrl
|
||||||
|
|
||||||
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
|
const MAX_FILE_SIZE = 1024 * 1024; // 1MB 上限
|
||||||
|
|
||||||
|
// P0 修复:从环境变量获取 EternalAI 地址,防止 SSRF + sync_token 泄露
|
||||||
|
const ETERNALAI_BASE_URL = process.env.ETERNALAI_BASE_URL || '';
|
||||||
|
|
||||||
|
// 拉取单个文件,返回文本内容
|
||||||
|
async function pullFile(baseUrl, roleId, filename, syncToken) {
|
||||||
|
const url = `${baseUrl}/api/hermes/roles/${roleId}/${filename}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'X-Sync-Token': syncToken },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`拉取 ${filename} 失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// P1 修复:校验文件大小,防止 OOM
|
||||||
|
const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
|
||||||
|
if (contentLength > MAX_FILE_SIZE) {
|
||||||
|
throw new Error(`${filename} 文件过大 (${contentLength} bytes),超过 ${MAX_FILE_SIZE} 上限`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
// 即使没有 Content-Length 也校验实际大小
|
||||||
|
if (text.length > MAX_FILE_SIZE) {
|
||||||
|
throw new Error(`${filename} 文件过大 (${text.length} chars),超过上限`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error(`拉取 ${filename} 超时(${FETCH_TIMEOUT_MS}ms)`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取角色的 SOUL.md 和 config.yaml
|
||||||
|
// P0 修复:使用 ETERNALALAI_BASE_URL 环境变量,忽略请求体中的 filePullBaseUrl
|
||||||
|
async function pullRoleFiles(roleId, syncToken) {
|
||||||
|
const baseUrl = ETERNALAI_BASE_URL.replace(/\/+$/, ''); // 去掉尾部斜杠
|
||||||
|
if (!baseUrl) {
|
||||||
|
throw new Error('ETERNALAI_BASE_URL 环境变量未配置,无法拉取文件');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [soulMd, configYaml] = await Promise.all([
|
||||||
|
pullFile(baseUrl, roleId, 'SOUL.md', syncToken),
|
||||||
|
pullFile(baseUrl, roleId, 'config.yaml', syncToken),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { soulMd, configYaml };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { pullRoleFiles, pullFile, ETERNALAI_BASE_URL };
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
// Profile 存储 — 基于文件系统,每个 profile 一个目录
|
||||||
|
// 结构:data/profiles/{profileId}/meta.json + SOUL.md + config.yaml
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||||
|
const PROFILES_DIR = path.join(DATA_DIR, 'profiles');
|
||||||
|
const QRCODES_DIR = path.join(DATA_DIR, 'qrcodes');
|
||||||
|
|
||||||
|
// profileId 格式:hermes_ + 24 位 hex(共 31 字符)
|
||||||
|
const PROFILE_ID_RE = /^hermes_[a-f0-9]{24}$/;
|
||||||
|
|
||||||
|
// P0 修复:校验 profileId 格式,防止路径穿越
|
||||||
|
function isValidProfileId(profileId) {
|
||||||
|
return typeof profileId === 'string' && PROFILE_ID_RE.test(profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
function ensureDirs() {
|
||||||
|
for (const dir of [DATA_DIR, PROFILES_DIR, QRCODES_DIR]) {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 profileId(hermes_ 前缀 + 随机 hex)
|
||||||
|
function generateProfileId() {
|
||||||
|
return 'hermes_' + crypto.randomBytes(12).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 profile(P1 修复:原子写入 — 先写临时目录,全部成功后 rename)
|
||||||
|
function createProfile({ profileId, profileName, roleId, sourceBaseUrl, modelKey, provider, multimediaModelKey, multimediaProvider, enableSchedule, soulMd, configYaml, qrCodeUrl }) {
|
||||||
|
if (!isValidProfileId(profileId)) {
|
||||||
|
throw new Error('无效的 profileId');
|
||||||
|
}
|
||||||
|
ensureDirs();
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
profileId,
|
||||||
|
profileName,
|
||||||
|
roleId,
|
||||||
|
sourceBaseUrl,
|
||||||
|
modelKey,
|
||||||
|
provider,
|
||||||
|
multimediaModelKey,
|
||||||
|
multimediaProvider,
|
||||||
|
enableSchedule,
|
||||||
|
qrCodeUrl,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
boundWechat: null,
|
||||||
|
boundAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 写入临时目录,全部成功后原子 rename 到正式路径
|
||||||
|
const tmpDir = path.join(PROFILES_DIR, `.tmp_${profileId}_${Date.now()}`);
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'meta.json'), JSON.stringify(meta, null, 2));
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'SOUL.md'), soulMd);
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'config.yaml'), configYaml);
|
||||||
|
// 原子操作:rename 在同一文件系统上是原子的
|
||||||
|
fs.renameSync(tmpDir, path.join(PROFILES_DIR, profileId));
|
||||||
|
} catch (err) {
|
||||||
|
// 失败时清理临时目录
|
||||||
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 profile 元数据
|
||||||
|
function getProfileMeta(profileId) {
|
||||||
|
if (!isValidProfileId(profileId)) return null;
|
||||||
|
const metaPath = path.join(PROFILES_DIR, profileId, 'meta.json');
|
||||||
|
if (!fs.existsSync(metaPath)) return null;
|
||||||
|
return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 profile 文件内容
|
||||||
|
function getProfileFile(profileId, filename) {
|
||||||
|
if (!isValidProfileId(profileId)) return null;
|
||||||
|
// filename 仅允许已知文件名
|
||||||
|
const ALLOWED_FILES = ['SOUL.md', 'config.yaml'];
|
||||||
|
if (!ALLOWED_FILES.includes(filename)) return null;
|
||||||
|
const filePath = path.join(PROFILES_DIR, profileId, filename);
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
return fs.readFileSync(filePath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列出所有 profile(P2 修复:跳过损坏的 meta.json)
|
||||||
|
function listProfiles() {
|
||||||
|
ensureDirs();
|
||||||
|
const entries = fs.readdirSync(PROFILES_DIR, { withFileTypes: true });
|
||||||
|
const profiles = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory() && !entry.name.startsWith('.tmp_')) {
|
||||||
|
try {
|
||||||
|
const meta = getProfileMeta(entry.name);
|
||||||
|
if (meta) profiles.push(meta);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[Hermes] 跳过损坏的 profile: ${entry.name}`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 按创建时间倒序
|
||||||
|
profiles.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定微信(P1 修复:原子检查 — 写入前再次校验未绑定)
|
||||||
|
function bindWechat(profileId, wechatId) {
|
||||||
|
if (!isValidProfileId(profileId)) return null;
|
||||||
|
const meta = getProfileMeta(profileId);
|
||||||
|
if (!meta) return null;
|
||||||
|
|
||||||
|
// 原子检查:若已绑定则拒绝(防止 TOCTOU 竞态)
|
||||||
|
if (meta.boundWechat) return { alreadyBound: true };
|
||||||
|
|
||||||
|
meta.boundWechat = wechatId;
|
||||||
|
meta.boundAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// 原子写入:先写临时文件再 rename
|
||||||
|
const metaPath = path.join(PROFILES_DIR, profileId, 'meta.json');
|
||||||
|
const tmpPath = path.join(PROFILES_DIR, profileId, `.meta.json.tmp_${Date.now()}`);
|
||||||
|
fs.writeFileSync(tmpPath, JSON.stringify(meta, null, 2));
|
||||||
|
fs.renameSync(tmpPath, metaPath);
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 profile(P0 修复:校验 profileId 防止路径穿越)
|
||||||
|
function deleteProfile(profileId) {
|
||||||
|
if (!isValidProfileId(profileId)) return false;
|
||||||
|
const dir = path.join(PROFILES_DIR, profileId);
|
||||||
|
if (!fs.existsSync(dir)) return false;
|
||||||
|
fs.rmSync(dir, { recursive: true });
|
||||||
|
// 同时清理二维码
|
||||||
|
const qrPath = path.join(QRCODES_DIR, `${profileId}.png`);
|
||||||
|
if (fs.existsSync(qrPath)) {
|
||||||
|
try { fs.unlinkSync(qrPath); } catch {}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存二维码图片
|
||||||
|
function saveQrCode(profileId, pngBuffer) {
|
||||||
|
if (!isValidProfileId(profileId)) {
|
||||||
|
throw new Error('无效的 profileId');
|
||||||
|
}
|
||||||
|
ensureDirs();
|
||||||
|
const filename = `${profileId}.png`;
|
||||||
|
fs.writeFileSync(path.join(QRCODES_DIR, filename), pngBuffer);
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取二维码图片路径
|
||||||
|
function getQrCodePath(profileId) {
|
||||||
|
if (!isValidProfileId(profileId)) return null;
|
||||||
|
const filePath = path.join(QRCODES_DIR, `${profileId}.png`);
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 roleId 查找已有 profile(P1 修复:幂等去重)
|
||||||
|
function findByRoleId(roleId) {
|
||||||
|
if (!roleId) return null;
|
||||||
|
const profiles = listProfiles();
|
||||||
|
return profiles.find((p) => p.roleId === roleId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ensureDirs,
|
||||||
|
generateProfileId,
|
||||||
|
createProfile,
|
||||||
|
getProfileMeta,
|
||||||
|
getProfileFile,
|
||||||
|
listProfiles,
|
||||||
|
bindWechat,
|
||||||
|
deleteProfile,
|
||||||
|
saveQrCode,
|
||||||
|
getQrCodePath,
|
||||||
|
findByRoleId,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
// 二维码生成 — 使用 qrcode 库生成 PNG 图片
|
||||||
|
|
||||||
|
const QRCode = require('qrcode');
|
||||||
|
const path = require('path');
|
||||||
|
const { saveQrCode } = require('./profile-store');
|
||||||
|
|
||||||
|
// 生成二维码并保存为 PNG 文件,返回文件名
|
||||||
|
async function generateAndSaveQrCode(profileId, bindUrl) {
|
||||||
|
const pngBuffer = await QRCode.toBuffer(bindUrl, {
|
||||||
|
type: 'png',
|
||||||
|
width: 320,
|
||||||
|
margin: 2,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return saveQrCode(profileId, pngBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateAndSaveQrCode };
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
// sync_token 验证 — 使用与 EternalAI 共享的 SYNC_SECRET 验证 JWT
|
||||||
|
// sync_token 由 EternalAI 签发(HS256),包含 { roleId, adminId, jti, type:'sync' },5 分钟过期
|
||||||
|
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const SYNC_SECRET = process.env.SYNC_SECRET;
|
||||||
|
|
||||||
|
if (!SYNC_SECRET) {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
throw new Error(
|
||||||
|
'SYNC_SECRET 环境变量未设置。请从 EternalAI 数据库获取 SYNC_SECRET 并配置到 .env 文件中。\n' +
|
||||||
|
'获取方式:在 EternalAI 服务器执行\n' +
|
||||||
|
' psql -d eternalai -c \'SELECT value FROM "SystemConfig" WHERE key=\'\'SYNC_SECRET\'\'\'\''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.warn('[安全警告] SYNC_SECRET 未设置,sync_token 验证将无法工作。请在 .env 中配置与 EternalAI 一致的 SYNC_SECRET。');
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2 修复:jti 重放保护 — 内存缓存已使用的 jti,5 分钟 TTL
|
||||||
|
const usedJtis = new Map();
|
||||||
|
const JTI_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function cleanupExpiredJtis() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [jti, ts] of usedJtis) {
|
||||||
|
if (now - ts > JTI_TTL_MS) {
|
||||||
|
usedJtis.delete(jti);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 sync_token,返回 payload 或 null
|
||||||
|
function verifySyncToken(token) {
|
||||||
|
if (!SYNC_SECRET) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// P3 修复:显式指定算法,防止 alg 混淆攻击
|
||||||
|
const decoded = jwt.verify(token, SYNC_SECRET, { algorithms: ['HS256'] });
|
||||||
|
if (decoded.type !== 'sync') return null;
|
||||||
|
|
||||||
|
// P2 修复:jti 重放保护
|
||||||
|
if (decoded.jti) {
|
||||||
|
cleanupExpiredJtis();
|
||||||
|
if (usedJtis.has(decoded.jti)) {
|
||||||
|
console.warn('[安全警告] sync_token 重放被拒绝, jti:', decoded.jti);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
usedJtis.set(decoded.jti, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { verifySyncToken };
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
// Hermes 管理员认证中间件 — 使用环境变量 HERMES_ADMIN_TOKEN 进行 Bearer token 认证
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const DEFAULT_TOKEN = 'dev_only_admin_token_change_me';
|
||||||
|
const ADMIN_TOKEN = process.env.HERMES_ADMIN_TOKEN;
|
||||||
|
|
||||||
|
// P1 修复:生产环境 fail-fast
|
||||||
|
if (!ADMIN_TOKEN) {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
throw new Error(
|
||||||
|
'HERMES_ADMIN_TOKEN 环境变量未设置。请生成一个强随机值(可用 openssl rand -hex 32)并配置到 .env 文件中。'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.warn('[安全警告] HERMES_ADMIN_TOKEN 未设置,使用开发环境默认 token。请勿在生产环境使用。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const EFFECTIVE_TOKEN = ADMIN_TOKEN || DEFAULT_TOKEN;
|
||||||
|
|
||||||
|
function adminAuthMiddleware(req, res, next) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: '需要管理员认证' });
|
||||||
|
}
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
|
||||||
|
// P3 修复:恒定时间比较,防止时序攻击
|
||||||
|
const a = Buffer.from(token);
|
||||||
|
const b = Buffer.from(EFFECTIVE_TOKEN);
|
||||||
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
||||||
|
return res.status(403).json({ error: '管理员 token 无效' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { adminAuthMiddleware };
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
// 绑定流程 — 二维码扫描后的绑定页面和绑定操作
|
||||||
|
// QR 码内容:{HERMES_BASE_URL}/api/bind/{profileId}
|
||||||
|
// 扫描后展示绑定页面,用户确认绑定
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { getProfileMeta, bindWechat } = require('../lib/profile-store');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 绑定页面(GET)— 扫描二维码后打开
|
||||||
|
router.get('/:profileId', (req, res) => {
|
||||||
|
try {
|
||||||
|
const meta = getProfileMeta(req.params.profileId);
|
||||||
|
if (!meta) {
|
||||||
|
// P3 修复:返回 404 状态码
|
||||||
|
return res.status(404).type('html').send(renderNotFoundPage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已绑定,显示已绑定页面
|
||||||
|
if (meta.boundWechat) {
|
||||||
|
return res.type('html').send(renderAlreadyBoundPage(meta));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示绑定确认页面
|
||||||
|
res.type('html').send(renderBindPage(meta));
|
||||||
|
} catch (err) {
|
||||||
|
// P2 修复:异常时返回 HTML 而非 JSON
|
||||||
|
console.error('绑定页面加载失败:', err);
|
||||||
|
res.status(500).type('html').send(renderNotFoundPage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 执行绑定(POST)
|
||||||
|
router.post('/:profileId', (req, res) => {
|
||||||
|
try {
|
||||||
|
const meta = getProfileMeta(req.params.profileId);
|
||||||
|
if (!meta) {
|
||||||
|
return res.status(404).json({ error: 'Profile 不存在' });
|
||||||
|
}
|
||||||
|
if (meta.boundWechat) {
|
||||||
|
return res.status(400).json({ error: '该 Profile 已绑定微信' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2 修复:req.body 可能为 undefined
|
||||||
|
const body = req.body || {};
|
||||||
|
// 绑定标识:优先用微信 OAuth code,其次用请求体中的 wechatId
|
||||||
|
// 实际生产环境中,这里应通过微信 OAuth 获取 openid
|
||||||
|
const wechatId = body.wechatId || body.openid;
|
||||||
|
|
||||||
|
if (!wechatId) {
|
||||||
|
return res.status(400).json({ error: '缺少 wechatId,请通过微信 OAuth 授权' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// P1 修复:bindWechat 内部有原子检查,处理 alreadyBound 返回
|
||||||
|
const result = bindWechat(req.params.profileId, wechatId);
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({ error: 'Profile 不存在' });
|
||||||
|
}
|
||||||
|
if (result.alreadyBound) {
|
||||||
|
return res.status(400).json({ error: '该 Profile 已被绑定' });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Hermes] Profile ${req.params.profileId} 已绑定微信: ${wechatId}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: '绑定成功',
|
||||||
|
profileId: result.profileId,
|
||||||
|
profileName: result.profileName,
|
||||||
|
boundAt: result.boundAt,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('绑定失败:', err);
|
||||||
|
res.status(500).json({ error: '绑定失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== HTML 页面渲染 =====
|
||||||
|
|
||||||
|
function renderBindPage(meta) {
|
||||||
|
// P3 修复:profileId 在 form action 中转义
|
||||||
|
const safeProfileId = encodeURIComponent(meta.profileId);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>绑定角色 - ${escapeHtml(meta.profileName)}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #333; }
|
||||||
|
.container { max-width: 400px; margin: 0 auto; padding: 24px 20px; min-height: 100vh; display: flex; flex-direction: column; justify-content: center; }
|
||||||
|
.card { background: #fff; border-radius: 16px; padding: 32px 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); text-align: center; }
|
||||||
|
.avatar { width: 72px; height: 72px; border-radius: 50%; background: linear-gradient(135deg, #667eea, #764ba2); margin: 0 auto 16px; display: flex; align-items: center; justify-content: center; font-size: 32px; color: #fff; }
|
||||||
|
h1 { font-size: 20px; margin-bottom: 8px; }
|
||||||
|
.desc { font-size: 14px; color: #888; margin-bottom: 24px; line-height: 1.6; }
|
||||||
|
.info { background: #f8f8f8; border-radius: 8px; padding: 12px 16px; margin-bottom: 24px; text-align: left; }
|
||||||
|
.info-row { display: flex; justify-content: space-between; font-size: 13px; padding: 4px 0; }
|
||||||
|
.info-label { color: #999; }
|
||||||
|
.info-value { color: #333; font-weight: 500; }
|
||||||
|
.btn { width: 100%; padding: 14px; border: none; border-radius: 12px; background: #07c160; color: #fff; font-size: 16px; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }
|
||||||
|
.btn:active { opacity: 0.8; }
|
||||||
|
.footer { text-align: center; margin-top: 16px; font-size: 12px; color: #bbb; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="avatar">${escapeHtml(meta.profileName.charAt(0))}</div>
|
||||||
|
<h1>${escapeHtml(meta.profileName)}</h1>
|
||||||
|
<p class="desc">扫描绑定后,即可在微信中与该角色对话</p>
|
||||||
|
<div class="info">
|
||||||
|
<div class="info-row"><span class="info-label">Profile ID</span><span class="info-value">${escapeHtml(meta.profileId)}</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">创建时间</span><span class="info-value">${new Date(meta.createdAt).toLocaleString('zh-CN')}</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">状态</span><span class="info-value" style="color:#07c160">待绑定</span></div>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/api/bind/${safeProfileId}">
|
||||||
|
<input type="hidden" name="wechatId" value="wechat_oauth_placeholder">
|
||||||
|
<button type="submit" class="btn">确认绑定</button>
|
||||||
|
</form>
|
||||||
|
<div class="footer">Hermes Server</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlreadyBoundPage(meta) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>已绑定 - ${escapeHtml(meta.profileName)}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #333; }
|
||||||
|
.container { max-width: 400px; margin: 0 auto; padding: 24px 20px; min-height: 100vh; display: flex; flex-direction: column; justify-content: center; }
|
||||||
|
.card { background: #fff; border-radius: 16px; padding: 32px 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); text-align: center; }
|
||||||
|
.icon { font-size: 48px; margin-bottom: 16px; }
|
||||||
|
h1 { font-size: 20px; margin-bottom: 8px; }
|
||||||
|
.desc { font-size: 14px; color: #888; margin-bottom: 16px; }
|
||||||
|
.info { background: #f8f8f8; border-radius: 8px; padding: 12px 16px; text-align: left; }
|
||||||
|
.info-row { display: flex; justify-content: space-between; font-size: 13px; padding: 4px 0; }
|
||||||
|
.info-label { color: #999; }
|
||||||
|
.info-value { color: #333; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon">✅</div>
|
||||||
|
<h1>${escapeHtml(meta.profileName)}</h1>
|
||||||
|
<p class="desc">该角色已成功绑定微信</p>
|
||||||
|
<div class="info">
|
||||||
|
<div class="info-row"><span class="info-label">绑定时间</span><span class="info-value">${new Date(meta.boundAt).toLocaleString('zh-CN')}</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">状态</span><span class="info-value" style="color:#07c160">已绑定</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotFoundPage() {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>角色不存在</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #333; }
|
||||||
|
.container { max-width: 400px; margin: 0 auto; padding: 24px; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.card { background: #fff; border-radius: 16px; padding: 32px; text-align: center; box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
|
||||||
|
.icon { font-size: 48px; margin-bottom: 16px; }
|
||||||
|
h1 { font-size: 18px; color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon">🔍</div>
|
||||||
|
<h1>角色不存在或二维码已失效</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str).replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
// 健康检查
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { listProfiles } = require('../lib/profile-store');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/health', (req, res) => {
|
||||||
|
let profileCount = 0;
|
||||||
|
let storageOk = true;
|
||||||
|
try {
|
||||||
|
profileCount = listProfiles().length;
|
||||||
|
} catch {
|
||||||
|
// P3 修复:区分"无 profile"和"读取失败"
|
||||||
|
storageOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2 修复:不泄露 syncSecretConfigured 等安全配置状态
|
||||||
|
res.json({
|
||||||
|
status: storageOk ? 'ok' : 'degraded',
|
||||||
|
service: 'hermes-server',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
profileCount,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
// Profile 管理 API — 查看、下载 profile 文件(供 Hermes Agent 和管理员使用)
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { adminAuthMiddleware } = require('../middleware/admin-auth');
|
||||||
|
const { listProfiles, getProfileMeta, getProfileFile, deleteProfile } = require('../lib/profile-store');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取所有 profile 列表(需管理员认证)
|
||||||
|
router.get('/', adminAuthMiddleware, (req, res) => {
|
||||||
|
try {
|
||||||
|
const profiles = listProfiles();
|
||||||
|
res.json({ profiles, total: profiles.length });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取 profile 列表失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取 profile 详情(需管理员认证)
|
||||||
|
router.get('/:profileId', adminAuthMiddleware, (req, res) => {
|
||||||
|
try {
|
||||||
|
const meta = getProfileMeta(req.params.profileId);
|
||||||
|
if (!meta) {
|
||||||
|
return res.status(404).json({ error: 'Profile 不存在' });
|
||||||
|
}
|
||||||
|
res.json({ profile: meta });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取 profile 详情失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 下载 SOUL.md(供 Hermes Agent 使用,限制本机访问)
|
||||||
|
router.get('/:profileId/SOUL.md', (req, res) => {
|
||||||
|
try {
|
||||||
|
// P2 修复:限制本机访问
|
||||||
|
const clientIp = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
|
||||||
|
if (clientIp !== '127.0.0.1' && clientIp !== '::1' && clientIp !== '') {
|
||||||
|
return res.status(403).json({ error: '仅限本机访问' });
|
||||||
|
}
|
||||||
|
const content = getProfileFile(req.params.profileId, 'SOUL.md');
|
||||||
|
if (content === null) {
|
||||||
|
return res.status(404).json({ error: 'Profile 或 SOUL.md 不存在' });
|
||||||
|
}
|
||||||
|
// P3 修复:使用语义正确的 Content-Type
|
||||||
|
res.type('text/markdown').send(content);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取 SOUL.md 失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 下载 config.yaml(供 Hermes Agent 使用,限制本机访问)
|
||||||
|
router.get('/:profileId/config.yaml', (req, res) => {
|
||||||
|
try {
|
||||||
|
// P2 修复:限制本机访问
|
||||||
|
const clientIp = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
|
||||||
|
if (clientIp !== '127.0.0.1' && clientIp !== '::1' && clientIp !== '') {
|
||||||
|
return res.status(403).json({ error: '仅限本机访问' });
|
||||||
|
}
|
||||||
|
const content = getProfileFile(req.params.profileId, 'config.yaml');
|
||||||
|
if (content === null) {
|
||||||
|
return res.status(404).json({ error: 'Profile 或 config.yaml 不存在' });
|
||||||
|
}
|
||||||
|
// P3 修复:使用语义正确的 Content-Type
|
||||||
|
res.type('text/yaml').send(content);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取 config.yaml 失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除 profile(需管理员认证)
|
||||||
|
router.delete('/:profileId', adminAuthMiddleware, (req, res) => {
|
||||||
|
try {
|
||||||
|
const deleted = deleteProfile(req.params.profileId);
|
||||||
|
if (!deleted) {
|
||||||
|
return res.status(404).json({ error: 'Profile 不存在' });
|
||||||
|
}
|
||||||
|
res.json({ message: 'Profile 已删除' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('删除 profile 失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
// POST /api/sync — 接收 EternalAI 的同步请求
|
||||||
|
// 流程:验证 sync_token → 回调拉取 SOUL.md + config.yaml → 创建/更新 profile → 生成二维码 → 返回
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { verifySyncToken } = require('../lib/sync-token-verify');
|
||||||
|
const { pullRoleFiles } = require('../lib/eternalai-client');
|
||||||
|
const { generateProfileId, createProfile, findByRoleId, deleteProfile } = require('../lib/profile-store');
|
||||||
|
const { generateAndSaveQrCode } = require('../lib/qrcode-gen');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// P2 修复:输入校验
|
||||||
|
const MAX_PROFILE_NAME_LEN = 128;
|
||||||
|
|
||||||
|
// 来源 IP 白名单检查(P1 修复:使用 socket.remoteAddress 而非可伪造的 req.ip)
|
||||||
|
function checkSourceIp(req, res, next) {
|
||||||
|
const allowedIps = process.env.ALLOWED_SOURCE_IPS;
|
||||||
|
if (!allowedIps) return next();
|
||||||
|
|
||||||
|
// 使用 socket 远程地址,不受 X-Forwarded-For 影响
|
||||||
|
const clientIp = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
|
||||||
|
if (!clientIp) {
|
||||||
|
return res.status(403).json({ error: '无法确定来源 IP' });
|
||||||
|
}
|
||||||
|
const allowed = allowedIps.split(',').map((ip) => ip.trim());
|
||||||
|
if (!allowed.includes(clientIp)) {
|
||||||
|
return res.status(403).json({ error: '来源 IP 不在白名单中' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/sync', checkSourceIp, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
profileName,
|
||||||
|
modelKey,
|
||||||
|
provider,
|
||||||
|
multimediaModelKey,
|
||||||
|
multimediaProvider,
|
||||||
|
enableSchedule,
|
||||||
|
syncToken,
|
||||||
|
roleId,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 参数校验
|
||||||
|
if (!syncToken || !roleId || !profileName) {
|
||||||
|
return res.status(400).json({ error: '缺少必要参数(syncToken, roleId, profileName)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2 修复:profileName 长度限制
|
||||||
|
if (profileName.length > MAX_PROFILE_NAME_LEN) {
|
||||||
|
return res.status(400).json({ error: `profileName 过长(上限 ${MAX_PROFILE_NAME_LEN} 字符)` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 sync_token
|
||||||
|
const payload = verifySyncToken(syncToken);
|
||||||
|
if (!payload) {
|
||||||
|
return res.status(401).json({ error: 'sync_token 无效或已过期' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 roleId 匹配
|
||||||
|
if (payload.roleId !== roleId) {
|
||||||
|
return res.status(403).json({ error: 'roleId 与 sync_token 不匹配' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// P1 修复:幂等去重 — 若该 roleId 已有 profile,先删除旧的再创建新的
|
||||||
|
const existing = findByRoleId(roleId);
|
||||||
|
if (existing) {
|
||||||
|
deleteProfile(existing.profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回调 EternalAI 拉取 SOUL.md 和 config.yaml
|
||||||
|
// P0 修复:使用环境变量 ETERNALAI_BASE_URL,不信任请求体中的 filePullBaseUrl
|
||||||
|
const { soulMd, configYaml } = await pullRoleFiles(roleId, syncToken);
|
||||||
|
|
||||||
|
// P2 修复:校验两个文件都非空
|
||||||
|
if (!soulMd || soulMd.length === 0) {
|
||||||
|
return res.status(400).json({ error: '拉取的 SOUL.md 为空' });
|
||||||
|
}
|
||||||
|
if (!configYaml || configYaml.length === 0) {
|
||||||
|
return res.status(400).json({ error: '拉取的 config.yaml 为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 profile
|
||||||
|
const profileId = generateProfileId();
|
||||||
|
const baseUrl = process.env.HERMES_BASE_URL || `http://localhost:${process.env.PORT || 3002}`;
|
||||||
|
const bindUrl = `${baseUrl}/api/bind/${profileId}`;
|
||||||
|
|
||||||
|
// P2 修复:先创建 profile,再生成二维码(避免孤儿文件)
|
||||||
|
// 先用 null 作为 qrCodeUrl 创建 profile
|
||||||
|
const meta = createProfile({
|
||||||
|
profileId,
|
||||||
|
profileName,
|
||||||
|
roleId,
|
||||||
|
sourceBaseUrl: process.env.ETERNALAI_BASE_URL || 'unknown',
|
||||||
|
modelKey,
|
||||||
|
provider,
|
||||||
|
multimediaModelKey,
|
||||||
|
multimediaProvider,
|
||||||
|
enableSchedule: !!enableSchedule,
|
||||||
|
soulMd,
|
||||||
|
configYaml,
|
||||||
|
qrCodeUrl: null, // 稍后更新
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成二维码
|
||||||
|
try {
|
||||||
|
const qrFilename = await generateAndSaveQrCode(profileId, bindUrl);
|
||||||
|
const qrCodeUrl = `${baseUrl}/api/qrcodes/${qrFilename}`;
|
||||||
|
// 更新 meta 中的 qrCodeUrl(通过重新写入 meta.json)
|
||||||
|
const { getProfileMeta } = require('../lib/profile-store');
|
||||||
|
const updatedMeta = getProfileMeta(profileId);
|
||||||
|
if (updatedMeta) {
|
||||||
|
updatedMeta.qrCodeUrl = qrCodeUrl;
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const metaPath = path.join(__dirname, '..', '..', 'data', 'profiles', profileId, 'meta.json');
|
||||||
|
fs.writeFileSync(metaPath, JSON.stringify(updatedMeta, null, 2));
|
||||||
|
}
|
||||||
|
meta.qrCodeUrl = qrCodeUrl;
|
||||||
|
} catch (qrErr) {
|
||||||
|
console.error('[Hermes] 二维码生成失败:', qrErr.message);
|
||||||
|
// 二维码生成失败不阻塞 profile 创建,但返回中不包含 qrCodeUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Hermes] Profile 创建成功: ${profileId} (name=${profileName}, roleId=${roleId})`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
qrCodeUrl: meta.qrCodeUrl,
|
||||||
|
profileId,
|
||||||
|
message: 'Profile 创建成功',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Hermes] 同步失败:', err.message);
|
||||||
|
// P3 修复:区分已知错误类型
|
||||||
|
if (err.message.includes('超时')) {
|
||||||
|
return res.status(504).json({ error: '拉取文件超时,请检查 EternalAI 服务状态' });
|
||||||
|
}
|
||||||
|
if (err.message.includes('ETERNALAI_BASE_URL')) {
|
||||||
|
return res.status(500).json({ error: 'Hermes Server 未配置 ETERNALAI_BASE_URL' });
|
||||||
|
}
|
||||||
|
if (err.message.includes('拉取') && err.message.includes('失败')) {
|
||||||
|
return res.status(502).json({ error: '拉取文件失败,请检查 EternalAI 服务是否正常' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: '同步失败,请稍后重试' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,356 @@
|
||||||
|
// 集成测试:EternalAI → hermes-server 端到端同步流程
|
||||||
|
// 前提:EternalAI (3001) 和 hermes-server (3002) 均已运行,SYNC_SECRET 已同步
|
||||||
|
// 前提:hermes-server 的 .env 已配置 ETERNALALAI_BASE_URL=http://localhost:3001
|
||||||
|
|
||||||
|
const BASE_ETERNAL = 'http://localhost:3001';
|
||||||
|
const BASE_HERMES = 'http://localhost:3002';
|
||||||
|
const HERMES_ADMIN_TOKEN = 'dev_only_admin_token_change_me';
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
async function assert(name, fn) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
passed++;
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEqual(actual, expected, msg) {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`${msg || ''} expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertTruthy(val, msg) {
|
||||||
|
if (!val) throw new Error(msg || 'expected truthy value');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理 hermes-server 上的所有 profile(避免重复运行导致冲突)
|
||||||
|
async function cleanupHermesProfiles() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_HERMES}/api/profiles`, {
|
||||||
|
headers: { Authorization: `Bearer ${HERMES_ADMIN_TOKEN}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
for (const p of data.profiles || []) {
|
||||||
|
try {
|
||||||
|
await fetch(`${BASE_HERMES}/api/profiles/${p.profileId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${HERMES_ADMIN_TOKEN}` },
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
console.log(` 已清理 hermes-server 上 ${data.profiles?.length || 0} 个 profile`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` 清理 hermes-server 跳过: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = require('../../src/lib/prisma');
|
||||||
|
const { hashPassword } = require('../../src/lib/auth');
|
||||||
|
|
||||||
|
// ===== 清理数据库 =====
|
||||||
|
console.log('\n===== 准备:清理数据库 =====');
|
||||||
|
await prisma.order.deleteMany();
|
||||||
|
await prisma.apiKey.deleteMany();
|
||||||
|
await prisma.role.deleteMany();
|
||||||
|
await prisma.admin.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
await prisma.systemConfig.deleteMany({ where: { key: 'HERMES_WEBHOOK_URL' } });
|
||||||
|
|
||||||
|
// 确保 SYNC_SECRET 存在
|
||||||
|
const crypto = require('crypto');
|
||||||
|
await prisma.systemConfig.upsert({
|
||||||
|
where: { key: 'SYNC_SECRET' },
|
||||||
|
update: {},
|
||||||
|
create: { key: 'SYNC_SECRET', value: crypto.randomBytes(32).toString('hex') },
|
||||||
|
});
|
||||||
|
|
||||||
|
// P1 修复:清理 hermes-server 上的残留 profile(避免重复运行测试失败)
|
||||||
|
console.log('\n===== 准备:清理 hermes-server 数据 =====');
|
||||||
|
await cleanupHermesProfiles();
|
||||||
|
|
||||||
|
// 创建测试用户和管理员
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { account: 'itest_user', password: hashPassword('Test123456'), isCreator: true },
|
||||||
|
});
|
||||||
|
const admin = await prisma.admin.create({
|
||||||
|
data: { account: 'itest_admin', password: hashPassword('admin123') },
|
||||||
|
});
|
||||||
|
console.log(` 用户: ${user.id}, 管理员: ${admin.id}`);
|
||||||
|
|
||||||
|
// ===== 测试开始 =====
|
||||||
|
console.log('\n===== 测试:EternalAI → hermes-server 集成 =====');
|
||||||
|
|
||||||
|
let adminToken, userToken, roleId;
|
||||||
|
|
||||||
|
// 1. 管理员登录
|
||||||
|
await assert('管理员登录', async () => {
|
||||||
|
const res = await fetch(`${BASE_ETERNAL}/api/admin-auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ account: 'itest_admin', password: 'admin123' }),
|
||||||
|
});
|
||||||
|
assertEqual(res.status, 200, '管理员登录状态码');
|
||||||
|
const data = await res.json();
|
||||||
|
assertTruthy(data.token, '管理员 token');
|
||||||
|
adminToken = data.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 用户登录
|
||||||
|
await assert('用户登录', async () => {
|
||||||
|
const res = await fetch(`${BASE_ETERNAL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ account: 'itest_user', password: 'Test123456' }),
|
||||||
|
});
|
||||||
|
assertEqual(res.status, 200, '用户登录状态码');
|
||||||
|
const data = await res.json();
|
||||||
|
assertTruthy(data.token, '用户 token');
|
||||||
|
userToken = data.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 用户创建角色
|
||||||
|
await assert('用户创建角色', async () => {
|
||||||
|
const res = await fetch(`${BASE_ETERNAL}/api/roles`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userToken}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
displayName: '集成测试角色',
|
||||||
|
personality: '温柔善良',
|
||||||
|
background: '来自星辰大海',
|
||||||
|
speechStyle: '轻声细语',
|
||||||
|
greeting: '你好呀,我是星灵',
|
||||||
|
soulMd: '# SOUL\n\n我是星灵,来自星辰大海的守护者。\n我温柔善良,喜欢倾听你的故事。',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assertEqual(res.status, 200, '创建角色状态码');
|
||||||
|
const data = await res.json();
|
||||||
|
assertTruthy(data.role.id, '角色 ID');
|
||||||
|
assertEqual(data.role.reviewStatus, 'pending_review', '初始审核状态');
|
||||||
|
roleId = data.role.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 管理员审核通过
|
||||||
|
await assert('管理员审核通过', async () => {
|
||||||
|
const res = await fetch(`${BASE_ETERNAL}/api/admin/reviews/${roleId}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
assertEqual(res.status, 200, '审核状态码');
|
||||||
|
const data = await res.json();
|
||||||
|
assertEqual(data.role.reviewStatus, 'approved', '审核后状态');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 配置 Hermes webhook URL 指向真实 hermes-server
|
||||||
|
await assert('配置 HERMES_WEBHOOK_URL 指向 hermes-server', async () => {
|
||||||
|
const res = await fetch(`${BASE_ETERNAL}/api/admin/config/HERMES_WEBHOOK_URL`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}` },
|
||||||
|
body: JSON.stringify({ value: `${BASE_HERMES}/api/sync` }),
|
||||||
|
});
|
||||||
|
assertEqual(res.status, 200, '配置状态码');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. 管理员发起同步 → hermes-server 接收 → 回调拉取文件 → 创建 profile → 返回二维码
|
||||||
|
let syncResponse;
|
||||||
|
await assert('管理员发起同步(真实 hermes-server)', async () => {
|
||||||
|
const res = await fetch(`${BASE_ETERNAL}/api/admin/sync/${roleId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
profileName: 'star-spirit',
|
||||||
|
modelKey: 'sk-test-key',
|
||||||
|
provider: 'openrouter',
|
||||||
|
multimediaModelKey: 'sk-multi-key',
|
||||||
|
multimediaProvider: 'openrouter',
|
||||||
|
enableSchedule: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assertEqual(res.status, 200, '同步状态码');
|
||||||
|
const data = await res.json();
|
||||||
|
assertEqual(data.role.reviewStatus, 'synced', '同步后状态');
|
||||||
|
assertTruthy(data.role.qrCodeUrl, '二维码 URL');
|
||||||
|
assertTruthy(data.profileId, 'profileId');
|
||||||
|
assertTruthy(data.profileId.startsWith('hermes_'), 'profileId 前缀');
|
||||||
|
syncResponse = data;
|
||||||
|
console.log(` profileId: ${data.profileId}`);
|
||||||
|
console.log(` qrCodeUrl: ${data.role.qrCodeUrl}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. 验证 hermes-server 健康检查
|
||||||
|
await assert('hermes-server 健康检查', async () => {
|
||||||
|
const res = await fetch(`${BASE_HERMES}/api/health`);
|
||||||
|
assertEqual(res.status, 200, '健康检查状态码');
|
||||||
|
const data = await res.json();
|
||||||
|
assertEqual(data.status, 'ok', '健康状态');
|
||||||
|
// P2 修复后:health 不再泄露 syncSecretConfigured,改用 storageOk/status
|
||||||
|
assertEqual(data.profileCount, 1, 'profile 数量');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. 验证 hermes-server profile 列表
|
||||||
|
await assert('hermes-server profile 列表', async () => {
|
||||||
|
const res = await fetch(`${BASE_HERMES}/api/profiles`, {
|
||||||
|
headers: { Authorization: `Bearer ${HERMES_ADMIN_TOKEN}` },
|
||||||
|
});
|
||||||
|
assertEqual(res.status, 200, 'profile 列表状态码');
|
||||||
|
const data = await res.json();
|
||||||
|
assertEqual(data.total, 1, 'profile 总数');
|
||||||
|
assertEqual(data.profiles[0].profileName, 'star-spirit', 'profile 名称');
|
||||||
|
assertEqual(data.profiles[0].roleId, roleId, 'roleId 关联');
|
||||||
|
// P0 修复后:sourceBaseUrl 来自 hermes-server 的 ETERNALAI_BASE_URL 环境变量
|
||||||
|
assertEqual(data.profiles[0].sourceBaseUrl, BASE_ETERNAL, 'sourceBaseUrl 来自环境变量');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 9. 验证 hermes-server profile 详情
|
||||||
|
await assert('hermes-server profile 详情', async () => {
|
||||||
|
const res = await fetch(`${BASE_HERMES}/api/profiles/${syncResponse.profileId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${HERMES_ADMIN_TOKEN}` },
|
||||||
|
});
|
||||||
|
assertEqual(res.status, 200, 'profile 详情状态码');
|
||||||
|
const data = await res.json();
|
||||||
|
assertEqual(data.profile.profileId, syncResponse.profileId, 'profileId');
|
||||||
|
assertEqual(data.profile.modelKey, 'sk-test-key', 'modelKey');
|
||||||
|
assertEqual(data.profile.provider, 'openrouter', 'provider');
|
||||||
|
assertEqual(data.profile.enableSchedule, false, 'enableSchedule');
|
||||||
|
assertEqual(data.profile.boundWechat, null, '未绑定状态');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 10. 验证 hermes-server 提供 SOUL.md 下载
|
||||||
|
await assert('hermes-server 提供 SOUL.md 下载', async () => {
|
||||||
|
const res = await fetch(`${BASE_HERMES}/api/profiles/${syncResponse.profileId}/SOUL.md`);
|
||||||
|
assertEqual(res.status, 200, 'SOUL.md 状态码');
|
||||||
|
const text = await res.text();
|
||||||
|
assertTruthy(text.includes('星灵'), 'SOUL.md 内容包含角色名');
|
||||||
|
assertTruthy(text.includes('星辰大海'), 'SOUL.md 内容包含背景');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 11. 验证 hermes-server 提供 config.yaml 下载
|
||||||
|
await assert('hermes-server 提供 config.yaml 下载', async () => {
|
||||||
|
const res = await fetch(`${BASE_HERMES}/api/profiles/${syncResponse.profileId}/config.yaml`);
|
||||||
|
assertEqual(res.status, 200, 'config.yaml 状态码');
|
||||||
|
const text = await res.text();
|
||||||
|
assertTruthy(text.includes('model:'), 'config.yaml 包含 model 字段');
|
||||||
|
assertTruthy(text.includes('temperature:'), 'config.yaml 包含 temperature 字段');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 12. 验证二维码图片可访问
|
||||||
|
await assert('二维码图片可访问', async () => {
|
||||||
|
const res = await fetch(syncResponse.role.qrCodeUrl);
|
||||||
|
assertEqual(res.status, 200, '二维码图片状态码');
|
||||||
|
assertEqual(res.headers.get('content-type'), 'image/png', '二维码图片类型');
|
||||||
|
const buffer = await res.arrayBuffer();
|
||||||
|
assertTruthy(buffer.byteLength > 100, '二维码图片大小');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 13. 验证绑定页面可访问
|
||||||
|
await assert('绑定页面可访问', async () => {
|
||||||
|
const res = await fetch(`${BASE_HERMES}/api/bind/${syncResponse.profileId}`);
|
||||||
|
assertEqual(res.status, 200, '绑定页面状态码');
|
||||||
|
const html = await res.text();
|
||||||
|
assertTruthy(html.includes('star-spirit'), '绑定页面包含 profile 名称');
|
||||||
|
assertTruthy(html.includes('确认绑定'), '绑定页面包含绑定按钮');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 14. 执行绑定
|
||||||
|
await assert('执行绑定', async () => {
|
||||||
|
const res = await fetch(`${BASE_HERMES}/api/bind/${syncResponse.profileId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ wechatId: 'wx_test_12345' }),
|
||||||
|
});
|
||||||
|
assertEqual(res.status, 200, '绑定状态码');
|
||||||
|
const data = await res.json();
|
||||||
|
assertEqual(data.profileId, syncResponse.profileId, '绑定返回 profileId');
|
||||||
|
assertTruthy(data.boundAt, '绑定时间');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 15. 验证已绑定后再次绑定返回 400
|
||||||
|
await assert('重复绑定返回 400', async () => {
|
||||||
|
const res = await fetch(`${BASE_HERMES}/api/bind/${syncResponse.profileId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ wechatId: 'wx_other' }),
|
||||||
|
});
|
||||||
|
assertEqual(res.status, 400, '重复绑定状态码');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 16. 验证已绑定页面显示
|
||||||
|
await assert('已绑定页面显示', async () => {
|
||||||
|
const res = await fetch(`${BASE_HERMES}/api/bind/${syncResponse.profileId}`);
|
||||||
|
const html = await res.text();
|
||||||
|
assertTruthy(html.includes('已成功绑定'), '已绑定页面提示');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 17. 验证 EternalAI 角色库显示已同步角色
|
||||||
|
await assert('EternalAI 角色库显示已同步角色', async () => {
|
||||||
|
const res = await fetch(`${BASE_ETERNAL}/api/roles`);
|
||||||
|
const data = await res.json();
|
||||||
|
const found = data.roles.find((r) => r.id === roleId);
|
||||||
|
assertTruthy(found, '角色库包含已同步角色');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 18. 验证 SYNC_SECRET 写保护(不能通过 PUT 修改)
|
||||||
|
await assert('SYNC_SECRET 写保护', async () => {
|
||||||
|
const res = await fetch(`${BASE_ETERNAL}/api/admin/config/SYNC_SECRET`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}` },
|
||||||
|
body: JSON.stringify({ value: 'hacked' }),
|
||||||
|
});
|
||||||
|
assertEqual(res.status, 403, '写保护状态码');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 19. 验证无效 sync_token 被拒绝
|
||||||
|
await assert('无效 sync_token 被拒绝', async () => {
|
||||||
|
const res = await fetch(`${BASE_HERMES}/api/sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
profileName: 'test',
|
||||||
|
syncToken: 'invalid.token.here',
|
||||||
|
roleId: 'fake-id',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assertEqual(res.status, 401, '无效 token 状态码');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 20. 验证缺少参数被拒绝
|
||||||
|
await assert('缺少参数被拒绝', async () => {
|
||||||
|
const res = await fetch(`${BASE_HERMES}/api/sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ profileName: 'test' }),
|
||||||
|
});
|
||||||
|
assertEqual(res.status, 400, '缺少参数状态码');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 清理 =====
|
||||||
|
console.log('\n===== 清理 =====');
|
||||||
|
// 清理 hermes-server 上的 profile
|
||||||
|
await cleanupHermesProfiles();
|
||||||
|
// 清理 EternalAI 数据库
|
||||||
|
await prisma.order.deleteMany();
|
||||||
|
await prisma.apiKey.deleteMany();
|
||||||
|
await prisma.role.deleteMany();
|
||||||
|
await prisma.admin.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
await prisma.systemConfig.deleteMany({ where: { key: 'HERMES_WEBHOOK_URL' } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
|
||||||
|
// ===== 结果 =====
|
||||||
|
console.log(`\n===== 结果: ${passed} passed, ${failed} failed =====`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('测试运行失败:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
67
index.html
67
index.html
|
|
@ -587,6 +587,73 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Admin Login View -->
|
||||||
|
<section id="admin-login" class="view view--admin-login">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name">管理员登录</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="auth-page" style="padding:2rem 1.5rem;">
|
||||||
|
<form id="admin-login-form" class="auth-form active" data-form="admin-login">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="admin-account">管理员账号</label>
|
||||||
|
<input class="field__input" name="account" type="text" autocomplete="username" placeholder="管理员账号" required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="admin-password">密码</label>
|
||||||
|
<input class="field__input" name="password" type="password" autocomplete="current-password" placeholder="密码" required />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary" type="submit" style="width:100%;">登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Admin Reviews View -->
|
||||||
|
<section id="admin-reviews" class="view view--admin-reviews">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name">管理员后台</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--small btn--ghost" type="button" data-action="admin-logout">退出</button>
|
||||||
|
</header>
|
||||||
|
<div class="center-tabs" style="padding:0.5rem 1rem;border-bottom:1px solid var(--border);">
|
||||||
|
<button class="center-tab active" type="button" data-center-tab="admin-reviews">审核列表</button>
|
||||||
|
<button class="center-tab" type="button" data-center-tab="admin-sync">同步状态</button>
|
||||||
|
<button class="center-tab" type="button" data-center-tab="admin-config">系统配置</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-panel" id="admin-reviews-panel">
|
||||||
|
<div style="padding:0.5rem 1rem;display:flex;gap:0.5rem;flex-wrap:wrap;">
|
||||||
|
<button class="btn btn--small btn--outline admin-filter active" data-filter="pending_review">待审核</button>
|
||||||
|
<button class="btn btn--small btn--outline admin-filter" data-filter="approved">已通过</button>
|
||||||
|
<button class="btn btn--small btn--outline admin-filter" data-filter="synced">已同步</button>
|
||||||
|
<button class="btn btn--small btn--outline admin-filter" data-filter="failed">同步失败</button>
|
||||||
|
<button class="btn btn--small btn--outline admin-filter" data-filter="rejected">已驳回</button>
|
||||||
|
<button class="btn btn--small btn--outline admin-filter" data-filter="">全部</button>
|
||||||
|
</div>
|
||||||
|
<div id="admin-review-list" style="padding:1rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-panel" id="admin-sync-panel" hidden>
|
||||||
|
<div id="admin-sync-list" style="padding:1rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-panel" id="admin-config-panel" hidden>
|
||||||
|
<div id="admin-config-list" style="padding:1rem;"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Admin Review Detail View -->
|
||||||
|
<section id="admin-review-detail" class="view view--admin-review-detail">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back-to-admin" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name">角色审核</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div id="admin-detail-content" style="padding:1rem;"></div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Tab Bar (U8) -->
|
<!-- Tab Bar (U8) -->
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,19 @@ model User {
|
||||||
apiKeys ApiKey[]
|
apiKeys ApiKey[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Admin {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
account String @unique
|
||||||
|
password String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model SystemConfig {
|
||||||
|
key String @id
|
||||||
|
value String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model ApiKey {
|
model ApiKey {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
|
|
@ -65,6 +78,10 @@ model Role {
|
||||||
desc String?
|
desc String?
|
||||||
price Float @default(0)
|
price Float @default(0)
|
||||||
status String @default("running")
|
status String @default("running")
|
||||||
|
reviewStatus String @default("pending_review")
|
||||||
|
reviewNote String?
|
||||||
|
qrCodeUrl String?
|
||||||
|
syncedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
// 管理员账号初始化脚本
|
||||||
|
// 用法: node scripts/seed-admin.js <account> <password>
|
||||||
|
// 或通过环境变量: ADMIN_ACCOUNT=admin ADMIN_PASSWORD=xxx node scripts/seed-admin.js
|
||||||
|
|
||||||
|
const prisma = require('../src/lib/prisma');
|
||||||
|
const { hashPassword } = require('../src/lib/auth');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const account = process.argv[2] || process.env.ADMIN_ACCOUNT;
|
||||||
|
const password = process.argv[3] || process.env.ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
if (!account || !password) {
|
||||||
|
console.error('用法: node scripts/seed-admin.js <account> <password>');
|
||||||
|
console.error(' 或: ADMIN_ACCOUNT=admin ADMIN_PASSWORD=xxx node scripts/seed-admin.js');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
console.error('密码至少 6 位');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.admin.findUnique({ where: { account } });
|
||||||
|
if (existing) {
|
||||||
|
// 更新密码
|
||||||
|
await prisma.admin.update({
|
||||||
|
where: { account },
|
||||||
|
data: { password: hashPassword(password) },
|
||||||
|
});
|
||||||
|
console.log(`管理员 ${account} 密码已更新`);
|
||||||
|
} else {
|
||||||
|
await prisma.admin.create({
|
||||||
|
data: { account, password: hashPassword(password) },
|
||||||
|
});
|
||||||
|
console.log(`管理员 ${account} 已创建`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('初始化管理员失败:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
11
server.js
11
server.js
|
|
@ -14,7 +14,18 @@ app.use(express.json());
|
||||||
app.use('/api/auth', require('./src/routes/auth'));
|
app.use('/api/auth', require('./src/routes/auth'));
|
||||||
app.use('/api/roles', require('./src/routes/roles'));
|
app.use('/api/roles', require('./src/routes/roles'));
|
||||||
app.use('/api/apikeys', require('./src/routes/apikeys'));
|
app.use('/api/apikeys', require('./src/routes/apikeys'));
|
||||||
|
app.use('/api/orders', require('./src/routes/orders'));
|
||||||
app.use('/api/hermes', require('./src/routes/hermes'));
|
app.use('/api/hermes', require('./src/routes/hermes'));
|
||||||
|
app.use('/api/admin-auth', require('./src/routes/admin-auth'));
|
||||||
|
app.use('/api/admin', require('./src/routes/admin'));
|
||||||
|
app.use('/api/admin/config', require('./src/routes/admin-config'));
|
||||||
|
app.use('/api/admin/sync', require('./src/routes/admin-sync'));
|
||||||
|
|
||||||
|
// Mock Hermes 端点(仅 development/test 环境注册,显式 allowlist 防止误开放)
|
||||||
|
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(express.static('.'));
|
app.use(express.static('.'));
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const crypto = require('crypto');
|
||||||
const prisma = require('./prisma');
|
const prisma = require('./prisma');
|
||||||
|
|
||||||
const JWT_EXPIRES_IN = '7d';
|
const JWT_EXPIRES_IN = '7d';
|
||||||
|
const ADMIN_JWT_EXPIRES_IN = '7d';
|
||||||
|
|
||||||
// 安全:生产环境必须配置 JWT_SECRET,杜绝硬编码密钥
|
// 安全:生产环境必须配置 JWT_SECRET,杜绝硬编码密钥
|
||||||
const JWT_SECRET = process.env.JWT_SECRET;
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
|
|
@ -17,6 +18,19 @@ 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,否则 fail-fast
|
||||||
|
const ADMIN_JWT_SECRET = process.env.ADMIN_JWT_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) {
|
||||||
return bcrypt.hashSync(password, 10);
|
return bcrypt.hashSync(password, 10);
|
||||||
|
|
@ -101,6 +115,39 @@ async function apiKeyMiddleware(req, res, next) {
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Admin 认证 =====
|
||||||
|
|
||||||
|
// 生成 Admin JWT
|
||||||
|
function adminSignToken(adminId) {
|
||||||
|
return jwt.sign({ adminId, role: 'admin' }, ADMIN_SECRET, { expiresIn: ADMIN_JWT_EXPIRES_IN });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 Admin JWT
|
||||||
|
function adminVerifyToken(token) {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, ADMIN_SECRET);
|
||||||
|
if (decoded.role !== 'admin') return null;
|
||||||
|
return decoded.adminId;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Express 中间件:验证 Admin JWT
|
||||||
|
function adminAuthMiddleware(req, res, next) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: '管理员未登录' });
|
||||||
|
}
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
const adminId = adminVerifyToken(token);
|
||||||
|
if (!adminId) {
|
||||||
|
return res.status(401).json({ error: '管理员登录已过期,请重新登录' });
|
||||||
|
}
|
||||||
|
req.adminId = adminId;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
hashPassword,
|
hashPassword,
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
|
|
@ -108,4 +155,7 @@ module.exports = {
|
||||||
verifyToken,
|
verifyToken,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
apiKeyMiddleware,
|
apiKeyMiddleware,
|
||||||
|
adminSignToken,
|
||||||
|
adminVerifyToken,
|
||||||
|
adminAuthMiddleware,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
// Hermes HTTP 客户端 — 向 Hermes 服务器发起同步请求
|
||||||
|
|
||||||
|
const SYNC_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
// 向 Hermes POST 同步请求
|
||||||
|
async function postSync(webhookUrl, payload) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || `Hermes 返回错误 (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error('Hermes 同步请求超时');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { postSync };
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const prisma = require('./prisma');
|
||||||
|
|
||||||
|
const SYNC_TOKEN_EXPIRES_IN = '5m';
|
||||||
|
|
||||||
|
// 从 SystemConfig 获取 SYNC_SECRET,不存在则自动生成
|
||||||
|
async function getSyncSecret() {
|
||||||
|
let config = await prisma.systemConfig.findUnique({ where: { key: 'SYNC_SECRET' } });
|
||||||
|
if (!config) {
|
||||||
|
const secret = crypto.randomBytes(32).toString('hex');
|
||||||
|
config = await prisma.systemConfig.create({
|
||||||
|
data: { key: 'SYNC_SECRET', value: secret },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return config.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 sync_token(5 分钟过期,jti 用 CSPRNG 生成)
|
||||||
|
async function generateSyncToken(roleId, adminId) {
|
||||||
|
const secret = await getSyncSecret();
|
||||||
|
const jti = crypto.randomBytes(16).toString('hex');
|
||||||
|
return jwt.sign({ roleId, adminId, jti, type: 'sync' }, secret, { expiresIn: SYNC_TOKEN_EXPIRES_IN });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 sync_token,返回 payload 或 null
|
||||||
|
// 注意:sync_token 在 5 分钟有效期内可多次使用(Hermes 需拉取 SOUL.md + config.yaml 两个文件)
|
||||||
|
// 重放防护依赖 5 分钟短过期 + HTTPS 传输 + roleId 绑定
|
||||||
|
async function verifySyncToken(token) {
|
||||||
|
try {
|
||||||
|
const secret = await getSyncSecret();
|
||||||
|
const decoded = jwt.verify(token, secret);
|
||||||
|
if (decoded.type !== 'sync') return null;
|
||||||
|
return decoded;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateSyncToken,
|
||||||
|
verifySyncToken,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
const express = require('express');
|
||||||
|
const prisma = require('../lib/prisma');
|
||||||
|
const { verifyPassword, adminSignToken, adminAuthMiddleware } = require('../lib/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 管理员登录
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { account, password } = req.body;
|
||||||
|
if (!account || !password) {
|
||||||
|
return res.status(400).json({ error: '账号和密码不能为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = await prisma.admin.findUnique({ where: { account } });
|
||||||
|
if (!admin || !verifyPassword(password, admin.password)) {
|
||||||
|
return res.status(401).json({ error: '账号或密码错误' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = adminSignToken(admin.id);
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
admin: { id: admin.id, account: admin.account },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('管理员登录失败:', err);
|
||||||
|
res.status(500).json({ error: '登录失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取当前管理员信息
|
||||||
|
router.get('/me', adminAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const admin = await prisma.admin.findUnique({
|
||||||
|
where: { id: req.adminId },
|
||||||
|
select: { id: true, account: true },
|
||||||
|
});
|
||||||
|
if (!admin) {
|
||||||
|
return res.status(404).json({ error: '管理员不存在' });
|
||||||
|
}
|
||||||
|
res.json({ admin });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取管理员信息失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
const express = require('express');
|
||||||
|
const prisma = require('../lib/prisma');
|
||||||
|
const { adminAuthMiddleware } = require('../lib/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 敏感配置项的值需要脱敏显示
|
||||||
|
const SENSITIVE_KEYS = ['SYNC_SECRET'];
|
||||||
|
|
||||||
|
// 受保护配置项:禁止通过 PUT 接口直接覆写
|
||||||
|
// SYNC_SECRET 由系统自动生成并轮换,管理员手动覆写会导致已知密钥攻击
|
||||||
|
const PROTECTED_KEYS = ['SYNC_SECRET'];
|
||||||
|
|
||||||
|
// 获取所有配置
|
||||||
|
router.get('/', adminAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const configs = await prisma.systemConfig.findMany();
|
||||||
|
const result = configs.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
value: SENSITIVE_KEYS.includes(c.key) ? '***' : c.value,
|
||||||
|
updatedAt: c.updatedAt,
|
||||||
|
}));
|
||||||
|
res.json({ configs: result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取系统配置失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新配置项
|
||||||
|
router.put('/:key', adminAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { value } = req.body;
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
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({
|
||||||
|
where: { key: req.params.key },
|
||||||
|
update: { value },
|
||||||
|
create: { key: req.params.key, value },
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
config: {
|
||||||
|
key: config.key,
|
||||||
|
value: SENSITIVE_KEYS.includes(config.key) ? '***' : config.value,
|
||||||
|
updatedAt: config.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('更新系统配置失败:', err);
|
||||||
|
res.status(500).json({ error: '更新失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
const express = require('express');
|
||||||
|
const prisma = require('../lib/prisma');
|
||||||
|
const { adminAuthMiddleware } = require('../lib/auth');
|
||||||
|
const { generateSyncToken } = require('../lib/sync-token');
|
||||||
|
const { postSync } = require('../lib/hermes-client');
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
profileName,
|
||||||
|
modelKey,
|
||||||
|
provider,
|
||||||
|
multimediaModelKey,
|
||||||
|
multimediaProvider,
|
||||||
|
enableSchedule,
|
||||||
|
webhookUrl,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!profileName) {
|
||||||
|
return res.status(400).json({ error: 'profile 名字不能为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await prisma.role.findUnique({ where: { id: req.params.roleId } });
|
||||||
|
if (!role) {
|
||||||
|
return res.status(404).json({ error: '角色不存在' });
|
||||||
|
}
|
||||||
|
if (!['approved', 'failed'].includes(role.reviewStatus)) {
|
||||||
|
return res.status(400).json({ error: `当前状态为 ${role.reviewStatus},无法同步(需 approved 或 failed)` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Hermes webhook URL:优先用请求参数,否则用全局配置
|
||||||
|
let hermesUrl = webhookUrl;
|
||||||
|
if (!hermesUrl) {
|
||||||
|
const config = await prisma.systemConfig.findUnique({ where: { key: 'HERMES_WEBHOOK_URL' } });
|
||||||
|
hermesUrl = config?.value;
|
||||||
|
}
|
||||||
|
if (!hermesUrl) {
|
||||||
|
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 回调拉取文件)
|
||||||
|
// 优先使用环境变量 BASE_URL,避免 Host 头被伪造
|
||||||
|
const baseUrl = process.env.BASE_URL || (req.protocol + '://' + req.get('host'));
|
||||||
|
|
||||||
|
// 生成 sync_token
|
||||||
|
const syncToken = await generateSyncToken(role.id, req.adminId);
|
||||||
|
|
||||||
|
// 更新状态为 syncing
|
||||||
|
await prisma.role.update({
|
||||||
|
where: { id: role.id },
|
||||||
|
data: { reviewStatus: 'syncing' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 向 Hermes POST 同步请求
|
||||||
|
const payload = {
|
||||||
|
profileName,
|
||||||
|
modelKey,
|
||||||
|
provider,
|
||||||
|
multimediaModelKey,
|
||||||
|
multimediaProvider,
|
||||||
|
enableSchedule: !!enableSchedule,
|
||||||
|
syncToken,
|
||||||
|
filePullBaseUrl: baseUrl,
|
||||||
|
roleId: role.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await postSync(hermesUrl, payload);
|
||||||
|
|
||||||
|
// 同步成功,存储二维码 URL
|
||||||
|
const updated = await prisma.role.update({
|
||||||
|
where: { id: role.id },
|
||||||
|
data: {
|
||||||
|
reviewStatus: 'synced',
|
||||||
|
qrCodeUrl: result.qrCodeUrl || null,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
reviewStatus: true,
|
||||||
|
qrCodeUrl: true,
|
||||||
|
syncedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ role: updated, profileId: result.profileId });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('同步失败:', err);
|
||||||
|
|
||||||
|
// 同步失败,仅当当前状态为 syncing 时才回滚为 failed(避免误覆盖 approved)
|
||||||
|
try {
|
||||||
|
const current = await prisma.role.findUnique({
|
||||||
|
where: { id: req.params.roleId },
|
||||||
|
select: { reviewStatus: true },
|
||||||
|
});
|
||||||
|
if (current && current.reviewStatus === 'syncing') {
|
||||||
|
await prisma.role.update({
|
||||||
|
where: { id: req.params.roleId },
|
||||||
|
data: { reviewStatus: 'failed' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (updateErr) {
|
||||||
|
console.error('更新失败状态出错:', updateErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '重置失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
const express = require('express');
|
||||||
|
const prisma = require('../lib/prisma');
|
||||||
|
const { adminAuthMiddleware } = require('../lib/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取待审核列表
|
||||||
|
router.get('/reviews', adminAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
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 [roles, total] = await Promise.all([
|
||||||
|
prisma.role.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
creatorId: true,
|
||||||
|
reviewStatus: true,
|
||||||
|
reviewNote: true,
|
||||||
|
qrCodeUrl: true,
|
||||||
|
syncedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.role.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ roles, total, page, pageSize });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取审核列表失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取角色详情(审核用)
|
||||||
|
router.get('/reviews/:roleId', adminAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const role = await prisma.role.findUnique({
|
||||||
|
where: { id: req.params.roleId },
|
||||||
|
});
|
||||||
|
if (!role) {
|
||||||
|
return res.status(404).json({ error: '角色不存在' });
|
||||||
|
}
|
||||||
|
res.json({ role });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取角色详情失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通过审核
|
||||||
|
router.post('/reviews/:roleId/approve', 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 !== 'pending_review') {
|
||||||
|
return res.status(400).json({ error: `当前状态为 ${role.reviewStatus},无法通过审核` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.role.update({
|
||||||
|
where: { id: req.params.roleId },
|
||||||
|
data: { reviewStatus: 'approved' },
|
||||||
|
select: { id: true, displayName: true, reviewStatus: true },
|
||||||
|
});
|
||||||
|
res.json({ role: updated });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('通过审核失败:', err);
|
||||||
|
res.status(500).json({ error: '操作失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 驳回审核
|
||||||
|
router.post('/reviews/:roleId/reject', adminAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { reviewNote } = req.body;
|
||||||
|
const role = await prisma.role.findUnique({ where: { id: req.params.roleId } });
|
||||||
|
if (!role) {
|
||||||
|
return res.status(404).json({ error: '角色不存在' });
|
||||||
|
}
|
||||||
|
if (role.reviewStatus !== 'pending_review') {
|
||||||
|
return res.status(400).json({ error: `当前状态为 ${role.reviewStatus},无法驳回` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.role.update({
|
||||||
|
where: { id: req.params.roleId },
|
||||||
|
data: { reviewStatus: 'rejected', reviewNote: reviewNote || null },
|
||||||
|
select: { id: true, displayName: true, reviewStatus: true, reviewNote: true },
|
||||||
|
});
|
||||||
|
res.json({ role: updated });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('驳回审核失败:', err);
|
||||||
|
res.status(500).json({ error: '操作失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取所有角色同步状态
|
||||||
|
router.get('/sync-status', adminAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const roles = await prisma.role.findMany({
|
||||||
|
where: { reviewStatus: { in: ['approved', 'syncing', 'synced', 'failed'] } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
creatorId: true,
|
||||||
|
reviewStatus: true,
|
||||||
|
qrCodeUrl: true,
|
||||||
|
syncedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json({ roles });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取同步状态失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const prisma = require('../lib/prisma');
|
const prisma = require('../lib/prisma');
|
||||||
const { apiKeyMiddleware } = require('../lib/auth');
|
const { apiKeyMiddleware } = require('../lib/auth');
|
||||||
|
const { verifySyncToken } = require('../lib/sync-token');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -40,31 +41,67 @@ function adaptToHermesConfig(role) {
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 统一认证中间件:支持 sync_token、API Key、JWT 三种认证方式
|
||||||
|
async function hermesAuthMiddleware(req, res, next) {
|
||||||
|
// 优先检查 X-Sync-Token header(Hermes 回调拉取)
|
||||||
|
const syncTokenHeader = req.headers['x-sync-token'];
|
||||||
|
if (syncTokenHeader) {
|
||||||
|
try {
|
||||||
|
const payload = await verifySyncToken(syncTokenHeader);
|
||||||
|
if (!payload) {
|
||||||
|
return res.status(401).json({ error: 'sync_token 无效或已过期' });
|
||||||
|
}
|
||||||
|
// 验证 token 中的 roleId 与 URL 中的 :id 一致
|
||||||
|
if (payload.roleId !== req.params.id) {
|
||||||
|
return res.status(403).json({ error: 'sync_token 与请求的角色不匹配' });
|
||||||
|
}
|
||||||
|
// 通过 Role.creatorId 反查 userId
|
||||||
|
const role = await prisma.role.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
select: { creatorId: true },
|
||||||
|
});
|
||||||
|
if (!role) {
|
||||||
|
return res.status(404).json({ error: '角色不存在' });
|
||||||
|
}
|
||||||
|
req.userId = role.creatorId;
|
||||||
|
req.authMethod = 'sync_token';
|
||||||
|
req.syncTokenPayload = payload;
|
||||||
|
return next();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('sync_token 验证失败:', err);
|
||||||
|
return res.status(500).json({ error: '认证失败' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则走原有 apiKeyMiddleware(支持 API Key 和 JWT)
|
||||||
|
return apiKeyMiddleware(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/hermes/roles/:id/SOUL.md — 返回 SOUL.md 内容
|
// GET /api/hermes/roles/:id/SOUL.md — 返回 SOUL.md 内容
|
||||||
router.get('/roles/:id/SOUL.md', apiKeyMiddleware, async (req, res) => {
|
router.get('/roles/:id/SOUL.md', hermesAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const role = await prisma.role.findUnique({
|
const role = await prisma.role.findUnique({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
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: '服务器错误' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/hermes/roles/:id/config.yaml — 返回适配后的 Hermes config.yaml
|
// GET /api/hermes/roles/:id/config.yaml — 返回适配后的 Hermes config.yaml
|
||||||
router.get('/roles/:id/config.yaml', apiKeyMiddleware, async (req, res) => {
|
router.get('/roles/:id/config.yaml', hermesAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const role = await prisma.role.findUnique({
|
const role = await prisma.role.findUnique({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
|
|
@ -81,16 +118,16 @@ router.get('/roles/:id/config.yaml', apiKeyMiddleware, 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: '服务器错误' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
const express = require('express');
|
||||||
|
const { verifySyncToken } = require('../lib/sync-token');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Mock Hermes 同步端点 — 模拟 Hermes 接收同步请求并回调拉取文件
|
||||||
|
// 仅在非 production 环境注册
|
||||||
|
router.post('/sync', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { syncToken, filePullBaseUrl, roleId, profileName } = req.body;
|
||||||
|
|
||||||
|
if (!syncToken || !filePullBaseUrl || !roleId) {
|
||||||
|
return res.status(400).json({ error: '缺少必要参数' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 sync_token
|
||||||
|
const payload = await verifySyncToken(syncToken);
|
||||||
|
if (!payload) {
|
||||||
|
return res.status(401).json({ error: 'sync_token 无效或已过期' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 roleId 匹配
|
||||||
|
if (payload.roleId !== roleId) {
|
||||||
|
return res.status(403).json({ error: 'roleId 与 sync_token 不匹配' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回调拉取 SOUL.md
|
||||||
|
const soulResponse = await fetch(`${filePullBaseUrl}/api/hermes/roles/${roleId}/SOUL.md`, {
|
||||||
|
headers: { 'X-Sync-Token': syncToken },
|
||||||
|
});
|
||||||
|
if (!soulResponse.ok) {
|
||||||
|
return res.status(500).json({ error: `拉取 SOUL.md 失败: ${soulResponse.status}` });
|
||||||
|
}
|
||||||
|
const soulMd = await soulResponse.text();
|
||||||
|
|
||||||
|
// 回调拉取 config.yaml
|
||||||
|
const configResponse = await fetch(`${filePullBaseUrl}/api/hermes/roles/${roleId}/config.yaml`, {
|
||||||
|
headers: { 'X-Sync-Token': syncToken },
|
||||||
|
});
|
||||||
|
if (!configResponse.ok) {
|
||||||
|
return res.status(500).json({ error: `拉取 config.yaml 失败: ${configResponse.status}` });
|
||||||
|
}
|
||||||
|
const configYaml = await configResponse.text();
|
||||||
|
|
||||||
|
// 模拟创建 profile 和生成二维码
|
||||||
|
console.log(`[Mock Hermes] 创建 profile: ${profileName}`);
|
||||||
|
console.log(`[Mock Hermes] SOUL.md 长度: ${soulMd.length}`);
|
||||||
|
console.log(`[Mock Hermes] config.yaml 长度: ${configYaml.length}`);
|
||||||
|
|
||||||
|
const qrCodeUrl = `https://mock.hermes.local/qr/${roleId}`;
|
||||||
|
const profileId = `mock-profile-${roleId}`;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
qrCodeUrl,
|
||||||
|
profileId,
|
||||||
|
message: 'Profile 创建成功(mock)',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Mock Hermes] 同步失败:', err);
|
||||||
|
res.status(500).json({ error: err.message || '同步失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
const express = require('express');
|
||||||
|
const prisma = require('../lib/prisma');
|
||||||
|
const { authMiddleware } = require('../lib/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 创建订单(付款)
|
||||||
|
router.post('/', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { roleId, amount } = req.body;
|
||||||
|
|
||||||
|
// 校验 roleId 和 amount 必填,且 amount > 0
|
||||||
|
if (!roleId || amount === undefined || amount === null) {
|
||||||
|
return res.status(400).json({ error: 'roleId 和 amount 为必填字段' });
|
||||||
|
}
|
||||||
|
const amountNum = parseFloat(amount);
|
||||||
|
if (isNaN(amountNum) || amountNum <= 0) {
|
||||||
|
return res.status(400).json({ error: 'amount 必须为大于 0 的数字' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询角色是否存在
|
||||||
|
const role = await prisma.role.findUnique({
|
||||||
|
where: { id: roleId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
qrCodeUrl: true,
|
||||||
|
reviewStatus: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
return res.status(404).json({ error: '角色不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 角色必须已同步且二维码非空
|
||||||
|
if (role.reviewStatus !== 'synced' || !role.qrCodeUrl) {
|
||||||
|
return res.status(400).json({ error: '该角色尚未同步,无法订阅' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建订单(默认 status='paid')
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
userId: req.userId,
|
||||||
|
roleId,
|
||||||
|
amount: amountNum,
|
||||||
|
status: 'paid',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
roleId: true,
|
||||||
|
amount: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
order,
|
||||||
|
role: {
|
||||||
|
qrCodeUrl: role.qrCodeUrl,
|
||||||
|
displayName: role.displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('创建订单失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 查询当前用户订单列表
|
||||||
|
router.get('/', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const orders = await prisma.order.findMany({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
roleId: true,
|
||||||
|
amount: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
role: {
|
||||||
|
select: {
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json({ orders });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取订单列表失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 查询订单详情
|
||||||
|
router.get('/:id', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
roleId: true,
|
||||||
|
amount: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
role: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
desc: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({ error: '订单不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验订单归属
|
||||||
|
if (order.userId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: '无权查看此订单' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ order });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取订单详情失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -4,11 +4,11 @@ const { authMiddleware } = require('../lib/auth');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// 获取角色库(所有已上架角色)
|
// 获取角色库(仅显示已同步完成的角色)
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const roles = await prisma.role.findMany({
|
const roles = await prisma.role.findMany({
|
||||||
where: { status: 'running' },
|
where: { reviewStatus: 'synced' },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -58,12 +63,25 @@ router.get('/:id', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取当前用户创建的角色
|
// 获取当前用户创建的角色(含审核状态和二维码)
|
||||||
router.get('/my/roles', authMiddleware, async (req, res) => {
|
router.get('/my/roles', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const roles = await prisma.role.findMany({
|
const roles = await prisma.role.findMany({
|
||||||
where: { creatorId: req.userId },
|
where: { creatorId: req.userId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
desc: true,
|
||||||
|
price: true,
|
||||||
|
status: true,
|
||||||
|
reviewStatus: true,
|
||||||
|
reviewNote: true,
|
||||||
|
qrCodeUrl: true,
|
||||||
|
syncedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
res.json({ roles });
|
res.json({ roles });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -108,6 +126,7 @@ router.post('/', authMiddleware, async (req, res) => {
|
||||||
desc: data.desc || data.personality.slice(0, 50),
|
desc: data.desc || data.personality.slice(0, 50),
|
||||||
price: parseFloat(data.price) || 0,
|
price: parseFloat(data.price) || 0,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
reviewStatus: 'pending_review',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -130,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: {
|
||||||
|
|
@ -157,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: '编辑失败' });
|
||||||
|
|
|
||||||
17
styles.css
17
styles.css
|
|
@ -1414,3 +1414,20 @@ textarea.field__input--tall {
|
||||||
scroll-behavior: auto !important;
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === 管理员后台样式 === */
|
||||||
|
.admin-panel { min-height: 60vh; }
|
||||||
|
|
||||||
|
/* 审核状态标签颜色 */
|
||||||
|
.role-card__status--pending { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.role-card__status--approved { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.role-card__status--rejected { background: #fee2e2; color: #dc2626; }
|
||||||
|
.role-card__status--syncing { background: #fef3c7; color: #d97706; }
|
||||||
|
.role-card__status--synced { background: #d1fae5; color: #059669; }
|
||||||
|
.role-card__status--failed { background: #ffedd5; color: #ea580c; }
|
||||||
|
|
||||||
|
/* 管理员筛选按钮 active 状态 */
|
||||||
|
.admin-filter.active { background: var(--primary, #667eea); color: #fff; border-color: var(--primary, #667eea); }
|
||||||
|
|
||||||
|
/* 管理员审核卡片 */
|
||||||
|
.admin-review-card { cursor: pointer; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue