修复后台删不掉内容系统发布的文章,登录状态重启后不再失效

- 删除文章:先删引用它的发布记录和实体关联再删文章(之前顺序反了,数据库外键拒绝删除),并放在一个事务里,失败时不会只删掉一半
- 管理员登录状态存到数据库(admin_sessions,只存令牌哈希),服务重启或部署后不用重新登录;登录过期时后台回到登录页并提示,而不是操作一直失败
- 内容系统发布的文章可修改知识中心列表里的摘要
- 文章缺少扩展记录时,修改分类和发布时间也能保存
- 提示文字改为“约 1 分钟内在网站生效”(前台页面缓存 60 秒)
- 新增不连数据库的单元测试 tests/adminFixes.unit.test.js(其他测试会写 .env 里的库)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
amb
2026-10-19 16:11:12 +08:00
co-authored by Claude Opus 5.5
parent 8da2962bfa
commit b07394eed2
8 changed files with 145 additions and 28 deletions
+10 -8
View File
@@ -5,9 +5,9 @@ import { FormEvent, useEffect, useMemo, useState } from 'react';
type Row = { type Row = {
content_id: string; title: string; publish_status: string; created_at: string; updated_at: string; content_id: string; title: string; publish_status: string; created_at: string; updated_at: string;
category?: string | null; tags?: string | null; author?: string | null; published_at?: string | null; category?: string | null; tags?: string | null; author?: string | null; published_at?: string | null;
source: 'content_system' | 'manual'; public_path: string; source: 'content_system' | 'manual'; public_path: string; summary?: string | null;
}; };
type Draft = { content_id: string; title: string; body: string; publish_status: string; category: string; tags: string; author: string; published_at: string; source?: Row['source'] }; type Draft = { content_id: string; title: string; body: string; publish_status: string; category: string; tags: string; author: string; published_at: string; summary: string; source?: Row['source'] };
type Request = (path: string, options?: RequestInit) => Promise<any>; type Request = (path: string, options?: RequestInit) => Promise<any>;
const STATUS: Record<string, string> = { published: '已发布', draft: '草稿', review: '审核中', unpublished: '已下线', historical: '历史版本' }; const STATUS: Record<string, string> = { published: '已发布', draft: '草稿', review: '审核中', unpublished: '已下线', historical: '历史版本' };
@@ -26,7 +26,7 @@ function toText(value?: string | null) {
const date = new Date(value); const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
} }
const empty = (): Draft => ({ content_id: '', title: '', body: '', publish_status: 'draft', category: '', tags: '', author: '阿美巴', published_at: '' }); const empty = (): Draft => ({ content_id: '', title: '', body: '', publish_status: 'draft', category: '', tags: '', author: '阿美巴', published_at: '', summary: '' });
/** Articles: search, filter, publish or take offline, edit, and delete one or many. */ /** Articles: search, filter, publish or take offline, edit, and delete one or many. */
export function ArticleManager({ request, onMessage, onError }: { request: Request; onMessage: (text: string) => void; onError: (text: string) => void }) { export function ArticleManager({ request, onMessage, onError }: { request: Request; onMessage: (text: string) => void; onError: (text: string) => void }) {
@@ -72,23 +72,24 @@ export function ArticleManager({ request, onMessage, onError }: { request: Reque
function toggle(row: Row) { function toggle(row: Row) {
const next = row.publish_status === 'published' ? 'unpublished' : 'published'; const next = row.publish_status === 'published' ? 'unpublished' : 'published';
void run(() => request(`/api/admin/contents/article/${row.content_id}`, { method: 'PUT', body: JSON.stringify({ publish_status: next }) }), void run(() => request(`/api/admin/contents/article/${row.content_id}`, { method: 'PUT', body: JSON.stringify({ publish_status: next }) }),
next === 'published' ? '文章已上线。' : '文章已下线,网站上不再显示。'); next === 'published' ? '文章已上线,网站约 1 分钟内显示。' : '文章已下线,网站约 1 分钟内不再显示。');
} }
async function edit(row: Row) { async function edit(row: Row) {
try { try {
const full = await request(`/api/admin/contents/article/${row.content_id}`); const full = await request(`/api/admin/contents/article/${row.content_id}`);
setEditing({ content_id: row.content_id, title: full.title || '', body: full.body || '', publish_status: full.publish_status || 'draft', setEditing({ content_id: row.content_id, title: full.title || '', body: full.body || '', publish_status: full.publish_status || 'draft',
category: full.category || '', tags: full.tags || '', author: full.author || '', published_at: toInput(full.published_at), source: row.source }); category: full.category || '', tags: full.tags || '', author: full.author || '', published_at: toInput(full.published_at), summary: row.summary || '', source: row.source });
} catch (e: any) { onError(e.message); } } catch (e: any) { onError(e.message); }
} }
async function save(event: FormEvent) { async function save(event: FormEvent) {
event.preventDefault(); event.preventDefault();
if (!editing) return; if (!editing) return;
const data = { title: editing.title.trim(), body: editing.body, publish_status: editing.publish_status, category: editing.category, const data = { title: editing.title.trim(), body: editing.body, publish_status: editing.publish_status, category: editing.category,
tags: editing.tags, author: editing.author, published_at: editing.published_at ? `${editing.published_at.replace('T', ' ')}:00` : '' }; tags: editing.tags, author: editing.author, published_at: editing.published_at ? `${editing.published_at.replace('T', ' ')}:00` : '',
...(editing.source === 'content_system' ? { summary: editing.summary } : {}) };
const path = editing.content_id ? `/api/admin/contents/article/${editing.content_id}` : '/api/admin/contents/article'; const path = editing.content_id ? `/api/admin/contents/article/${editing.content_id}` : '/api/admin/contents/article';
await run(async () => { await request(path, { method: editing.content_id ? 'PUT' : 'POST', body: JSON.stringify(data) }); setEditing(null); }, await run(async () => { await request(path, { method: editing.content_id ? 'PUT' : 'POST', body: JSON.stringify(data) }); setEditing(null); },
editing.content_id ? '修改已保存,网站上立即生效。' : '文章已新增。'); editing.content_id ? '修改已保存,网站约 1 分钟内更新。' : '文章已新增。');
} }
return <div className="article-manager"> return <div className="article-manager">
@@ -134,8 +135,9 @@ export function ArticleManager({ request, onMessage, onError }: { request: Reque
{editing && <div className="article-editor-backdrop" role="presentation" onMouseDown={(e) => { if (e.target === e.currentTarget) setEditing(null); }}> {editing && <div className="article-editor-backdrop" role="presentation" onMouseDown={(e) => { if (e.target === e.currentTarget) setEditing(null); }}>
<form className="editor-form article-editor" onSubmit={save} role="dialog" aria-modal="true" aria-label={editing.content_id ? '编辑文章' : '新增文章'}> <form className="editor-form article-editor" onSubmit={save} role="dialog" aria-modal="true" aria-label={editing.content_id ? '编辑文章' : '新增文章'}>
<header><h3>{editing.content_id ? '编辑文章' : '新增文章'}</h3><button type="button" className="admin-quiet-button" onClick={() => setEditing(null)}>关闭</button></header> <header><h3>{editing.content_id ? '编辑文章' : '新增文章'}</h3><button type="button" className="admin-quiet-button" onClick={() => setEditing(null)}>关闭</button></header>
{editing.source === 'content_system' && <p className="article-note">这篇文章来自内容系统。这里的修改会立即生效,但内容系统再次发布同一篇文章时会覆盖这里的修改。</p>} {editing.source === 'content_system' && <p className="article-note">这篇文章来自内容系统。这里的修改约 1 分钟内在网站生效,但内容系统再次发布同一篇文章时会覆盖这里的修改。</p>}
<label>标题<input value={editing.title} onChange={(e) => setEditing({ ...editing, title: e.target.value })} required /></label> <label>标题<input value={editing.title} onChange={(e) => setEditing({ ...editing, title: e.target.value })} required /></label>
{editing.source === 'content_system' && <label>摘要(知识中心列表里显示)<textarea rows={3} value={editing.summary} onChange={(e) => setEditing({ ...editing, summary: e.target.value })} /></label>}
<label>正文<small>{editing.body.replace(/\s/g, '').length} 字</small><textarea rows={16} value={editing.body} onChange={(e) => setEditing({ ...editing, body: e.target.value })} /></label> <label>正文<small>{editing.body.replace(/\s/g, '').length} 字</small><textarea rows={16} value={editing.body} onChange={(e) => setEditing({ ...editing, body: e.target.value })} /></label>
<div className="article-editor-grid"> <div className="article-editor-grid">
<label>分类<input value={editing.category} onChange={(e) => setEditing({ ...editing, category: e.target.value })} /></label> <label>分类<input value={editing.category} onChange={(e) => setEditing({ ...editing, category: e.target.value })} /></label>
+3 -1
View File
@@ -129,7 +129,8 @@ type Setting = { setting_key: string; setting_value: string; setting_group: stri
async function request(path: string, token: string, options: RequestInit = {}) { async function request(path: string, token: string, options: RequestInit = {}) {
const response = await fetch(`${API_URL}${path}`, { ...options, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...(options.headers || {}) } }); const response = await fetch(`${API_URL}${path}`, { ...options, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...(options.headers || {}) } });
const data = response.status === 204 ? null : await response.json(); const data = response.status === 204 ? null : await response.json().catch(() => null);
if (response.status === 401 && path !== '/api/admin/login') { window.dispatchEvent(new Event('ameiba-admin-unauthorized')); throw new Error('登录已过期,请重新登录。'); }
if (!response.ok) throw new Error(data?.error || '操作没有完成,请稍后重试'); if (!response.ok) throw new Error(data?.error || '操作没有完成,请稍后重试');
return data; return data;
} }
@@ -143,6 +144,7 @@ export default function AdminPage() {
const [editing, setEditing] = useState<ContentItem | null>(null); const [details, setDetails] = useState<Record<string, string>>({}); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [editing, setEditing] = useState<ContentItem | null>(null); const [details, setDetails] = useState<Record<string, string>>({}); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
useEffect(() => { const saved = window.localStorage.getItem('ameiba_admin_token'); if (saved) { setToken(saved); loadSettings(saved); } }, []); useEffect(() => { const saved = window.localStorage.getItem('ameiba_admin_token'); if (saved) { setToken(saved); loadSettings(saved); } }, []);
useEffect(() => { const expired = () => { window.localStorage.removeItem('ameiba_admin_token'); setToken(''); setMessage(''); setError('登录已过期,请重新登录。'); }; window.addEventListener('ameiba-admin-unauthorized', expired); return () => window.removeEventListener('ameiba-admin-unauthorized', expired); }, []);
async function loadSettings(authToken = token) { try { setSettings(await request('/api/admin/settings', authToken)); } catch (e: any) { setError(e.message); } } async function loadSettings(authToken = token) { try { setSettings(await request('/api/admin/settings', authToken)); } catch (e: any) { setError(e.message); } }
async function loadContents(contentType = type, authToken = token) { try { setContents((await request(`/api/admin/contents/${contentType}?pageSize=100`, authToken)).list || []); } catch (e: any) { setError(e.message); } } async function loadContents(contentType = type, authToken = token) { try { setContents((await request(`/api/admin/contents/${contentType}?pageSize=100`, authToken)).list || []); } catch (e: any) { setError(e.message); } }
async function login(event: FormEvent) { event.preventDefault(); setError(''); try { const response = await fetch(`${API_URL}/api/admin/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || '用户名或密码错误'); window.localStorage.setItem('ameiba_admin_token', data.token); setToken(data.token); setPassword(''); await loadSettings(data.token); } catch (e: any) { setError(e.message); } } async function login(event: FormEvent) { event.preventDefault(); setError(''); try { const response = await fetch(`${API_URL}/api/admin/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || '用户名或密码错误'); window.localStorage.setItem('ameiba_admin_token', data.token); setToken(data.token); setPassword(''); await loadSettings(data.token); } catch (e: any) { setError(e.message); } }
+12
View File
@@ -165,6 +165,18 @@ const createTables = () => {
) )
`); `);
// 管理员登录状态(存库,服务重启后不用重新登录)
db.exec(`
CREATE TABLE IF NOT EXISTS admin_sessions (
token_hash CHAR(64) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
username VARCHAR(64) NOT NULL,
expires_at BIGINT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_admin_sessions_username (username)
)
`);
// 前台可编辑站点配置 // 前台可编辑站点配置
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS site_settings ( CREATE TABLE IF NOT EXISTS site_settings (
+1
View File
@@ -86,6 +86,7 @@ class Article extends Content {
const setClause = Object.keys(articleData).map(key => `${key} = ?`).join(', '); const setClause = Object.keys(articleData).map(key => `${key} = ?`).join(', ');
const values = Object.values(articleData); const values = Object.values(articleData);
db.prepare('INSERT IGNORE INTO articles (article_id) VALUES (?)').run(contentId);
const stmt = db.prepare(`UPDATE articles SET ${setClause} WHERE article_id = ?`); const stmt = db.prepare(`UPDATE articles SET ${setClause} WHERE article_id = ?`);
stmt.run(...values, contentId); stmt.run(...values, contentId);
} }
+9 -2
View File
@@ -68,7 +68,7 @@ router.get('/articles', (req, res) => {
const rows = db.prepare(` const rows = db.prepare(`
SELECT c.content_id, c.title, c.publish_status, c.created_at, c.updated_at, SELECT c.content_id, c.title, c.publish_status, c.created_at, c.updated_at,
a.category, a.tags, a.author, a.published_at, a.category, a.tags, a.author, a.published_at,
p.publication_id, p.slug, p.source_system p.publication_id, p.slug, p.source_system, p.summary
FROM contents c FROM contents c
LEFT JOIN articles a ON a.article_id = c.content_id LEFT JOIN articles a ON a.article_id = c.content_id
LEFT JOIN integration_publications p ON p.content_id = c.content_id LEFT JOIN integration_publications p ON p.content_id = c.content_id
@@ -124,6 +124,9 @@ router.post('/contents/:type', (req, res) => {
router.put('/contents/:type/:id', (req, res) => { router.put('/contents/:type/:id', (req, res) => {
try { try {
const data = { ...req.body }; const data = { ...req.body };
// The summary shown in the knowledge list belongs to the content system's publication record.
const summary = req.params.type === 'article' && typeof data.summary === 'string' ? data.summary.trim() : undefined;
delete data.summary;
if (req.params.type === 'article') { if (req.params.type === 'article') {
// The admin form sends "2026-10-19T15:30" (local time); MySQL wants "2026-10-19 15:30:00". // The admin form sends "2026-10-19T15:30" (local time); MySQL wants "2026-10-19 15:30:00".
if (typeof data.published_at === 'string') { if (typeof data.published_at === 'string') {
@@ -137,7 +140,11 @@ router.put('/contents/:type/:id', (req, res) => {
else delete data.published_at; else delete data.published_at;
} }
} }
res.json(ContentService.update(req.params.id, data, req.params.type)); const updated = ContentService.update(req.params.id, data, req.params.type);
if (summary !== undefined) {
getDb().prepare('UPDATE integration_publications SET summary = ? WHERE content_id = ?').run(summary, req.params.id);
}
res.json(updated);
} catch (error) { } catch (error) {
res.status(400).json({ error: error.message }); res.status(400).json({ error: error.message });
} }
+14 -13
View File
@@ -2,7 +2,9 @@ const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { getDb } = require('../config/database'); const { getDb } = require('../config/database');
const sessions = new Map(); const SESSION_MS = 8 * 60 * 60 * 1000;
// Only a hash of the token is stored, so the table never holds a usable login.
const tokenHash = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');
const hashPassword = (password, salt = crypto.randomBytes(16).toString('hex')) => { const hashPassword = (password, salt = crypto.randomBytes(16).toString('hex')) => {
const hash = crypto.scryptSync(password, salt, 64).toString('hex'); const hash = crypto.scryptSync(password, salt, 64).toString('hex');
@@ -23,17 +25,18 @@ class AdminAuthService {
throw new Error('用户名或密码错误'); throw new Error('用户名或密码错误');
} }
const token = crypto.randomBytes(32).toString('hex'); const token = crypto.randomBytes(32).toString('hex');
sessions.set(token, { userId: user.user_id, username: user.username, expiresAt: Date.now() + 8 * 60 * 60 * 1000 }); const db = getDb();
return { token, username: user.username, expiresIn: 8 * 60 * 60 }; db.prepare('DELETE FROM admin_sessions WHERE expires_at < ?').run(Date.now());
db.prepare('INSERT INTO admin_sessions (token_hash, user_id, username, expires_at) VALUES (?, ?, ?, ?)')
.run(tokenHash(token), user.user_id, user.username, Date.now() + SESSION_MS);
return { token, username: user.username, expiresIn: SESSION_MS / 1000 };
} }
static verify(token) { static verify(token) {
const session = sessions.get(token); if (!token) return null;
if (!session || session.expiresAt < Date.now()) { const row = getDb().prepare('SELECT user_id, username, expires_at FROM admin_sessions WHERE token_hash = ?').get(tokenHash(token));
if (token) sessions.delete(token); if (!row || Number(row.expires_at) < Date.now()) return null;
return null; return { userId: row.user_id, username: row.username, expiresAt: Number(row.expires_at) };
}
return session;
} }
static changePassword(username, currentPassword, newPassword) { static changePassword(username, currentPassword, newPassword) {
@@ -43,14 +46,12 @@ class AdminAuthService {
if (!user || !verifyPassword(currentPassword, user.password_hash)) throw new Error('当前密码错误'); if (!user || !verifyPassword(currentPassword, user.password_hash)) throw new Error('当前密码错误');
db.prepare('UPDATE admin_users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE username = ?') db.prepare('UPDATE admin_users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE username = ?')
.run(hashPassword(newPassword), username); .run(hashPassword(newPassword), username);
for (const [token, session] of sessions.entries()) { db.prepare('DELETE FROM admin_sessions WHERE username = ?').run(username);
if (session.username === username) sessions.delete(token);
}
return { success: true }; return { success: true };
} }
static logout(token) { static logout(token) {
sessions.delete(token); if (token) getDb().prepare('DELETE FROM admin_sessions WHERE token_hash = ?').run(tokenHash(token));
} }
} }
+13 -2
View File
@@ -175,10 +175,21 @@ class ContentService {
} }
const Model = this.getModelByType(content.content_type); const Model = this.getModelByType(content.content_type);
// MySQL refuses to delete content that other rows still point at, so those go first: the content system's
// publication record (which also lets the content system publish the article again) and entity links.
// One transaction, so a failure leaves the article whole instead of half deleted.
const db = getDb();
db.prepare('START TRANSACTION').run();
try {
db.prepare('DELETE FROM integration_publications WHERE content_id = ?').run(contentId);
db.prepare('DELETE FROM entity_content_joins WHERE content_id = ?').run(contentId);
const result = Model.delete(contentId); const result = Model.delete(contentId);
// A deleted article published by the content system must be publishable again: forget its publication record. db.prepare('COMMIT').run();
getDb().prepare('DELETE FROM integration_publications WHERE content_id = ?').run(contentId);
return result; return result;
} catch (error) {
db.prepare('ROLLBACK').run();
throw error;
}
} }
// 内容状态流转 // 内容状态流转
+81
View File
@@ -0,0 +1,81 @@
// Unit tests with the database replaced by a fake: the other suites in this folder write to the database in
// .env, which is the production one, so these must never touch it.
const statements = [];
let rows = {};
let failOn = null;
const fakeDb = {
prepare(sql) {
return {
run: (...params) => {
statements.push(sql);
if (failOn && sql.startsWith(failOn)) throw new Error(`fake failure: ${failOn}`);
// Like MySQL's foreign keys: content still referenced by a publication cannot be deleted.
if (sql.startsWith('DELETE FROM contents') && rows.publication) throw new Error('ER_ROW_IS_REFERENCED_2');
if (sql.startsWith('DELETE FROM integration_publications')) rows.publication = null;
if (sql.startsWith('INSERT INTO admin_sessions')) rows.session = { token_hash: params[0], user_id: params[1], username: params[2], expires_at: params[3] };
if (sql.startsWith('DELETE FROM admin_sessions WHERE token_hash')) rows.session = null;
return { changes: 1 };
},
get: (...params) => {
statements.push(sql);
if (sql.includes('FROM contents')) return rows.content;
if (sql.includes('FROM admin_users')) return rows.user;
if (sql.includes('FROM admin_sessions')) return rows.session && rows.session.token_hash === params[0] ? rows.session : undefined;
return undefined;
},
all: () => [],
};
},
};
jest.mock('../src/config/database', () => ({ getDb: () => fakeDb, closeDb: () => {}, toMySqlParam: (v) => v }));
const crypto = require('crypto');
const ContentService = require('../src/services/ContentService');
const AdminAuthService = require('../src/services/AdminAuthService');
beforeEach(() => {
statements.length = 0;
failOn = null;
rows = { content: { content_id: 'c1', content_type: 'article' }, publication: { content_id: 'c1' } };
});
test('deleting a content-system article removes what points at it first, in one transaction', () => {
ContentService.delete('c1');
expect(statements.filter((sql) => !sql.startsWith('SELECT'))).toEqual([
'START TRANSACTION',
'DELETE FROM integration_publications WHERE content_id = ?',
'DELETE FROM entity_content_joins WHERE content_id = ?',
'DELETE FROM articles WHERE article_id = ?',
'DELETE FROM contents WHERE content_id = ?',
'COMMIT',
]);
});
test('a failed delete is rolled back instead of leaving half an article', () => {
failOn = 'DELETE FROM contents';
expect(() => ContentService.delete('c1')).toThrow('fake failure');
expect(statements[statements.length - 1]).toBe('ROLLBACK');
expect(statements).not.toContain('COMMIT');
});
test('admin logins are kept in the database, so a restart does not log the admin out', () => {
const salt = 'ab'.repeat(16);
rows.user = { user_id: 'u1', username: 'admin', password_hash: `${salt}:${crypto.scryptSync('secret-pass', salt, 64).toString('hex')}` };
const { token } = AdminAuthService.login('admin', 'secret-pass');
expect(rows.session.token_hash).not.toBe(token);
jest.isolateModules(() => {
// A fresh copy of the service stands in for the restarted server.
const Restarted = require('../src/services/AdminAuthService');
expect(Restarted.verify(token)).toMatchObject({ username: 'admin' });
expect(Restarted.verify('wrong-token')).toBeNull();
});
AdminAuthService.logout(token);
expect(AdminAuthService.verify(token)).toBeNull();
});
test('an expired login is refused', () => {
rows.session = { token_hash: crypto.createHash('sha256').update('t').digest('hex'), user_id: 'u1', username: 'admin', expires_at: Date.now() - 1 };
expect(AdminAuthService.verify('t')).toBeNull();
});