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

- 删除文章:先删引用它的发布记录和实体关联再删文章(之前顺序反了,数据库外键拒绝删除),并放在一个事务里,失败时不会只删掉一半
- 管理员登录状态存到数据库(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
+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();
});