返回文章列表
/ 架构

数据恢复:审计 / 时间旅行快照设计

用统一历史表记录变更,先交付「误删恢复」,架构上再扩展到其它实体。

审计数据恢复快照

状态:草案,待评审(暂不落地代码) 范围:首批覆盖 商品(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 自动建表 vs sync:false 需迁移脚本)。
  • 删除时内存实体仍含完整值:delete 进入 pendingChanges 的实体在内存中保留全部属性,可直接快照”删除前状态”,保证删后也能还原。

3. 方案概述

采用统一历史表 entity_history + JSON 行快照(已确认用统一表,见 §11-1;机制上为完整审计预留,本期落地聚焦”删除还原”):

  1. 新增一张统一历史表,拦截 submitChanges 时对被删的实体写入 action: "delete" 记录(含删除前完整行 JSON 快照、版本号、操作人、时间、店铺)。本期覆盖 Product / BlogArticle 及其直接关联从表 ProductSKU / ProductSpecification / BlogComment(关联表默认纳入,见 §11-3)。本期不记录 insert / update 历史。
  2. 拦截点包装 submitChanges:提交前读取变更集,仅对 deletes 中属于上述实体的记录生成历史,并把它一并 add 进同一 DataContext,使历史与原删除在 同一事务 内原子提交(删前先留痕,删后仍可恢复)。
  3. 还原(本期 = 删除还原):从历史表取被删记录的 delete 快照,整行写回主表恢复;其关联子表(SKU / Spec / Comment)随主表一并恢复(按外键归属重建,见 §6.2)。恢复前须通过 §6.1 唯一性检查。
  4. 前端:商品 / 博客列表提供”回收站 / 已删”入口,列出可恢复的删除记录,支持预览与一键还原。

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)(基于 alinq MetaType.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 唯一性检查(强制)

还原会把历史快照里的”业务唯一键”值写回主表,若该值已被同店其它现行记录占用,会造成业务键重复(商品编码撞车、博客标题重复等)。因此还原必须先做唯一性检查:

  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)。
  2. 检查流程(还原 API 内、写库前执行):
    • 遍历实体标注的每个唯一约束(一组字段);
    • 取目标历史快照中该约束所有字段的值,在当前店铺上下文内,查主表”主键 ≠ 当前实体 id”且这些字段的组合值完全相同的记录;
    • 任一约束命中 → 检查失败,返回冲突约束、冲突记录 id / 名称,HTTP 409;
    • 若实体未标注任何唯一约束,则跳过检查。
  3. 多店隔离(无需还原代码显式处理):还原查重走 StoreDataContext / StoreScopedQueryable,数据访问层已给每条查询自动注入 storeId 条件,A 店与 B 店的同值互不冲突是随之而来的天然结果;还原逻辑里的查重 where 只需写业务字段,不要再显式拼 storeId(显式条件反而可能与注入值不一致,徒增出错面)。
  4. 冲突处理(可并存):
    • 阻止还原并提示”该 <字段> 已被占用(记录 X)“,要求用户先处理占用项;
    • 或提供”重命名后还原”:对冲突字段自动加后缀(如 -restored-v{n})再写回。
  5. 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:1194 id: 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 执行):
    1. 还原前读取快照中该外键标量列的值;
    2. 若引用行当前存在(active)→ 外键天然满足,跳过;
    3. 若引用行已删但本账号可恢复(存在同 operatorId 的 delete 历史且在保留期内)→ 先递归还原该引用行,使其关联重新打通;同一还原事务内完成;
    4. 若引用行已永久删除(历史被清理 / 非本账号删除)→ 阻止还原,返回明确错误(如”关联记录 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 独立落地,不等本方案。
  • 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. 评审决策(已确认)

  1. 历史表形态:统一表 entity_history(不用分表)。本期仅记录 delete(删除还原数据源);insert/update 全量审计暂不做。
  2. 软删除:不引入软删除。本期为纯审计快照,删除仍物理删、靠 delete 快照还原。
  3. 关联表范围:默认纳入关联从表——Product 的 ProductSKU / ProductSpecification,BlogArticle 的 BlogComment,与主表一并历史记录与级联还原(见 §3 / §6.2)。
  4. 操作人来源(沿用现有机制):operatorId 从请求上下文(backoffice 会话 / 商户 token)获取,无上下文留空;解析复用现有鉴权中间件。
  5. retention:历史保留 1 个月(30 天),由定时任务定期物理删除过期历史行(见 §4.3)。
  6. 实体唯一性标注(§6.1 依据):Product 的 name 唯一、BlogArticle 的 title 唯一(注意 BlogArticle 无 name 字段,唯一列是其 title!,见 entities.ts:692)。鉴于多店隔离(StoreScopedEntity),落地均建议复合 (storeId, name) / (storeId, title);以实体静态唯一约束声明(项目层,不改 alinq.js),还原查重据此进行。
  7. 还原范围仅限本账号删除的记录(§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-alinq DataContext.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(extends BaseEntity,无量纲)也会自动获得这两列;所有 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 的列表筛选。