diff --git a/back/api/script.ts b/back/api/script.ts index 4f2ce92e..3d6f00cd 100644 --- a/back/api/script.ts +++ b/back/api/script.ts @@ -9,11 +9,11 @@ import * as fs from 'fs/promises'; import { celebrate, Joi } from 'celebrate'; import path, { join, parse } from 'path'; import ScriptService from '../services/script'; +import ScriptHistoryService from '../services/scriptHistory'; import { t } from '../shared/i18n'; import multer from 'multer'; import { writeFileWithLock } from '../shared/utils'; import { - ScriptHistory, ScriptHistoryError, HistoryUnavailableError, } from '../shared/scriptHistory'; @@ -120,12 +120,6 @@ export default (app: Router) => { }, ); - const historyService = () => - new ScriptHistory( - config.scriptPath, - join(config.dataPath, 'script-history'), - config.blackFileList, - ); const historyError = (error: unknown, res: Response, next: NextFunction) => { if (error instanceof ScriptHistoryError) { return res.status(error.status).send({ @@ -148,7 +142,8 @@ export default (app: Router) => { celebrate({ query: Joi.object(historyQuery).unknown(true) }), async (req: Request, res: Response, next: NextFunction) => { try { - const data = await historyService().list( + const service = Container.get(ScriptHistoryService); + const data = await service.list( (req.query.path as string) || '', req.query.filename as string, ); @@ -168,7 +163,8 @@ export default (app: Router) => { }), async (req: Request, res: Response, next: NextFunction) => { try { - const data = await historyService().detail( + const service = Container.get(ScriptHistoryService); + const data = await service.detail( (req.query.path as string) || '', req.query.filename as string, req.query.id as string, @@ -191,12 +187,8 @@ export default (app: Router) => { async (req: Request, res: Response, next: NextFunction) => { try { const { path = '', filename, id, expectedHash } = req.body; - const data = await historyService().restore( - path, - filename, - id, - expectedHash, - ); + const service = Container.get(ScriptHistoryService); + const data = await service.restore(path, filename, id, expectedHash); res.send({ code: 200, data }); } catch (error) { historyError(error, res, next); @@ -304,7 +296,8 @@ export default (app: Router) => { join(config.bakPath, originFilename.replace(/\//g, '')), ); } - const data = await historyService().save(path, filename, content, { + const service = Container.get(ScriptHistoryService); + const data = await service.save(path, filename, content, { skipHistory: req.body.skipHistory, expectedHash: req.body.expectedHash, }); @@ -357,15 +350,11 @@ export default (app: Router) => { message: t('暂无权限'), }); } - const data = await historyService().save( - path || '', - filename, - content, - { - skipHistory: req.body.skipHistory, - expectedHash: req.body.expectedHash, - }, - ); + const service = Container.get(ScriptHistoryService); + const data = await service.save(path || '', filename, content, { + skipHistory: req.body.skipHistory, + expectedHash: req.body.expectedHash, + }); return res.send({ code: 200, data }); } catch (e) { return historyError(e, res, next); diff --git a/back/services/scriptHistory.ts b/back/services/scriptHistory.ts new file mode 100644 index 00000000..aa50e7cf --- /dev/null +++ b/back/services/scriptHistory.ts @@ -0,0 +1,16 @@ +import path from 'path'; +import { Service } from 'typedi'; +import config from '../config'; +import { ScriptHistory } from '../shared/scriptHistory'; + +/** Panel configuration and lifecycle; the storage core stays independently testable. */ +@Service() +export default class ScriptHistoryService extends ScriptHistory { + constructor() { + super( + config.scriptPath, + path.join(config.dataPath, 'script-history'), + config.blackFileList, + ); + } +} diff --git a/docs/script-history.md b/docs/script-history.md index b01b731b..854bd121 100644 --- a/docs/script-history.md +++ b/docs/script-history.md @@ -14,7 +14,7 @@ 位置:`/script-history/.br`。目录位于脚本目录之外,避免进入脚本树、订阅和任务扫描。目录权限 `0700`,历史文件权限 `0600`。目录随整个数据目录备份。 -每个脚本仅一个 Brotli 压缩 JSON 包,存储完整的旧内容快照以及时间、来源、内容哈希。新文件内容不再重复存储,只保存头版本元数据,供下一次留档标注来源。包内重复文本共享压缩窗口,不依赖补丁链,也不需要额外数据库、文件监听、后台任务或常驻缓存。使用 Node 内置 zlib 异步压缩,Brotli quality=4、window=4 MiB,不新增依赖。 +每个脚本仅一个 Brotli 压缩 JSON 包,存储完整的旧内容快照以及时间、来源、内容哈希。新文件内容不再重复存储,只保存头版本元数据,供下一次留档标注来源。面板服务通过 TypeDI 注册为单例,路由使用 `Container.get(ScriptHistoryService)` 获取;构造实例只初始化配置,首次发生保存时才创建历史目录。包内重复文本共享压缩窗口,不依赖补丁链,也不需要额外数据库、文件监听、后台任务或常驻缓存。使用 Node 内置 zlib 异步压缩,Brotli quality=4、window=4 MiB,不新增依赖。 | 限制 | 默认值 | | --- | --- | diff --git a/test/back/script-history-api.test.cjs b/test/back/script-history-api.test.cjs index 8aab02f1..0f6d4bfb 100644 --- a/test/back/script-history-api.test.cjs +++ b/test/back/script-history-api.test.cjs @@ -1,3 +1,4 @@ +require('reflect-metadata'); const assert = require('node:assert/strict'); const test = require('node:test'); const fs = require('node:fs/promises'); @@ -5,6 +6,7 @@ const os = require('node:os'); const path = require('node:path'); const express = require('express'); const { errors } = require('celebrate'); +const { Container } = require('typedi'); const load = require('../helpers/load-security-module.cjs'); const { resolveFileAccess } = load( path.join(__dirname, '../../back/shared/fileAccess.ts'), @@ -29,7 +31,7 @@ test('script routes save, list, preview and restore, with validation and conflic return resolveFileAccess(scripts, [dir || '', name]); } } - const api = load(path.join(__dirname, '../../back/api/script.ts'), { + const mocks = { '../config': { default: config, __esModule: true }, 'fs/promises': { ...fs, @@ -54,7 +56,30 @@ test('script routes save, list, preview and restore, with validation and conflic }, '../services/script': { default: ScriptService, __esModule: true }, '../shared/i18n': { t: (value) => value }, - typedi: { Container: { get: () => new ScriptService() } }, + }; + const cache = new Map(); + const api = load( + path.join(__dirname, '../../back/api/script.ts'), + mocks, + cache, + ); + const { default: ScriptHistoryService } = load( + path.join(__dirname, '../../back/services/scriptHistory.ts'), + mocks, + cache, + ); + Container.set(ScriptService, new ScriptService()); + t.after(() => { + Container.remove(ScriptHistoryService); + Container.remove(ScriptService); + }); + assert.ok(Container.has(ScriptHistoryService)); + const historyService = Container.get(ScriptHistoryService); + assert.strictEqual(Container.get(ScriptHistoryService), historyService); + // An empty history read must not create storage or other persistent resources. + assert.deepEqual((await historyService.list('', 'test.js')).versions, []); + await assert.rejects(fs.stat(path.join(root, 'script-history')), { + code: 'ENOENT', }); const app = express(); app.use(express.json({ limit: '50mb' }));