状态:草案,待评审(暂不落地代码) 范围:首批覆盖 商品(Product) 与 博客文章(BlogArticle),架构上可扩展到其它实体 方案选型:审计 / 时间旅行快照(统一历史表记录变更;本期仅交付”删除还原”(误删恢复),修改版本回滚 / 字段级 / 任意时间点还原列为后续扩展)
1. 目标与范围
- 目标(本期):对商品的删除自动留存”删除前完整状态”,用户误删后可把被删记录整行恢复(删除还原)。即:删除不再意味数据不可恢复。
- 首批实体:
Product(src/common/data/mall/entities.ts:393,继承StoreScopedEntity)、BlogArticle(同文件:691,继承StoreScopedEntity)。 - 非目标(本期不做):
- 修改版本回滚 / 字段级还原 / 任意时间点还原(机制预留,列为后续扩展,见 §9 P3);
- 订单、会员等主表以外实体的历史(本期仅覆盖商品 / 博客及其直接关联从表,见 §11-3;其它实体架构留口,后续按需扩展)。
- 关联表纳入(本期):
Product的直接关联从表ProductSKU/ProductSpecification,以及BlogArticle的直接关联从表BlogComment,默认一并纳入历史与删除还原(见 §3 / §11-3)。
2. 现状与约束
- 删除目前是物理删除:
maishu-alinq的DataContext.remove()→DELETE FROM,无回收站、无历史。 - 实体统一基类:
BaseEntity(id / createdAt / updatedAt / sortNumber,带onValidate审计钩子)→StoreScopedEntity(加storeId)。商品、博客文章均继承StoreScopedEntity,自带多店隔离。 - 写入路径可拦截:
maishu-alinq的DataContext.submitChanges()顺序执行 insert → update → delete,写库前可经getChangeSet()取到{inserts, updates, deletes}(alinq.js/src/data-context.ts:844);项目层data-context-decorator.ts的createDataContext/createRequestDataContext是请求级数据上下文统一工厂,是注入历史写入的最佳包装点。 - DB 双形态:本地 / 测试 SQLite,生产店铺库 PostgreSQL;历史表需兼容两者(
sync:true自动建表 vssync:false需迁移脚本)。 - 删除时内存实体仍含完整值:
delete进入 pendingChanges 的实体在内存中保留全部属性,可直接快照”删除前状态”,保证删后也能还原。
3. 方案概述
采用统一历史表 entity_history + JSON 行快照(已确认用统一表,见 §11-1;机制上为完整审计预留,本期落地聚焦”删除还原”):
- 新增一张统一历史表,拦截
submitChanges时对被删的实体写入action: "delete"记录(含删除前完整行 JSON 快照、版本号、操作人、时间、店铺)。本期覆盖Product/BlogArticle及其直接关联从表ProductSKU/ProductSpecification/BlogComment(关联表默认纳入,见 §11-3)。本期不记录 insert / update 历史。 - 拦截点包装
submitChanges:提交前读取变更集,仅对deletes中属于上述实体的记录生成历史,并把它一并add进同一DataContext,使历史与原删除在 同一事务 内原子提交(删前先留痕,删后仍可恢复)。 - 还原(本期 = 删除还原):从历史表取被删记录的
delete快照,整行写回主表恢复;其关联子表(SKU / Spec / Comment)随主表一并恢复(按外键归属重建,见 §6.2)。恢复前须通过 §6.1 唯一性检查。 - 前端:商品 / 博客列表提供”回收站 / 已删”入口,列出可恢复的删除记录,支持预览与一键还原。
4. 数据模型
4.1 推荐:统一历史表 entity_history
// src/common/data/mall/entities.ts(建议位置)
export class EntityHistory extends StoreScopedEntity {
/** 被记录实体的类型名,如 "Product" / "BlogArticle" */
entityType!: string;
/** 被记录实体的 id */
entityId!: string;
/** 该实体递增版本号(同 entityType+entityId 从 1 起) */
version!: number;
/** 变更动作:delete(本期实际仅写此值;枚举保留 insert/update 以备未来扩展审计) */
action!: "insert" | "update" | "delete";
/** 操作人 id(backoffice 管理员 / 商户用户;无则空) */
operatorId?: string | null;
/** 操作时间(写库时间) */
operatedAt!: Date;
/** 被记录实体在删除前的完整状态(删除还原的数据源) */
snapshot!: any; // JSON 列
}
备选:按实体分表(product_history / blog_article_history,字段强类型)。
- 优点:类型安全、大实体查询 / 索引性能好。
- 缺点:每加一个实体要建一张表、改拦截逻辑,扩展性差。
- 本期建议用统一表,后续若单实体历史量极大再考虑分表。
4.2 版本号与索引
- 版本号由应用层在写入前计算:
max(version) + 1(按entityType + entityId分组)。高并发可用唯一约束(entityType, entityId, version)兜底。 - 建议索引:
(entityType, entityId, version)、(storeId, entityType, entityId)、(operatedAt)。 - 迁移:测试库
sync:true自动建表;生产库sync:false需增量迁移脚本(建表 + 索引;历史表初始为空,无需回填旧数据)。
4.3 数据保留与清理(retention)
- 保留期:历史记录保留 1 个月(30 天)(已确认,见 §11-5)。
- 清理:由定时任务(如每日凌晨)物理删除
entity_history中超保留期的行(硬删除历史,不进回收站);清理任务自身不写历史,避免递归。 - 关联清理(按”删除血缘”成组,不可割裂主外键关联):清理不能简单地「逐行按
operatedAt过期即删」。一个被删实体常通过主外键关联依赖其它实体(如分类树A→B→C靠parentId自引用;Product.categoryId → ProductCategory、Order.memberId → Member、BlogComment.articleId → BlogArticle等);若只清掉被引用方(如A、B、ProductCategory)的历史而保留引用方(C、Product、BlogComment)的历史,则引用方再也无法被完整还原(其关联链已断)。因此同一storeId内、经任意 FK(parentId/categoryId/productId/memberId/articleId…)相连的删除记录视为一条”删除血缘”,仅当整条血缘中最年轻的operatedAt也超过保留期时才整体清理(即血缘保留期 =max(操作时间) + 30d)。典型的一次级联删除会把关联记录写在同一个submitChanges,operatedAt本就相同、自然同生同灭;本规则主要兜住”分多次、不同时刻”删除的场景(如C、B、A在不同时间被删)。 - 影响:超过保留期(且整条血缘均超期)的删除记录将无法还原;回收站 UI 需提示用户”删除记录仅保留 30 天”。
5. 变更捕获(写入拦截)
5.1 拦截位置
在 StoreDataContext / MallDataContext 封装类里重写 submitChanges 是最干净的注入点:先按变更集补写 EntityHistory,再 await super.submitChanges(...)(历史与原删除进同一事务)。这样与已落地的缓存 onSubmitted 钩子同处一个类、自动衔接(见 §5.2「与缓存钩子衔接」),无需在 data-context-decorator.ts 里猴子补丁 dc.submitChanges。伪代码(子类重写形态):
// src/common/data/mall/*-data-context.ts —— 项目层,不改 alinq.js
const HISTORY_TYPES = [Product, BlogArticle, ProductSKU, ProductSpecification, BlogComment];
/** 把本次待删实体补写为 EntityHistory(dc.add 进同一 pendingChanges → 同一事务提交)。 */
async function recordDeleteHistory(dc: DataContext) {
// 1. 读变更集(insert/update/delete 的实体数组);本期仅处理 deletes
const { deletes } = dc.getChangeSet();
// 2. 仅对被删的 Product / BlogArticle 及其关联从表(SKU / Spec / Comment)生成历史,
// 排除 EntityHistory 自身(避免递归记录)
const toRecord = deletes.filter(e => HISTORY_TYPES.some(T => e instanceof T));
for (const e of toRecord) {
dc.add(EntityHistory, {
storeId: (e as StoreScopedEntity).storeId,
entityType: e.constructor.name,
entityId: e.id,
version: await nextVersion(dc, e.constructor.name, e.id),
action: "delete", // 本期仅记录删除(删除还原数据源);全量审计时再按变更类型区分
operatorId: currentOperatorId(), // 见 §7 操作人
operatedAt: new Date(),
snapshot: serializeRow(e), // 复用 shapeOf:持久非关联列,保留 FK 标量(见 §5.2)
});
}
}
export class StoreDataContext extends DataContext {
// ... 既有 getTable / onSubmitted(缓存失效,post-commit,见 data-context-cache-plan.md)...
/**
* 删除留痕:pre-commit 快照必须趁 delete 仍在 pendingChanges 时取完整状态,
* 因此重写 submitChanges(而不是等 onSubmitted——那已是 post-commit,删后拿不到状态)。
*/
protected override async submitChanges(failureMode: ConflictMode = ConflictMode.FailOnFirstConflict) {
await recordDeleteHistory(this);
// 3. 原提交(历史与原变更同批事务);super 内 finally 触发 onSubmitted → 精确失效缓存
await super.submitChanges(failureMode);
}
}
// MallDataContext 同样重写 submitChanges,复用同一 recordDeleteHistory。
5.2 关键细节
- delete 快照:
deletes中的实体在内存仍含完整属性,serializeRow(e)直接拿到删除前状态,删后也能还原整行。 - 递归防护:历史记录本身是
EntityHistory实体,必须排除在toRecord之外,否则会无限递归。 - 序列化(复用
shapeOf):快照列直接复用src/common/data/cache/entity-shape.ts的shapeOf(e)(基于 alinqMetaType.persistentDataMembers去关联)取「持久非关联成员」,与实体缓存用同一套列集,映射一改三处(快照 / 缓存 / 映射)自动同步。务必保留外键标量列(categoryId/productId/articleId),只剥关联导航成员(category/skus/comments/author)——FK 标量是 §6.2 级联恢复子表的依据,一旦剥掉就无法按归属重建。避免手写JSON.parse(JSON.stringify(e)),后者会把Date等非标量序列化出问题且易带入循环引用。 - 原子性:历史记录
dc.add(...)进同一 pendingChanges,与原变更在同一submitChanges事务内提交;任一部分失败整体回滚。 - 与缓存
onSubmitted钩子衔接:历史写入(pre-commit,须在delete仍于 pendingChanges 时取完整状态)与缓存失效(post-commit,见data-context-cache-plan.md的onSubmitted钩子)是互补的两段。若按 §5.1 在StoreDataContext/MallDataContext重写submitChanges,同一批提交自然变成「写历史(前)→ 提交 →onSubmitted失效缓存(后)」,零额外包装。注意:EntityHistory不在cacheableEntities白名单,purge 定时任务硬删历史行触发onSubmitted时是 no-op,无需特殊处理。 - 不改 alinq 源码:拦截完全在项目层(
StoreDataContext/MallDataContext重写submitChanges)完成,不改 alinq.js(与已落地的 decimal 修复、onSubmitted钩子解耦,互不干扰)。
6. 还原机制
强制前置:唯一性检查(§6.1)——还原必须先通过唯一性检查,未通过不得写库。
6.1 唯一性检查(强制)
还原会把历史快照里的”业务唯一键”值写回主表,若该值已被同店其它现行记录占用,会造成业务键重复(商品编码撞车、博客标题重复等)。因此还原必须先做唯一性检查:
- 唯一性由实体标注唯一约束(类似 DB 唯一索引,单一事实来源):在实体(或其 fluent mapping)上声明唯一约束,约束可由单个字段或多个字段组合(复合唯一)构成,例如
Product标注unique: ['code'](单列)或unique: [['storeId','code']](复合)。这是唯一性的权威定义——既用于还原查重,也可(将来)据此生成 DB 复合唯一索引。约束的存储与读取无需修改 alinq.js:在 node-shop-v2 项目内,于实体类上加静态声明(如static uniqueConstraints: string[][]),由项目层还原逻辑读取查重即可;maishu-alinq 不感知。若未来希望”标注即生成数据库唯一索引”(强一致、防并发绕过),再单独评估是否扩展 alinq.js 的 sync / mapping(那会影响所有依赖它的项目)。不要在还原逻辑里另维护清单,始终以实体标注为准(见 §11-6)。 - 检查流程(还原 API 内、写库前执行):
- 遍历实体标注的每个唯一约束(一组字段);
- 取目标历史快照中该约束所有字段的值,在当前店铺上下文内,查主表”主键 ≠ 当前实体 id”且这些字段的组合值完全相同的记录;
- 任一约束命中 → 检查失败,返回冲突约束、冲突记录 id / 名称,HTTP 409;
- 若实体未标注任何唯一约束,则跳过检查。
- 多店隔离(无需还原代码显式处理):还原查重走
StoreDataContext/StoreScopedQueryable,数据访问层已给每条查询自动注入storeId条件,A 店与 B 店的同值互不冲突是随之而来的天然结果;还原逻辑里的查重where只需写业务字段,不要再显式拼storeId(显式条件反而可能与注入值不一致,徒增出错面)。 - 冲突处理(可并存):
- 阻止还原并提示”该
<字段>已被占用(记录 X)“,要求用户先处理占用项; - 或提供”重命名后还原”:对冲突字段自动加后缀(如
-restored-v{n})再写回。
- 阻止还原并提示”该
- DB 兜底:即便应用层漏检,若将来数据库加了唯一索引,提交时捕获唯一约束异常并转成友好错误(兜底,不作为主手段)。
6.2 删除还原(本期核心)
从历史表取被删记录的 delete 快照 → 整行写回主表(恢复被误删记录)→ 还原操作可记一笔审计(如操作日志或 action: "restore"),便于追溯。被删记录通过快照中保存的主键 id 重建;还原前必须过 §6.1 唯一性检查,且只能还原本账号(operatorId = 当前操作人)删除的记录,他人删除或 operatorId 为空的记录不可还原(见 §7「还原范围」)。
级联恢复关联子表:恢复 Product / BlogArticle 时,其关联从表(ProductSKU / ProductSpecification / BlogComment)中、外键指向该主表且同被删的记录也一并恢复(按 entityId / 外键归属从 entity_history 取对应 delete 快照重建)。即”删一棵,还原一棵”,主表与子表在同一还原事务内恢复。
- Date 字段复活(必做):
snapshot存进entity_history是 JSON,Date已转字符串。还原dc.add(Product, {...snapshot})传入的是字符串,而BaseEntity.onValidate→fillAuditColumns仅在createdAt == null时才补(entities.ts:43),不会覆盖已设值——字符串日期会原样写进 Date 列导致报错 / 存错类型。须复用缓存的dateKeys思路:按MetaType的dbType含time的成员(shapeOf已算好dateKeys)把字符串转回Date后再dc.add(见 §13.5)。 - 主键复用:还原复用原
id。本期实体均为 guid 主键(见entities.ts:1194id: guid()),复用原 id 安全;若以后纳入 DB 自增主键实体,需单独评估重新生成 id,不在本期范围。 - 子表唯一性:
ProductSKU/ProductSpecification/BlogComment若还原时也需 §6.1 唯一性检查,须各自声明uniqueConstraints(如ProductSKU的(storeId, productId, code));未声明则 §6.1 对它们直接跳过检查。 - 关联完整性(主外键关联,必做):还原一个实体时,必须保证其持有所有外键引用指向的行存在或可恢复,否则会产生悬空外键、数据关联不起来。这不止父子层级(
parentId自引用树),而是任意主外键关联——例如Product.categoryId → ProductCategory、ProductSKU.productId → Product、BlogComment.articleId → BlogArticle、Order.memberId → Member、ProductReview.productId → Product等,每一条 FK 都需满足。以分类树A → B → C(A为顶级父类,靠parentId自引用)为例:若删除顺序为C、B、A,还原C前必须先还原B(再还原A),否则C.parentId指向已删的B、链断——这只是「单一父引用」的特例;多外键实体(如订单同时引用会员 / 地址 / 店铺)需对每条 FK 重复同样校验。处理规则(对实体持有的每个外键引用fkField → Parent执行):- 还原前读取快照中该外键标量列的值;
- 若引用行当前存在(active)→ 外键天然满足,跳过;
- 若引用行已删但本账号可恢复(存在同
operatorId的delete历史且在保留期内)→ 先递归还原该引用行,使其关联重新打通;同一还原事务内完成; - 若引用行已永久删除(历史被清理 / 非本账号删除)→ 阻止还原,返回明确错误(如”关联记录 X(类型 Y)已永久删除,无法还原;请先恢复该关联或改选其它归属”),避免孤儿数据。
- 该规则与 §6.2「向下级联恢复子表」互补:向下保证 主→从 链路、向上(逐 FK)保证 从→主 链路,合起来才能完整还原一个被删对象及其全部主外键关联。
- 递归须记录已访问
id防环;同一还原请求内已恢复的引用不重复还原。FK 集合由实体MetaType关联元数据推导(复用 §5.2 的shapeOf同套映射),无需手写字段名;某实体若不持有任何外键引用则本项校验为空操作。
注:本期”还原”特指删除还原(把已删的整行恢复)。修改版本回滚、字段级还原、任意时间点还原不在本期,见 §9 P3。
6.3 字段级还原(后续扩展,非本期)
对比目标历史快照与当前行,仅覆盖用户勾选的字段;未勾选字段保持当前值。适合”只改错了某几个字段”的场景。需先有修改历史(insert/update 审计)才可实现,本期未记录。
6.4 任意时间点还原(后续扩展,非本期)
历史表按 operatedAt 记录,可检索 <= 时间点 的最新版本作为还原目标(时间旅行)。需先有修改历史,本期未记录。
6.5 其它冲突
- 并发:还原基于历史快照覆盖,可能覆盖他人同期修改;建议还原前二次确认,并写入操作日志。
- 外键:还原商品不影响其
categoryId指向(若分类已删,还原后列表仍按现状展示,不强制联动)。
7. API 设计(建议)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/product/deleted | 已删商品列表(回收站,分页;按 storeId 隔离) |
| GET | /api/product/deleted/:id | 某被删商品的删除前快照(供预览确认) |
| POST | /api/product/restore/:id | 删除还原:从 delete 快照整行恢复被删商品(须过 §6.1 唯一性检查) |
| GET | /api/blog/article/deleted | 同上(博客) |
| GET | /api/blog/article/deleted/:id | 同上 |
| POST | /api/blog/article/restore/:id | 同上 |
- 多店隔离:历史查询 / 还原走
StoreDataContext/StoreScopedQueryable,storeId条件由数据访问层自动注入,代码里无需(也不应)显式过滤(见 §6.1-3)。 - 权限与还原范围(operatorId 归属,即唯一访问控制):只能查看 / 还原本账号删除的记录——已删列表、快照预览与还原接口均按
operatorId = 当前操作人过滤 / 校验。归属校验本身就是授权:「能删除」已隐含「能还原自己的删除」,无需再叠加管理员 / 角色门禁。operatorId为空(定时任务等无上下文删除)的记录任何账号都不可还原;他人删除的记录不在回收站中出现,直接调还原接口返回 404(避免泄露他人删除记录的存在)。 - 操作人
operatorId:在写入拦截里从请求上下文(backoffice 会话 / 商户用户)取当前操作人;无上下文(如定时任务)留空(此类删除记录因此不可还原,见上一条)。
8. 前端设计
- 列表页 / 回收站:商品 / 博客列表加”回收站 / 已删”入口(Tab 或按钮),进入后列出可恢复的删除记录(含删除时间、操作人);仅显示本账号(当前操作人)删除的记录,他人删除的记录不出现;支持预览删除前内容、一键还原。
- 还原交互:还原为整行恢复,直接确认(含唯一性冲突提示);还原后提示成功并刷新列表。
- 历史版本时间线 / 字段级还原 / 时间点还原 UI:本期不做,列为后续(见 §9 P3);可预留入口位置。
- store 端 vs backoffice:商品 / 博客编辑主要在 store 端(商户后台);回收站与还原入口应放在商户可操作的位置,backoffice 仅做管理审计查看(可选)。
9. 实施步骤
- P0 数据层:
EntityHistory实体 + 映射(统一表);submitChanges历史拦截包装(对Product/BlogArticle及其关联从表ProductSKU/ProductSpecification/BlogComment的deletes记delete快照);版本号计算;迁移脚本(生产库建表 + 索引);name唯一约束标注(§11-6)。 - P1 删除还原 API + 回收站 UI:已删列表 / 删除前快照接口 +
restore接口(主表 + 关联子表级联恢复;均按operatorId限定本账号删除的记录,见 §11-7),前端回收站(列表 + 预览 + 还原 + 唯一性冲突提示);retention 提示(30 天)。 - P2 操作日志 + 唯一性冲突处理 + 定时清理:记录还原操作;唯一性冲突阻止 / 重命名还原(§6.1);实现 1 个月历史定期物理删除的清理任务(§4.3)。
- 注(2026-11-06):本条「操作日志」特指还原动作的业务留痕,仍依赖回收站 P1,尚未实现。通用的后台操作日志已由 backoffice 全局
OperationLogInterceptor独立落地,不等本方案。
- 注(2026-11-06):本条「操作日志」特指还原动作的业务留痕,仍依赖回收站 P1,尚未实现。通用的后台操作日志已由 backoffice 全局
- P3 后续扩展(非本期):修改版本回滚 / 字段级还原 / 任意时间点还原(需先记录 insert/update 历史);按需扩展到会员 / 订单等其它主表实体。
10. 风险与对策
| 风险 | 对策 |
|---|---|
历史表体积增长(商品 data JSON、博客 content 大文本) | 仅快照标量持久化列,排除导航属性;评估 retention 清理策略 |
| 还原时业务唯一键重复 | 唯一性由实体标注(类似唯一索引,§6.1);还原基于实体元数据读取唯一约束(可多字段组合)查重,storeId 限定由数据访问层自动注入(无需显式拼条件),冲突阻止 / 重命名还原 + DB 兜底 |
| 还原写回的外键 / 子表 | 删除还原仅恢复主表整行;其外键(如 categoryId)指向若已删,恢复后按现状展示不强制联动;子表(SKU / 评论)不在本期恢复范围(见风险表末行) |
递归记录 EntityHistory 自身 | 拦截显式排除 EntityHistory 类型 |
生产库 sync:false 不自动建表 | 提供增量迁移脚本(建表 + 索引),测试库走 sync:true |
| 关联子表还原一致性 | 本期已纳入 ProductSKU / ProductSpecification / BlogComment 的历史与级联还原;需确保主表与子表在同一还原事务内恢复、外键归属正确 |
与缓存 onSubmitted 钩子冲突 | 不冲突、互补:历史写入 pre-commit(删除前取完整状态),缓存失效 post-commit(见 data-context-cache-plan.md)。在 StoreDataContext / MallDataContext 重写 submitChanges 时,同一批「写历史 → 提交 → onSubmitted 失效缓存」自动衔接;EntityHistory 不在 cacheableEntities,purge 定时任务硬删历史触发 onSubmitted 为 no-op,无需特殊处理 |
| 还原 Date 字段类型丢失 | snapshot 经 JSON 存库后 Date 变字符串;还原前须按 shapeOf 的 dateKeys 将字符串转回 Date 再 dc.add,否则字符串日期原样写进 Date 列报错(fillAuditColumns 仅在 createdAt == null 时补,不会覆盖已设字符串值) |
11. 评审决策(已确认)
- 历史表形态:统一表
entity_history(不用分表)。本期仅记录delete(删除还原数据源);insert/update 全量审计暂不做。 - 软删除:不引入软删除。本期为纯审计快照,删除仍物理删、靠
delete快照还原。 - 关联表范围:默认纳入关联从表——
Product的ProductSKU/ProductSpecification,BlogArticle的BlogComment,与主表一并历史记录与级联还原(见 §3 / §6.2)。 - 操作人来源(沿用现有机制):
operatorId从请求上下文(backoffice 会话 / 商户 token)获取,无上下文留空;解析复用现有鉴权中间件。 - retention:历史保留 1 个月(30 天),由定时任务定期物理删除过期历史行(见 §4.3)。
- 实体唯一性标注(§6.1 依据):
Product的name唯一、BlogArticle的title唯一(注意BlogArticle无name字段,唯一列是其title!,见entities.ts:692)。鉴于多店隔离(StoreScopedEntity),落地均建议复合(storeId, name)/(storeId, title);以实体静态唯一约束声明(项目层,不改 alinq.js),还原查重据此进行。 - 还原范围仅限本账号删除的记录(§7「还原范围」):已删列表、快照预览、还原接口(含级联子表历史查询)均按
operatorId = 当前操作人过滤 / 校验;operatorId为空(无上下文删除)的记录任何账号不可还原。归属校验即唯一访问控制(能删除即能还原自己的删除),不再叠加角色门禁。
12. 关联资料
- 实体定义:
src/common/data/mall/entities.ts(Product:393、BlogArticle:691、StoreScopedEntity:70、BaseEntity:61) - 写入拦截点:
src/common/data/data-context-decorator.ts(createDataContext/createRequestDataContext)、maishu-alinqDataContext.submitChanges/getChangeSet(alinq.js/src/data-context.ts:744/:844)、DataContext.onSubmitted钩子(alinq.js/src/data-context.ts,post-commit,缓存失效用;与本病历拦截 pre-commit 互补) - 关联方案:实体缓存方案
doc/docs/architecture/data-context-cache-plan.md(shapeOf/EntityShape/EntityCache/onSubmitted缓存失效)——本病历快照序列化(§5.2)与还原 Date 复活(§6.2 / §13.5)均需复用其中的shapeOf/dateKeys,避免重复实现持久列集合 - 既有删除调用(后续还原需对照):
src/backoffice/controllers/product.controller.ts、src/backoffice/controllers/blog/blog.controller.ts
13. 实现参考骨架(示例代码,待评审后落地)
以下为方案落地前的参考骨架,非最终实现;路径 / 类名以项目实际为准。所有”标注”均在项目层,不改 alinq.js。
13.1 实体唯一约束标注(§11-6 决策:name 唯一)
约束以”字段数组”表达,天然支持单列与复合;落地取复合 (storeId, name)(多店隔离):
// src/common/data/mall/entities.ts
export class Product extends StoreScopedEntity {
@column() name!: string;
// …其它字段
/** 唯一约束(单列 name / 复合 storeId+name);决策见 §11-6,落地取复合 */
static uniqueConstraints: string[][] = [["storeId", "name"]];
}
export class BlogArticle extends StoreScopedEntity {
@column() title!: string; // 注意:BlogArticle 无 name 字段,唯一列是 title
static uniqueConstraints: string[][] = [["storeId", "title"]];
}
还原查重读取 Entity.uniqueConstraints;未标注则跳过检查(§6.1)。
13.2 还原查重(读取实体标注,§6.1 流程)
async function checkUniqueBeforeRestore(dc, Entity, snapshot, selfId) {
const constraints = (Entity as any).uniqueConstraints || [];
for (const fields of constraints) {
const where: any = { id: { "!=": selfId } };
for (const f of fields) where[f] = snapshot[f]; // 按约束的字段组合判重
// 不显式拼 storeId:dc 是 StoreDataContext,StoreScopedQueryable 已自动注入 storeId 条件(§6.1-3)
const conflict = await dc.firstOrDefault(Entity, where);
if (conflict) {
throw new ConflictError(
`字段 [${fields.join(",")}] 已被记录 "${conflict.name}" 占用`,
fields, conflict.id); // HTTP 409
}
}
}
13.3 历史表实体(精简,详见 §4.1)
export class EntityHistory extends StoreScopedEntity {
entityType!: string;
entityId!: string;
version!: number;
action!: "insert" | "update" | "delete"; // 本期仅写 "delete"
operatorId?: string | null;
operatedAt!: Date;
snapshot!: any; // 删除前完整行 JSON
}
13.4 submitChanges 历史拦截(删除留痕,详见 §5.1)
在 StoreDataContext / MallDataContext 两个封装类里重写 submitChanges(共用 §5.1 的 recordDeleteHistory),与缓存 onSubmitted 钩子同处一个类、自动衔接:
// src/common/data/mall/store-data-context.ts
export class StoreDataContext extends DataContext {
// ... 既有 getTable / onSubmitted(缓存失效,post-commit)...
/** 删除留痕:pre-commit 快照,趁 delete 仍在 pendingChanges 时取完整状态。 */
protected override async submitChanges(failureMode: ConflictMode = ConflictMode.FailOnFirstConflict) {
await recordDeleteHistory(this); // 写 EntityHistory(pre-commit,同事务)
await super.submitChanges(failureMode); // 提交后 super 内 finally 触发 onSubmitted → 缓存失效
}
}
// MallDataContext 同样重写,复用同一 recordDeleteHistory(完整定义见 §5.1)。
13.5 删除还原控制器(整行 + 级联子表 + 唯一性检查)
// POST /api/product/restore/:id
async function restore(req, res) {
const { id } = req.params;
const hist = await dc.firstOrDefault(EntityHistory,
{ entityType: "Product", entityId: id, action: "delete",
operatorId: currentOperatorId() }); // 只能还原本账号删除的记录(§7「还原范围」);
// operatorId 条件也把「无上下文删除(operatorId 为空)」一并挡掉,他人/系统删除一律查不到 → 404
if (!hist) return res.status(404).json({ message: "未找到删除记录或超过保留期" });
await checkUniqueBeforeRestore(dc, Product, hist.snapshot, id); // §13.2(storeId 由数据访问层自动注入)
// 关联完整性:还原前先确保本实体持有的所有外键引用存在/可恢复(主外键关联,见 §6.2「关联完整性」)
// 如 Product.categoryId → ProductCategory;分类树自身用 parentId 递归。引用行在同事务内先还原。
await restoreFkReferences(dc, Product, hist.snapshot, currentOperatorId());
// 复活 Date 字段:snapshot 经 JSON 存库后 Date 已变字符串,复用缓存模块的 shapeOf 取 dateKeys
const shape = shapeOf(Product); // 来自 src/common/data/cache/entity-shape
for (const k of shape.dateKeys) {
if (typeof hist.snapshot[k] === "string") hist.snapshot[k] = new Date(hist.snapshot[k]);
}
dc.add(Product, { ...hist.snapshot, id }); // 主表整行恢复(createdAt 等由 snapshot 保留,fillAuditColumns 不覆盖已设值)
for (const Sub of [ProductSKU, ProductSpecification]) { // 级联恢复关联子表
const subs = await dc.getList(EntityHistory,
{ entityType: Sub.name, action: "delete", operatorId: currentOperatorId() });
// 子表同样只还原本账号删除的记录(子表可能在不同批次被他人删除);storeId 由数据访问层自动注入
for (const s of subs.filter(s => s.snapshot.productId === id)) {
dc.add(Sub, { ...s.snapshot, id: s.entityId });
}
}
await dc.submitChanges();
return res.json({ ok: true });
}
/** 还原某实体持有的所有外键引用(每条 FK 递归向上),保证主外键关联不断。见 §6.2「关联完整性」。 */
async function restoreFkReferences(dc, Entity, snapshot, operatorId, visited = new Set<string>()) {
// fkReferencesOf 由实体 MetaType 的关联元数据推导(复用 §5.2 的 shapeOf 同套映射),
// 返回本实体持有的所有外键引用 [{ field, parentType }],无需手写字段名;无 FK 则返回空数组。
for (const { field, parentType: Parent } of fkReferencesOf(Entity)) {
const refId = (snapshot as Record<string, unknown>)[field];
if (refId == null || visited.has(String(refId))) continue;
visited.add(String(refId));
const exists = await dc.getTable(Parent).singleOrDefault("id = @id", { id: refId });
if (exists) continue; // 引用行已存在,外键天然满足
const pHist = await dc.firstOrDefault(EntityHistory,
{ entityType: Parent.name, entityId: refId, action: "delete", operatorId });
if (!pHist) throw new Error(`关联记录 ${Parent.name} ${refId} 已永久删除,无法还原依赖它的 ${Entity.name}`);
await restoreFkReferences(dc, Parent, pHist.snapshot, operatorId, visited); // 先还原更上层引用
const pShape = shapeOf(Parent);
for (const k of pShape.dateKeys) // 同样需复活 Date
if (typeof pHist.snapshot[k] === "string") pHist.snapshot[k] = new Date(pHist.snapshot[k]);
dc.add(Parent, { ...pHist.snapshot, id: pHist.entityId }); // 引用行先于当前实体写入(同事务)
}
}
13.6 定时清理任务(retention 硬删,见 §4.3)
// 每日凌晨:物理删除超保留期的历史行(清理任务自身不写历史,避免递归)
// 关联清理:按"删除血缘"成组——同一 storeId 内经 FK 相连的删除记录视为一条血缘,
// 仅当整条血缘中最年轻的 operatedAt 也超期才整体清理(见 §4.3「关联清理」)。
async function purgeOldHistory(dc) {
const cutoff = new Date(Date.now() - 30 * 24 * 3600 * 1000);
const expired = await dc.getList(EntityHistory, { operatedAt: { "<": cutoff } });
// 按血缘分组:同 storeId + 经任意 FK(parentId/categoryId/productId/memberId/articleId…)连通的删除记录归为一组
const lineages = groupByLineage(expired);
const toPurge = lineages
.filter(l => l.every(h => h.operatedAt < cutoff)) // 整条血缘都超期才清(最年轻者决定)
.flat();
await dc.deleteAllOnSubmit(toPurge); // 批量删除统一用 deleteAllOnSubmit(list)
await dc.submitChanges();
}
14. 通用审计字段(createdBy / updatedBy)——独立于回收站的全局审计
范围说明:本节是通用审计需求,与第 4–6 节的「删除还原 / 回收站归属」正交。
- 回收站归属用的是
EntityHistory.operatorId(历史表上的「谁删的」,见 §7);- 本节是「业务行上记录创建人 / 最后修改人」,作用于所有
BaseEntity派生实体。 两者数据来源都是currentOperatorId(),但落在不同表、服务不同目的,互不替代。本节能落地的前提之一(currentOperatorId()载体)也正好被回收站的 write 拦截复用(§5.1 / §13.5)。
14.1 字段定义(BaseEntity 层)
在 BaseEntity 增加两个可空列(所有派生实体自动继承,无需逐个改):
export class BaseEntity {
id!: string;
createdAt?: Date;
updatedAt?: Date | null;
sortNumber?: number | null;
createdBy?: string | null; // 新增:创建人(首次 insert 写入,之后不覆盖)
updatedBy?: string | null; // 新增:最后修改人(每次 update 刷新)
onValidate(action: ChangeAction) { fillAuditColumns(this, action); }
}
Store(extendsBaseEntity,无量纲)也会自动获得这两列;所有StoreScopedEntity子表同理。- 不按
storeId区分维度——createdBy是平台用户 id,跨店也能查「谁建的」。
14.2 「当前操作人」的获取(复用现有 AsyncLocalStorage 机制)
项目已有 storeIdStorage(src/common/data/store-id-storage.ts:AsyncLocalStorage<string>)等请求级载体;新增同款:
// src/common/data/operator-id-storage.ts
import { AsyncLocalStorage } from "async_hooks";
export const operatorIdStorage = new AsyncLocalStorage<string | null>();
/** 取当前请求的操作人 id;非请求上下文(定时任务等)返回 null。 */
export function currentOperatorId(): string | null {
return operatorIdStorage.getStore() ?? null;
}
注入时机:在解析出当前用户的那一层用 .run() 包裹后续链路(与 storeIdStorage.run(store.id, ...) 同范式):
- backoffice:现有
backoffice/start.ts:135已把user.id写入req.headers[headerNames.userId],在同一处改为operatorIdStorage.run(user.id, () => next())(或封装到 auth 中间件统一包裹)。 - store:在 store 登录态解析中间件里
operatorIdStorage.run(当前用户 id, () => next())。 - 定时任务 / 无请求上下文:
currentOperatorId()返回null,字段留空(与「operatorId为空不可还原」语义一致)。如希望系统自动创建的行可追溯,可注入哨兵值(如"system")。
注意:fillAuditColumns 在 submitChanges 内执行,必然处于上述 .run 的异步作用域内,故能直接读到——与 storeIdStorage 的取用方式完全一致,无需改动调用方。
14.3 fillAuditColumns 改造
基于现有实现(entities.ts:43)扩展,按 action 分别写入:
export function fillAuditColumns(entity: AuditableEntity, action: ChangeAction): void {
const operator = currentOperatorId();
if (action === ChangeAction.insert) {
if (entity.createdAt == null) entity.createdAt = new Date();
if (entity.sortNumber == null) entity.sortNumber = 0;
if (entity.createdBy == null) entity.createdBy = operator; // 只写一次
return;
}
if (action === ChangeAction.update) {
entity.updatedAt = new Date();
entity.updatedBy = operator; // 每次刷新
}
// delete:不触碰 createdBy / updatedBy(行即将被删,无「最后修改人」语义;回收站还原走 EntityHistory,与此无关)
}
要点:
createdBy仅在 insert 且为空时写入(与createdAt同策略,避免后续提交覆盖)。updatedBy每次 update 刷新(与updatedAt同策略)。delete动作不更新这两列。
14.4 数据库迁移
对所有继承 BaseEntity 的表新增两列(可空,TEXT / VARCHAR):
ALTER TABLE <each_BaseEntity_table> ADD COLUMN created_by TEXT NULL;
ALTER TABLE <each_BaseEntity_table> ADD COLUMN updated_by TEXT NULL;
可写一次性迁移脚本按实体清单批量执行;存量数据两列留 NULL(无历史来源)。建议与回收站所需的 entity_history 建表/索引放在同一迁移脚本,减少上线次数。
14.5 与 EntityHistory.operatorId 的关系(避免混淆)
| 维度 | 落点 | 含义 | 写入时机 |
|---|---|---|---|
BaseEntity.createdBy / updatedBy | 业务表 | 该行的创建人 / 最后修改人 | fillAuditColumns(insert / update) |
EntityHistory.operatorId | 历史表 | 触发「删除留痕」的操作人 | recordDeleteHistory(delete 拦截,§5.1) |
两者都来自 currentOperatorId(),但服务目的不同:前者是业务行的生命周期归属,后者是回收站「仅还原本人删除」的归属判据。还原接口仍按 EntityHistory.operatorId 过滤(§7),不依赖业务行的 createdBy。二者可独立演进、互不耦合。
14.6 是否纳入本期
- 与回收站(本期)正交,可单独排期。
- 建议:先随回收站一起落地
operatorIdStorage+currentOperatorId()(回收站 write 拦截已需要它),createdBy/updatedBy字段与迁移可放在其后作为「通用审计」小增量。 - 不在本期范围:字段的展示 / 查询 UI、按
createdBy的列表筛选。