WeKan 通用变更历史(changeHistory):只追加变更存储、防篡改哈希链与 Restore/Undo/Redo 的完整实现
2026/9/14 18:17:00 网站建设 项目流程

WeKan 通用变更历史(changeHistory):只追加变更存储、防篡改哈希链与 Restore/Undo/Redo 的完整实现

【免费下载链接】wekanThe Open Source kanban, built with Meteor. GitHub issues/PRs are only for FLOSS Developers, not for support, support is at https://wekan.fi/commercial-support/ . PR source translation to imports/i18n/data/en.i18n.json, other translations at https://app.transifex.com/wekan/wekan项目地址: https://gitcode.com/GitHub_Trending/we/wekan

WeKan 的 History 功能为看板中每一次卡片、列表、泳道、检查项、评论与附件的变更建立一条只追加(append-only)的变更流水,并在此之上提供按行恢复(Restore)、Ctrl+Z/Ctrl+Y 撤销重做,以及用于识别数据库副本"哪份更新"的完整性哈希链。本文以设计文档 docs/Features/Reports/History/History.md 为主体,结合 models/changeHistory.js、server/models/changeHistoryHooks.js、server/models/changeHistory.js 等实际源码,讲清这条历史链从写入、读取、恢复到防篡改校验的完整实现路径。

一、功能定位与当前实现状态

History 的设计目标是:在卡片详情视图的每个分组菜单中加入History选项,点击后打开一个大型弹层——左栏固定展示最新一条历史与各贡献者头像(点击头像可过滤出该用户在本范围内的全部变更),右区为统一表格布局:表格上方依次是搜索框、分页控件、Restore 按钮;表格列依次为行选择复选框、"变更类型"(可翻译:added / removed / edited / moved / restored)、"变更内容"、"时间戳"(按卡片所选日期格式渲染)。服务端分页只加载当前页,RTL 界面下所有元素镜像排布。Restore 将选中变更恢复到该分组,且Restore 本身也会被记录进历史——同时记在原始变更者与被恢复操作者两人的名下;历史只追加,永远不可编辑。

从文档头部的状态栏与 models/changeHistory.js 的注释可以确认,功能已按 §10 的分阶段计划落地:存储、写侧钩子、读侧方法、恢复逻辑与 UI 均已实现,Ctrl+Z/Ctrl+Y已改读本存储,changeHistory也已被列入 snap 的MERGE_COLLECTIONS(见 snap-src/bin/database-choose.mjs 中'changeHistory', 'userPositionHistory'一项)。尚未上线的是 §9 的保留期(retention)cron、Member/Board 设置入口的菜单项(表格本身已支持这些 scope),以及"恢复一个被删除的子实体是否要重建实体"这一产品决策(附件已在 §12 中定为软删除方案,评论仍开放)。

二、核心障碍:为什么 Activities 不够用

WeKan 本来就有记录行为的Activities集合,但它记录的是"发生了什么"(activityTypecardIdlistIdmemberId等引用),不记录变更前后值:编辑描述只会记一条activityType: 'changedDescription',不存之前的文本。因此"查看者视图"(谁、何时、改了什么类型)大体可以从 Activities 搭出来,但Restore 不行——旧值没有被保存下来,无从恢复;全仓库只有userPositionHistory为位置变更存了 before/after。

所以该功能被拆成两部分:一个捕获每组变更前后内容的内容版本化子系统(Restore 的前置条件),以及历史查看器加恢复 UI。文档同时给出 v1 阶段否决过的替代方案:Activities上扩展 before/after 内容。被拒的理由是Activities刻意保持无 schema、高吞吐,并驱动通知与 webhook,在其中塞入内容字段会拖累这些路径;独立集合让关注点分离、可以独立做保留上限。

三、数据模型:append-only 的 changeHistory 集合

新增的唯一 Mongo 集合changeHistory覆盖所有实体的所有变更,一次变更一行,定义在 models/changeHistory.js。文档 §4 给出的核心字段与实现一一对应:

{ _id, boardId, // 权限范围与 publication 用;非看板变更可为 null swimlaneId, listId, cardId, // 容器 id 列:scope 因此只是一次等值查询 entityType, // 'card' | 'list' | 'swimlane' | 'board' | 'checklist' | 'checklistItem' // | 'comment' | 'attachment' | 'customField' entityId, // 被变更实体的 _id group, // 卡片详情视图的逻辑分组:'description' | 'labels' | 'members' | 'dates' // | 'checklists' | 'title' | ...(非卡片变更可选) changeType, // 'added' | 'removed' | 'edited' | 'moved' | 'restored' previousContent, // 黑盒;'added' 时为 null newContent, // 黑盒;'removed' 时为 null userId, // 变更者——Member 设置视图的过滤轴 createdAt, // Date,客户端按查看者/卡片日期格式渲染 undone, // Boolean——已被撤销,可重做直到被覆盖 undoneAt, // Date——排序重做栈 batchId, // 归组多实体变更(多选移动 / 多选恢复按一个整体撤销) restoredFromId, // 仅 changeType === 'restored' 时设置:内容来源行 restoredByUserId, // 谁执行了恢复 // 防篡改字段(见下一节): previousHash, // 前一行(同板)的 integrityHash integrityHash, // 本行受保护字段的 SHA-256 isCheckpoint, // 保留期检查点(预留) superseded, // 被新变更覆盖的撤销行标记 }

几个设计要点在源码中有直接体现:

  • 容器 id 全存,读侧免 join。models/changeHistory.js 的注释写明:每行携带它所在的全部容器 id,所以"泳道的历史"就是{ swimlaneId }一次等值查询,天然包含其下列表与卡片的行,不需要遍历树。这正是 models/lib/changeHistoryQuery.js 中SCOPE_COLUMN映射(board→boardId、swimlane→swimlaneId、list→listId、card→cardId)成立的前提。
  • append-only 不变式。集合不对客户端暴露 update/remove(内部undone/undoneAt翻转除外),保留上限将由服务端 cron 实现(复用userPositionHistory.cleanup的模式)。
  • previousContent/newContent是 blackbox。models/changeHistory.js 中两者均为blackbox: true:描述存{ text }、标签存{ labelId }、日期存毫秒、移动存{ sort, swimlaneId, listId, boardId }。实际实现中字段级差异统一存成{ field, value }形状(见 models/lib/changeHistoryGroups.js 的contentForField),因为恢复端按字段名读回,避免每个字段一个专用 applier。
  • 取代userPositionHistory。旧集合的移动行与本 schema 一一映射(entityType为 card/list/swimlane、changeType: 'moved'),撤销/重做方法已改为读changeHistory#6478的位置历史功能平移后继续工作。
  • 刻意不导入任何其他模型。models/changeHistory.js 的头部注释解释了这条"最重要实现教训":前作models/userPositionHistory.js导入了 Cards、Lists 等,导致这些文件无法再导入它,只能靠typeof UserPositionHistory !== 'undefined'守卫——而守卫为 false,于是位置历史多年静默失效(#6478)。本集合零依赖,任何变更路径都能正常导入它;"把变更写回实体"的职责放在 server/models/changeHistory.js,该文件可以随便导入模型,因为没有任何文件导入它

四、防篡改的撤销与重做:逐板哈希链

设计文档的 "Tamper-evident undo and redo" 一节定义了完整性机制,实现落在 models/lib/changeHistoryIntegrity.js:

  • 每行携带previousHashintegrityHash。后者是SHA-256(对受保护字段的规范化序列化 + 上一行哈希),形成逐板哈希链。受保护字段列表PROTECTED覆盖boardIdswimlaneIdlistIdcardIdentityTypeentityIdgroupchangeTypepreviousContentnewContentuserIdcreatedAtbatchIdrestoredFromIdrestoredByUserIdpreviousHash;可变栈状态(undoneundoneAt)被排除。改动内容、操作者、实体、板、时间、来源或链上任一环都会使该行失效。
  • 规范化器(canonicalizer)的行为在 models/lib/changeHistoryIntegrity.js 的canonical()中可见:对象键排序、数组保序、区分 null、Date(序列化为$date)、字符串、数字与布尔,并对非有限数字抛错、对不支持的类型拒绝。哈希实现是无依赖的同步 SHA-256(文件头注释说明原因:导入 Nodecrypto会把crypto-browserify及其 Node-only 依赖拉进客户端测试包,而 Web Crypto 是异步的,行必须在 insert 前完成哈希)。
  • 恢复/撤销/重做前先验链。server/models/changeHistory.js 的requireHistoryIntegrity逐行校验自身哈希、前驱行存在且唯一、"创世行"(previousHash为 null)每板唯一。校验失败时拒绝操作,写入 SecurityLog(history-integrity-failed进入 Problems → Security and Recovery),并通过RecoveryEvents.record(RecoveryEvents.types.HISTORY_INTEGRITY_FAILED, ...)记录低负载全链审计事件;绝不猜测或静默修复历史内容。
  • 并发追加者序列化链头。models/changeHistory.js 的record()在插入前按{ boardId, integrityHash: { $nin: [null, ''] } }取同板最新行作为previousHash,使两行有效记录不会无声地声称同一前驱;verifyHistoryRows(models/lib/changeHistoryIntegrity.js)则能检测predecessor-missinghistory-fork(两个子行引用同一父行)与multiple-history-roots
  • 新变更使重做分支作废而非删除旧行:删除会破坏可审计性与哈希链。models/changeHistory.js 中,非 restored 的新变更会把该用户同板undone: true的行标记superseded: true——保留行、链不断,但重做无法复活过期内容。保留期将来以检查点(isCheckpoint)替换过期前缀,检查点携带被移除前缀的哈希,而不是悄悄剪断链条。
  • 测试覆盖"变更每个受保护字段、删除/重排行、篡改前驱、以及有效 undo/redo 路径",确保完整性强制不会变成功能故障(对应tests/changeHistoryIntegrity.test.cjs等测试)。

五、写侧:集合钩子作为中央咽喉点

设计文档 §5 要求"在服务端、每一条变更路径上"记录 before/after,并建议评估"一个薄而集中的咽喉点"。最终实现选择了比逐 setter 埋点更进一步的形式:每个集合一个after.update钩子做字段 diff,外加子实体的 insert/remove 钩子,全部集中在 server/models/changeHistoryHooks.js。

钩子挂载范围(server/models/changeHistoryHooks.js 的Meteor.startup):

集合实体类型更新 diff生命周期(added/removed)
Cardscard无(移动与软删除自行记录)
Listslist无(同上,由列表软删除带batchId记录)
Swimlanesswimlane
Checklistschecklist
ChecklistItemschecklistItem
CardCommentscomment
Attachments.collectionattachment有(仅name字段)after.insert(软删除自记)

文件头注释给出了选钩子而非改二十个 setter 的核心理由——覆盖面:REST API、CSV/Trello 导入器与规则引擎都直接写集合、不经过客户端 setter,逐 setter 埋点会记录 UI 里改的描述、却悄悄漏掉 API 里改的同一条描述。

钩子内部的关键机制:

  • 定位容器locate(entityType, doc)(server/models/changeHistoryHooks.js)把每个实体解析为它所在的boardId/swimlaneId/listId/cardId;定位不到 board 的行"没有任何视图会显示",直接丢弃。附件经由meta.cardId找卡片,板级附件(背景图)以meta.boardId定位。
  • 字段到分组的映射表:models/lib/changeHistoryGroups.js 按实体类型给出字段→分组表(如dueAt→dateslabelIds→labelsarchived/deletedAt→lifecycle),未列出的字段不记录——modifiedAtdateLastActivitysort等簿记字段几乎每次写入都变,记满就是没人读的历史。NEVER_RECORD集合另加一层全局黑名单(_idsort、容器 id 列等)。
  • changeType 由取值行为决定diffFields(models/lib/changeHistoryGroups.js)按"空→有值 = added、有值→空 = removed、否则 = edited"归类,调用方无需自行判断;changed()还处理了钩子在值未变时也会触发的噪声问题。
  • batchId 归组:一次更新可同时触及多个分组(卡片表单一次保存),每个分组独立成行以便独立恢复,但共享batchId = edit-<id>-<ts>,撤销时整个保存作为一个单位回退。
  • 刻意不记的:卡片移动由Card.move自行记录为一条带完整前后位置的变更——按字段 diff 会把一次拖拽报成最多四条编辑(boardId、swimlaneId、listId、sort),既错在表格里也不可撤销;列表软删除/恢复由 server/models/lists.js 带batchId记录(把列表与其随删的卡片绑在一起)。
  • best-effort 契约ChangeHistory.record(models/changeHistory.js)吞掉一切异常,只console.warn——历史记录失败永远不允许弄失败它所描述的那次变更。恢复(restore)自身的写入通过isRecordingSuppressed()/withoutRecording()(server/lib/historyRecordingScope.js)抑制钩子重记,避免同一次写入留下editedrestored两条描述同一写入的行。

六、读侧:一个分页/可搜索的方法服务所有视图

文档 §6 的设计是一个方法(而非对整个日志做朴素 reactive publication)同时服务卡片分组视图、Member 设置视图和任意过滤组合:

Meteor.call('changeHistory.page', { scope, // 'board' | 'swimlane' | 'list' | 'card'——容器类型 scopeId, // 该容器的 _id group, // 把 card 范围收窄到卡片视图的一个分组 userId, // 单一贡献者——Member 视图,或其他 scope 内点击头像 search, // 匹配变更类型标签 + 内容文本 page, pageSize, // 1 起页码;服务端钳制 pageSize }) -> { rows, total, page, pageSize, contributors: [{ userId, count }] }

服务端把{scope, scopeId}转成 id 列过滤(纯函数scopeSelector,models/lib/changeHistoryQuery.js),再叠加userId/group/search。权限与实现细节在 server/models/changeHistory.js:

  • 权限:board scope 直接校验;其他 scope 先解析到所属板再校验"你能否看到这张板"——调用者点名一张看不到的板上的泳道时,回答(哪怕只是行数)不得泄露任何东西。查看他人Member 视图时,限定在调用者可见的板集合内(Boards.userBoardIds),使其永远不可能变成"给我看这个人做过的一切"。
  • 搜索在 JS 侧执行:内容黑盒没有固定形状,没有可索引的东西,Mongo 正则也靠不住;scope 选择器已把范围收窄到单卡/列表/泳道/板,随后用matchesSearch(大小写不敏感,匹配changeTypegroupentityTypepreviousContent/newContent的文本投影,含数字与布尔值)过滤。contentText有深度上限(4 层),病态黑盒不会挂住服务端。
  • 分页:复用共享pageInfo()(models/lib/tablePage.js,即 Table Page 设计 的分页器,文档明确"不要加第二个分页器")。默认每页 25 行(UI 侧PAGE_SIZE),服务端上限MAX_PAGE_SIZE = 200
  • contributors:对过滤后行集按userId计数并按数量降序返回,驱动卡片视图左栏的头像列表;视图已固定到某userId时该列表不显示。
  • 启动时为查询列建索引:{ boardId, createdAt: -1 }{ cardId, group, createdAt: -1 }{ listId, createdAt: -1 }{ swimlaneId, createdAt: -1 }{ userId, createdAt: -1 },以及撤销/重做栈用的{ userId, boardId, undone, createdAt: -1 }{ userId, boardId, undone, undoneAt: -1 }(server/models/changeHistory.js)。

七、UI:一套 historyTable,参数化 scope

文档 §7/§7a 的结论——每个非卡片分组面都是同一个historyTable配不同的 scope,只有一套实现,列、搜索、分页、RTL 与 Restore 共享;给一个新菜单加 History 的全部成本就是"一个带 scope 打开historyPopup的菜单项 + 两行处理器"——在 client/components/history/historyTable.js 中得到印证:文件头注释声明卡片分组菜单、整卡、Member 设置、Board 设置与泳道/列表菜单"全部渲染这同一个模板",且任何地方都没有第二份表格、搜索、分页或恢复代码

与文档对应的实现细节:

  • 左栏(historyNavshowContributors()仅当 scope 可能跨多个用户时渲染(Member 视图已是单人,不显示);点击头像设置userId过滤,语义是"该用户在本 scope 内的变更",而非替换 scope。
  • 状态存放searchpageuserId、选中行 id 等全部放在模板实例的ReactiveDict上,不放在 Blaze 数据上下文——这是 #6479 的教训:重渲染会丢掉写进数据上下文的临时字段,症状就是搜索框在输入时自己清空。选中行因 ReactiveDict 的 EJSON 克隆会丢 Set,用 id 数组保存。
  • 列渲染summarise(row)(client/components/history/historyTable.js)按写侧实际产生的形状渲染内容黑盒——{ field, value }字段 diff、移动的 position、创建/删除的整文档——并回退到可读文本而不是[object Object];空值渲染为 em dash(每种语言都读作"这里没有内容",且免去 197 个翻译文件加词)。
  • RTLisRtl()读取document.dir供模板镜像布局(搜索右、分页中、Restore 左,列序反转)。
  • 附件行的特殊控件attachmentForRow借助 publication 仍会下发软删除附件这一事实(见 §12)取得行对应的附件,渲染预览(openAttachmentSlideshow接受软删除附件)与下载链接;当附件当前处于删除态时渲染行内 Restore 按钮——调用与选中恢复同一个changeHistory.restore方法、同一 applier、同样的溯源行。行内控件永不渲染js-add-cover/js-add-background-image:只有卡片上存活附件才能当封面或板背景。

八、Restore:双份再记录与方向语义

changeHistory.restore(server/models/changeHistory.js)按文档 §8 的步骤执行:

  1. 读入选中行(selectionToIds归一化 Set/数组/对象三种输入、去重保序),要求 board 写权限(requireBoardWrite,comment-only 用户被拒)。
  2. 逐行验完整性requireHistoryIntegrity)。
  3. 先读当前值再应用currentContentOf(row)在应用前抓取实体当前内容,使追加的行描述的是"这次恢复实际做了什么"——否则追加行会复制被恢复行自身的 before/after,连续两次恢复后就成了错误的记录。
  4. 通过与普通编辑相同的 setter 写回:applier 从不做裸 selector 写入(WeKan 的 Activities 本身由after.update集合钩子生成,走集合写入即触发同样的钩子、校验与活动记录;.direct会跳过这一切,因而从不使用)。卡片位置(group: 'position')走card.move(...)整条回退而非四连写;附件生命周期行走restoreAttachment/softDeleteAttachment(与卡片自己的 Delete 同一条路,activity 与镜像历史行同样写出),封面绝不触碰。
  5. 双份再记录:追加两条changeType: 'restored'行——一条记在原始变更者名下(是"他的"数据被恢复了),一条记在恢复操作者Meteor.userId())名下(谁恢复了什么使之成为当前值);两条都带restoredFromIdrestoredByUserId,溯源在行滚出页面后仍存活。若行主就是操作者本人则只记一条。
  6. 多选恢复按选中行从旧到新顺序应用,共享一个restore-<ts>-<userId>batchId,使多选恢复作为单个逻辑变更被撤销。
  7. 历史行本身从不被恢复操作编辑或删除。

方向语义是该功能里一个值得记住的修正,contentForDirection(models/lib/changeHistoryGroups.js)注释把它讲透了:undopreviousContentredonewContent,而restorenewContent——因为表格"内容"列展示的就是行的newContent,读者选中的行和拿到的值必须是同一个东西。旧实现让 Restore 跑 'undo',选中标题为你想要描述的恢复行,拿到的却是它之前那条;无newContent的行(removed 行)恢复时回退到previousContent,即"恢复一个删除 = 放回它删掉的东西"。Undo(Ctrl+Z)与 Restore 是不同操作,只在选中行恰好是最后一条时才看起来一样。

九、Undo/Redo:从位置历史推广到全部记录变更

Ctrl+Z/Ctrl+Y是这个历史的键盘前端,绑定在 client/lib/keyboard.js(undoRedoLast('changeHistory.undoLast')/changeHistory.redoLast),语义为当前用户、当前板

  • Undo= 恢复调用者最近一条尚未撤销的changeHistory行(标记undone),适用于任意entityType/changeType,不限于移动。
  • Redo= 重新应用调用者最近被撤销的行。
  • 新变更清空重做栈——按前文 §4 的机制以superseded标记而非删除。

服务端实现(server/models/changeHistory.js)取最近 50 条候选后交给纯函数pickUndo/pickRedo(models/lib/undoRedoSelection.js)挑行——选择规则不依赖 Meteor/Blaze 运行时,可直接单测。这套方法取代了 #6478 的userPositionHistory.undoLast/redoLast:同样的快捷键、同样的选择规则,但作用于统一存储上的所有记录变更。文档特别强调:Undo 经由与普通编辑相同的 setter恢复内容(校验与 Activities 照常运行),且恢复本身被追加进历史——所以撤销可审计、且自身也可撤销/重做。

十、append-only 使 snap 的双数据库副本可合并

设计文档 §9a 解释了为什么"只追加"不仅是审计洁癖——snap 现在依赖它。WeKan snap 可能同时持有两份都已写入的数据副本:MongoDB 到 FerretDB 的迁移是快照、没有同步机制,snap revert不回滚$SNAP_COMMON,两个库可能被交替写入。哪份被服务决定用户看到什么,选错看起来就是数据丢失。

文件时间戳回答不了"哪份副本保有工作"(mtime 只说文件何时被动过,启动数据库就会触碰文件);数据本身能回答——历史是数据里最能回答的部分:用户的每次变更都写一行,最新历史行就是两侧中最近真正在工作的时刻。snap-src/bin/db-eval.mjs evidence读取的正是这个(每集合行数加任何文档携带的最新时间戳),由 snap-src/bin/database-choose.mjs 比较两者。

另一份副本不丢,靠的就是 APPEND-ONLY:一份的行可以插入另一份而不与已有内容矛盾。snap 服务持有较新工作的副本,并把所有_id缺席的文档拷入(database-merge-missing.mjs)——已存在的文档绝不覆盖(两侧都编辑过的卡片,较新版本保持);两侧都不删除,未选中的副本留在磁盘上,snap set一次即可切回;另一份上写入的活动、评论与(本设计落地后)changeHistory行成为被服务副本历史的一部分——那些工作在卡片历史里可读,而不是搁浅在没人打开的数据库里。

脚本刻意不做的是三方合并(调和两处对同一字段的编辑)——那是对某人工作的决定,不该由脚本做;两份副本无法区分时(最新时刻相差数小时内,或都没有时间戳),snap 什么都不改并如实说明。

本设计对 snap 的债务(实现均已满足):

  1. 每行_id稳定且永不复用,"按_id缺席"是"此行不在"的安全判据;
  2. 行插入后保持不可变(行若可原地更新,两份副本就会对它产生分歧,合并被迫二选一);
  3. 保留 cron 按年龄剪枝、不改写行——被剪副本与完整副本合并后仍是完整副本,而非矛盾;
  4. changeHistory已列入 snap-src/bin/database-choose.mjs 的MERGE_COLLECTIONS——列表是 snap 得知哪些集合承载历史的唯一地方。

十一、附件:软删除、历史、恢复、清除(§12)

这一节是"规则而非提案"(维护者,2026-09),每条由tests/attachmentSoftDelete*.test.cjs下的测试钉住:

  • 从卡片删除附件是软删除。"删除"调用attachments.softDelete,在附件文档上置deletedAtdeletedBydeleteBatchId(与列表同款的三个字段和同款 models/lib/softDelete.js 助手),文件保留,任何存储后端都不删字节。若被删附件是卡片封面,软删除会取消封面;Restore不会恢复它——封面是对存活附件做出的选择,恢复者可以重做。
  • 软删除附件在一切画卡片的地方不可见:打开的卡片图库、"Attachments (N)" 计数、minicard 回形针徽章、幻灯片翻页、封面选择器、板背景选择器、API 列表端点、导出器、My Attachments 页——全部读同一 live 过滤({ deletedAt: null },即notDeleted()/liveAttachments()),该过滤匹配字段缺席的文档,无需回填。而publication 仍向客户端下发软删除附件,因为卡片历史必须够得到它们(历史行要能预览与恢复)。deleteAttachmentactivity 照常写入(actor 规则与以前相同,#5504),活动流与 webhook 看到的删除行为不变。
  • 卡片历史显示谁删了它,并能恢复。软删除写一条changeHistory行:entityType: 'attachment'group: 'lifecycle'changeType: 'removed'userId为删除者;内容携带文件名{ name, deleted }),行可读作 "Removed · photo.png",摘要无需回查。行以previousContent: { deleted: false, name }newContent写出——按 §8 语义 Restore 应用行所显示的东西,删除行显示它删掉的,故恢复删除行即带回附件;恢复再记镜像行(added)加一对通用restored行。上传与重命名同样被记录(addednameedited),经由同一个钩子咽喉点,经meta.cardId定位到卡片。Restore通过restoreModifier()清掉三个簿记字段;卡片、计数与徽章因读 live 过滤而即时包含它;恢复已存活附件是 no-op。
  • 不存在单附件硬删除。板成员、板管理员、全局管理员都不能从卡片、板、Files 报告、REST API 或 DDP API 永久删除单个附件:每条路径现在是软删除(api.attachment.deleteDELETE /api/attachment/delete/:idDELETE /api/boards/:boardId/attachments/:attachmentIdremoveBoardBackground)或已移除(Files 报告的永久删除按钮)。REST 用POST /api/boards/:boardId/attachments/:attachmentId/restore恢复、GET /api/boards/:boardId/attachments/deleted列出可恢复项,走的正是卡片与历史同款的attachments.softDelete/attachments.restore方法。客户端根本无法删除附件文档:Attachments.allow({ remove })返回 false,onBeforeRemove拒绝一切_FilesCollectionRemove_attachments调用,并作为尝试记入 Admin Panel → Problems。剩余的两类removeAsync不是删用户附件:models/attachments.server.js 中上传被拒(可疑文件名、尺寸/MIME 校验失败)丢弃的字节从未成为附件,attachmentBulkMove.js 的 CollectionFS 导出转换存储格式但保留文件;有读源码的测试列出这些调用,出现其他调用即失败。
  • 唯一的硬删除:Admin Panel / Problems / Delete 中开启Enable permanent deleteenablePermanentDelete)后,全局管理员可归档板并从归档中删除(permanentlyDeleteArchivedBoards)。删板移除该板meta.boardId下的所有附件——存活与软删除的——连同存储后端的文件,经由boardRemover;这是附件字节唯一会被移除的地方,并与板删除本身一样记录在 Recovery 中。

十二、分期路线、安全边界与遗留问题

文档 §10 的分阶段计划(每阶段在下一阶段前先经实机验证):1)changeHistory模型 + 写助手 + 纯助手(分页/搜索/选择/选撤销-重做)+ 单测,把已发布的位置撤销/重做迁移上来;2) 第一个内容分组 Description:记录前后值、读方法、查看器 UI(表格/搜索/分页/头像),先 LTR 后 RTL;3) Description 的 Restore(单选+多选)与双份再记录;4) Member 设置与 Board 设置的 History 视图(同一方法 + 同一 UI 换 scope);5) 铺开到其余全部分组/实体(标题、标签、日期、成员/被指派人、检查表、子任务、附件、评论、自定义字段、板/泳道结构变更),一个 PR 一个;6) 把 "History" 菜单项接入所有卡片分组菜单。从当前代码看,写侧第 5 阶段(server/models/changeHistoryHooks.js 自述 "Phase 5 ... record EVERY remaining group, from one place")与 UI 统一模板(第 6 阶段)均已就位;尚未落地的是 §9 的保留 cron 与 Member/Board 设置菜单入口。

安全边界(§9)在代码中的对应:所有读/恢复方法以板访问为准入(requireBoardVisible/写权限,与updateListSortuserPositionHistory.*同款);changeHistory不对客户端暴露 update/delete(append-only 不变式);§11 的开放问题中,附件恢复的"重建"问题已在 §12 定案(软删除因此恢复无需重建任何东西),评论的重建、保留行数上限、历史是否跨卡片归档/跨板移动存活、以及按板开关(存储成本)仍是待决产品问题。

文档附录沉淀的三条工程教训,值得任何往"跨模块共享集合"上用力的人记住:导入你的集合助手userPositionHistorytrackChangetypeof … !== 'undefined'守卫,却从未导入集合,记录多年静默失效,#6478 修复);不要把状态藏在 Blaze 数据上下文上(#6479:重渲染丢掉临时字段,弹窗/表格状态放模板实例/ReactiveDict);分页/搜索/选择写成纯函数(如 models/lib/undoRedoSelection.js、models/lib/changeHistoryQuery.js),使逻辑不依赖 Meteor/Blaze 运行时即可单测。

十三、测试与可核验入口

整条链路有对应测试覆盖,可作为深入阅读的起点:

关注点测试/源码
scope/搜索/选择纯函数tests/changeHistoryQuery.test.cjs
字段→分组表与 difftests/changeHistoryGroups.test.cjs
钩子接线(覆盖、排除项)tests/changeHistoryWiring.test.cjs
单模板约束(唯一 historyTable)tests/historyOneTemplate.test.cjs
Restore 展示即所得tests/historyRestoreAppliesWhatIsShown.test.cjs
完整性哈希与链校验tests/changeHistoryIntegrity.test.cjs、tests/changeHistoryIntegrity.test.cjs 同族
撤销记录所声称的内容tests/undoRecordsWhatItClaims.test.cjs
附件软删除规则(§12 逐条钉住)tests/attachmentSoftDelete.test.cjs、tests/attachmentSoftDeleteNoHardDelete.test.cjs、tests/attachmentSoftDeleteReads.test.cjs、tests/attachmentHistoryRowControls.test.cjs

i18n 侧,变更类型是带 i18n 键的小闭集:added复用既有键,history-change-removedhistory-change-movedhistory-change-restored等仅加在 imports/i18n/data/en.i18n.json,其余语言经 Transifex 跟进。布局、搜索、分页与 RTL 规则复用 Table Page 设计,本功能只定义其特有部分:存储、scope 与恢复。

【免费下载链接】wekanThe Open Source kanban, built with Meteor. GitHub issues/PRs are only for FLOSS Developers, not for support, support is at https://wekan.fi/commercial-support/ . PR source translation to imports/i18n/data/en.i18n.json, other translations at https://app.transifex.com/wekan/wekan项目地址: https://gitcode.com/GitHub_Trending/we/wekan

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询