ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Wekan 按用户隔离的数据存储架构:UI 状态校验、位置历史与泳道数据修复的源码级实现

2026/9/14 2:39:02 拓冰建站 浏览量
Wekan 按用户隔离的数据存储架构:UI 状态校验、位置历史与泳道数据修复的源码级实现 Wekan 按用户隔离的数据存储架构UI 状态校验、位置历史与泳道数据修复的源码级实现【免费下载链接】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 仓库中的 IMPLEMENTATION_SUMMARY.md“按用户隔离的持久化审计”实施总结展开系统讲解四项核心改造每用户 UI 状态折叠态、列宽、泳道高度的存储模型、localStorage 数据校验与自动清理、UserPositionHistory位置历史与 undo/redo 机制、以及swimlaneId完整性迁移。读完本文你可以理解这些状态“存在哪里、如何校验、如何隔离”并能对照仓库源码验证其调用链、索引、安全边界与迁移行为。1. 改造目标把“看板级”UI 状态降级为“用户级”在多人协作的看板应用中折叠某个泳道、拖宽某一列、调整泳道高度本质上是查看者个人的视图偏好而非看板数据的一部分。如果把这些状态写进看板/列/泳道文档本身就会出现“我折叠的列别人也看不见”的互相干扰。该文档描述的整体方案是已登录用户UI 状态写入用户文档的profile字段随账号跨设备同步未登录用户UI 状态写入浏览器localStorage并通过校验器防止脏数据、控制体积原来挂在泳道/列上的collapsed字段与collapse()变更方法被移除仅保留兼容读取。这一点在模型代码中有直接注释佐证models/swimlanes.js 第 547–548 行写着“collapse() removed - collapsed state is per-user only, Use user.setCollapsedSwimlane(boardId, swimlaneId, collapsed) instead”models/lists.js 第 283 行同样标注“collapsed state is per-user only, stored in user profile.collapsedLists”。1.1 用户 profile 中的状态字段从 models/users.js 的 schema 定义看以下 profile 子字段均采用profile[boardId][itemId] value的两层映射结构均为blackbox类型默认空对象profile 字段值类型含义profile.listWidths数字每个列宽按 boardId → listIdprofile.swimlaneHeights数字每个泳道高度profile.collapsedLists布尔列的折叠态profile.collapsedSwimlanes布尔泳道的折叠态profile.collapsedCards布尔整张 minicard 的折叠态profile.collapsedCardSections布尔卡片内各 section/清单的折叠态profile.listConstraints/profile.autoWidthBoards/profile.fixedListWidthBoards/profile.fixedListWidths混合列宽约束与“同宽模式”开关模型层提供了一组读写方法例如setCollapsedList(boardId, listId, collapsed)与setCollapsedSwimlane(boardId, swimlaneId, collapsed)models/users.js 约 2703–2721 行通过Users.updateAsync仅$set对应 profile 子路径读取侧约 2234–2243 行在取值时会做typeof boolean的类型检查类型不符即视为未设置。对未登录用户同类方法如setCollapsedListToStorage约 2282 行则改走 localStorage 路径。1.2 旧字段与兼容性文档明确声明的向后兼容策略是存量泳道/列文档上的旧collapsed字段值被忽略不再驱动渲染每用户折叠态优先缺失swimlaneId的卡片自动补全孤立卡片迁入救援泳道迁移过程不丢数据。2. LocalStorage 校验系统client/lib/localStorageValidator.js未登录场景下所有 per-user UI 偏好都落在浏览器localStorage里。由于 localStorage 内容完全由客户端掌控可能被篡改、损坏或无限增长Wekan 增加了一个独立的校验与清理模块 client/lib/localStorageValidator.js并在 client/imports.js 第 62 行通过import /client/lib/localStorageValidator;全局引入。2.1 容量与保留策略常量// Maximum age for localStorage data (90 天) const MAX_AGE_MS 90 * 24 * 60 * 60 * 1000; // Maximum number of boards to keep per storage key const MAX_BOARDS_PER_KEY 50; // Maximum number of items per board const MAX_ITEMS_PER_BOARD 100;即每个存储键最多保留50 个看板、每个看板最多100 个条目超出部分在清理时直接丢弃——这与文档“Performance Considerations”一节给出的上限一致。2.2 五个受管存储键及其校验规则validateAndCleanLocalStorage()约 180 行依次处理五个键localStorage 键校验函数合法值规则wekan-swimlane-heightsvalidateSwimlaneHeights-1自动或 50–2000 像素wekan-list-widthsvalidateListWidths100–1000 像素wekan-list-constraintsvalidateListWidths同上wekan-collapsed-listsvalidateCollapsedStates严格布尔值wekan-collapsed-swimlanesvalidateCollapsedStates严格布尔值数值校验由isValidNumber(value, min, max)完成必须是number、非NaN、有限值且落在闭区间内布尔校验isValidBoolean要求typeof value boolean。注意文档中“Invalid data rejected, not sanitized”的原则在这里得到落实不合法的值直接被丢弃而不是尝试纠正整个 JSON 解析失败时validateAndCleanKey会捕获异常并localStorage.removeItem(key)即损坏数据整体清除。2.3 每日一次的全量清理清理不是每次启动都跑而是由shouldRunCleanup()约 210 行根据键wekan-last-cleanup记录的时间戳判断——距上次清理超过 24 小时才执行。模块加载时的自启动逻辑if (Meteor.isClient) { Meteor.startup(() { if (shouldRunCleanup()) { validateAndCleanLocalStorage(); } }); }这里也对应了文档“Bug Fixes Applied”第 3 条校验器必须有客户端守卫Meteor.isClient与typeof localStorage undefined双重判断和条件式的Meteor.startup调用否则在服务端打包环境中会报错。模块对外还导出了四个可复用函数与一组校验器validateAndCleanLocalStorage()、shouldRunCleanup()、getValidatedLocalStorageData(key, validator)、setValidatedLocalStorageData(key, data, validator)以及validators对象含swimlaneHeights、listWidths、collapsedStates、isValidNumber、isValidBoolean。用户模型的 localStorage 读写正是通过这组 API 进行的——例如 models/users.js 约 2093–2132 行读取列宽时调用getValidatedLocalStorageData(wekan-list-widths, validators.listWidths)写回时调用setValidatedLocalStorageData(...)保证“读必校验、写必校验”。2.4 带上下界的原子读写原语models/lib/userStorageHelpers.js 提供另一层更细粒度的辅助函数getValidatedNumber(key, boardId, itemId, defaultValue, min, max)/setValidatedNumber(...)/getValidatedBoolean(...)/setValidatedBoolean(...)。写入前逐项做typeof、NaN、isFinite与区间检查非法值打印console.warn并返回false绝不落盘读取时若键缺失、类型不符或越界则回退到defaultValue。从源码结构看它面向的是“按单个 boardId itemId 读写一个值”的场景与 validator 模块“全量键清洗”的职责互补。一个值得注意的源码细节该文件的isValidNumber第 13 行写的是if (value min || value max) return false;上界判断是而非这与 client/lib/localStorageValidator.js 中同名函数的边界行为不一致阅读或复用这个辅助函数时需要留意其实际判定范围。3. 每用户位置历史UserPositionHistory这是整个改造中最重的部分为每个用户的实体泳道、列、卡片、清单、清单项位置变化建立一份按用户隔离的历史记录支撑 undo/redo 与检查点。3.1 集合定义与 Schemamodels/userPositionHistory.jsmodels/userPositionHistory.js 定义了new Mongo.Collection(userPositionHistory)并挂载 SimpleSchema。核心字段userId/boardId/entityId定位“谁在哪个看板动了哪个实体”entityType允许值[swimlane, list, card, checklist, checklistItem]actionType允许值[move, create, delete, restore, archive]previousState可选 blackbox/newStateblackbox变更前后的完整状态为加速 undo 而扁平化的字段previousSort/newSort、previousSwimlaneId/newSwimlaneId、previousListId/newListId、previousBoardId/newBoardId检查点支持isCheckpoint默认false与checkpointName批量分组batchId用于把一次关联操作如移动多张卡片归为一组redo 支持源码注释标注为 #6478undone默认false与undoneAt——“一个变更被撤销后可以被重做直到产生新的变更记录新记录会清空 redo 栈undoneAt决定 redo 顺序”。文档“API Methods Added”中列出的方法名在集合文件中没有定义它们位于服务端文件见 3.3 节集合文件本身提供的是文档级行为getDescription()生成人类可读描述如move card to different listcanUndo()按 entityType 分别检查实体是否仍存在于ReactiveCache卡片/列/泳道/清单或ChecklistItems实体已被删除则不可撤销undo()/redo()分别恢复previous*/new*字段。对卡片 undo 时源码特意在执行时二次校验目标看板约 261–279 行“History documents are writable from DDP. Re-check the destination at execution time so a forged or stale entry cannot cross board bounds”——即伪造或过期的历史条目无法把卡片搬到自己无权访问的看板越界尝试会触发tripCanary(history.cross-board, ...)告警并抛出not-authorized_applyDeleted(deleted)配合 Wekan 的软删除机制源码注释指向 docs/Features/Undo/Undo.mddelete的 undo 是恢复列表及其级联卡片清除deletedAt/deletedBy/deleteBatchIdrestore的 undo 是重新标记删除batchId作为规范的分组标识保证反复 undo/redo 周期的一致性。v1 阶段仅对 list含其卡片生效其他实体类型的删除路径尚未软化。3.2 变更是如何被记录的卡片 move 的调用链文档说“Enhancedmove()method to track changes / Automatic UserPositionHistory entry creation”实际代码在 models/cards.js 约 3055–3100 行卡片move()完成Cards.updateAsync后在服务端且有Meteor.userId()时先调用recordCardChange(...)写入另一套历史注释说明是过渡期“双写”CtrlZ 现在读的是新存储再懒加载const UserPositionHistory require(/models/userPositionHistory).default;并调用trackChange({...})。这段注释还记录了一个真实的 Bug 修复对应文档“Bug Fixes Applied”第 2 条的延伸旧的守卫是裸标识符typeof UserPositionHistory ! undefined该文件从未导入它守卫永远为 false导致卡片移动从未被记录由于models/userPositionHistory反过来导入了本文件顶层 import 会构成循环依赖因此必须采用调用处的懒加载require。try/catch包裹保证“历史写失败绝不阻塞移动本身”失败仅console.warn。类似的trackChange调用点还有列表侧server/models/lists.js 第 55、130、673 行列表删除/移动等场景。3.3 服务端实现索引、Meteor 方法与容量治理server/models/userPositionHistory.jsserver/models/userPositionHistory.js 负责方法层与数据治理其中索引定义与文档“Database Indexes”一节完全对应第 21–25 行经ensureIndex建索引await ensureIndex(UserPositionHistory, { userId: 1, boardId: 1, createdAt: -1 }); await ensureIndex(UserPositionHistory, { userId: 1, entityType: 1, entityId: 1 }); await ensureIndex(UserPositionHistory, { userId: 1, isCheckpoint: 1 }); await ensureIndex(UserPositionHistory, { batchId: 1 }); await ensureIndex(UserPositionHistory, { createdAt: 1 });trackChange第 28 行起static 挂载负责插入历史并在同一次调用中做容量裁剪按{ userId, boardId, isCheckpoint: { $ne: true } }取最近 1000 条以第 1000 条的createdAt为界删除更旧的非检查点记录——这正是文档“Max 1000 entries per user per board / Checkpoints never deleted / Old entries auto-deleted”三条规则的实现。另有一个Meteor.setInterval(..., 24 * 60 * 60 * 1000)的每日兜底清理任务定期调用UserPositionHistory.cleanup()。文档列出的方法清单当前实现均为async且带check(...)参数校验与登录校验实际签名如下Meteor.methods({ async userPositionHistory.createCheckpoint(boardId, checkpointName), async userPositionHistory.undo(historyId), async userPositionHistory.undoLast(boardId), async userPositionHistory.redoLast(boardId), async userPositionHistory.getRecent(boardId, limit 50), async userPositionHistory.getCheckpoints(boardId), async userPositionHistory.restoreToCheckpoint(checkpointId), });几个与文档一致的实现细节所有方法首先if (!this.userId) throw new Meteor.Error(not-authorized, Must be logged in)随后requireBoardVisible(this.userId, boardId)验证看板成员资格——对应文档“Authorization: Board membership verified / Cannot modify other users history”getRecent查询带limit: Math.min(limit, 100)即文档“Limited to 100 results maximum”undoLast取“最近一条未被撤销undone: { $ne: true }”的非检查点记录redoLast取“最近被撤销undone: true”的记录——实现 undo/redo 双栈语义restoreToCheckpoint将检查点之后的变更按序重放createdAt: { $gt: checkpoint.createdAt }。4. swimlaneId 完整性迁移server/migrations/ensureValidSwimlaneIds.jsserver/migrations/ensureValidSwimlaneIds.js 保证“每张卡片都指向一个真实、未归档、属于同一看板的泳道”。当前源码的编排方式与文档略有演进需以源码为准迁移名/版本MIGRATION_NAME ensure-valid-swimlane-idsMIGRATION_VERSION 1文件头部的Migrations集合定义第 19 行被特意放在最前面源码注释“must be defined first”对应文档 Bug Fix 第 1 条早期定义顺序问题曾导致启动时报 “Migrations.findOne is not a function”数据修复部分不再随启动自动执行源码中大段Meteor.startup自动运行代码已被注释标注“DISABLED: This migration now runs from Admin Panel / Cron / Run All Migrations”。取而代之的是导出的runEnsureValidSwimlaneIdsMigration()完成时向migrations集合 upsert{ name, version, completedAt, results: { cardsFixed, listsFixed, cardsRescued } }重复调用会检测到版本已完成而直接返回。启动时仍然自动执行的只有一件事安装校验钩子第 336–345 行日志打印 “SwimlaneId validation hooks installed”。4.1 三类数据修复fixCardsWithoutSwimlaneId()查出swimlaneId缺失、为null或空串的卡片将其补到该看板“第一个可见泳道”type ! template-swimlane且archived: false按sort升序若看板完全没有泳道则先创建一个标题为Default、sort: 0的泳道。fixListsWithoutSwimlaneId()列的swimlaneId缺失时置为空串——源码注释解释这是向后兼容语义列可以跨泳道共享空串即“不限定泳道”。rescueOrphanedCards()swimlaneId指向已不存在泳道的卡片被“救援”getOrCreateRescuedSwimlane(boardId)会查找标题匹配/rescued.*data/i的现有泳道没有则创建Rescued Data (Missing Swimlane)color: redsort: 9999999排在最末每张被救援卡片都会写一条userId: migration的moveCard活动日志。4.2 防止问题复发的写入钩子addSwimlaneIdValidationHooks()安装两个核心钩子Cards.before.insert处理“客户端提供的 swimlaneId 指向已删除/已归档泳道”的场景源码注释引用了 #1959/#1971列表视图添加卡片会复用过期 id拖拽会落到已归档的默认泳道导致卡片“存在但永不渲染”。校验Swimlanes.findOneAsync({ _id, boardId, archived: false })无效则回退到该看板第一个可见泳道完全缺失时同 4.1 的 Default 泳道逻辑。Cards.before.update若修改器试图$unset swimlaneId直接删除该$unset并告警若$set.swimlaneId null改写为默认泳道 id。这落实了文档“Validation hooks prevent swimlaneId removal”。5. 性能边界一览把散落在各文件中的限制汇总均可在对应源码中逐条核对项限制出处localStorage 每键看板数50client/lib/localStorageValidator.jsMAX_BOARDS_PER_KEYlocalStorage 每看板条目数100同上MAX_ITEMS_PER_BOARDlocalStorage 数据保留期90 天同上MAX_AGE_MS全量清理频率每天一次shouldRunCleanup()列宽合法区间100–1000 pxvalidateListWidths泳道高度合法区间-1 或 50–2000 pxvalidateSwimlaneHeights位置历史每用户每看板 1000 条检查点永不清除server/models/userPositionHistory.js 第 92–102 行历史查询getRecent上限 100 条同上6. 安全设计要点文档“Security Notes”一节给出三条原则源码中的对应实现用户隔离undoLast/redoLast/getRecent的查询条件一律带userId: this.userId历史写入时userId取自Meteor.userId()。用户只能撤销自己发起的变更检查点也是 per-user 的。拒绝而非净化客户端存储侧validator 模块对非法值直接丢弃服务端方法侧用check(...)做类型校验非法参数直接抛错。越权防护所有方法要求登录并通过requireBoardVisible看板成员检查卡片 undo 还会在执行时二次核验目标看板见 3.1防止 DDP 写入伪造的历史条目跨看板搬运数据。仓库中还有专门的安全回归测试覆盖这些方法server/lib/tests/clonebleed.security.tests.js 第 191–200 行断言userPositionHistory.createCheckpoint/getRecent/getCheckpoints在私有看板上对非成员必须返回失败。7. 验证方式手动与自动化测试文档给出的手动验证流程启动后检查日志、双账号切换验证折叠态隔离、故意损坏 localStorage 验证自动清理、移动卡片验证历史记录仍然适用。仓库自带的启动脚本是 start-wekan.sh文档中的cd /home/wekan/repos/wekan npm start是作者本机路径请按实际部署环境调整。仓库内与这套机制直接相关的自动化测试可作为回归依据tests/undoRedoSelection.test.cjs 与 tests/undoRecordsWhatItClaims.test.cjsundo/redo 记录与行为的完整性tests/changeHistoryWiring.test.cjs历史双写过渡期的接线tests/securityAdvisories20260825.test.cjs、tests/canaryCoverage.test.cjs含history.cross-boardcanary 的覆盖检查。排障建议来自文档“Support Troubleshooting”结合源码更新应用启动异常检查 MongoDB 状态、启动日志注意Migrations集合现在定义在迁移文件顶部若仍报 “Migrations.findOne is not a function”通常是加载顺序/打包问题数据“消失”先查migrations集合中ensure-valid-swimlane-ids的completedAt与results再到 “Rescued Data (Missing Swimlane)” 泳道里找被救援卡片undo 不生效确认userPositionHistory集合存在、有对应记录、且实体仍存在canUndo()对已删除实体返回 false另注意文档时代的限制——被删除实体若未经软删除路径v1 仅 list 及其卡片其 undo 走的是“恢复deleted标记”不是从硬删除中复活。8. 已知局限与文档写作后的演进文档列出的四项已知局限无 undo/redo 按钮等 UI、仅位置历史、单用户撤销、无历史搜索中从当前源码看部分已演进redo 已落地集合 schema 增加了undone/undoneAt字段源码注释标注 #6478服务端提供redoLast方法卡片移动路径的“从未记录”Bug裸标识符守卫也已修复——见 3.2仍属后续工作与文档“Planned Features”一致撤销/重做按钮、历史侧边栏可视化、CtrlZ / CtrlShiftZ 快捷键的 UI 接线、字段级历史与历史检索协作撤销仍为单用户语义历史按userId隔离A 的 undo 只还原 A 自己的变更轨迹。9. 相关文件索引文件角色client/lib/localStorageValidator.jslocalStorage 校验、每日清理、原子读写 APImodels/lib/userStorageHelpers.js带上下界/类型检查的 per-item 读写原语models/userPositionHistory.js历史集合 Schema 与 undo/redo 文档方法server/models/userPositionHistory.js索引、Meteor 方法、容量治理与每日清理server/migrations/ensureValidSwimlaneIds.jsswimlaneId 数据修复与写入钩子models/users.jsprofile 状态字段 Schema 与读写方法models/cards.js卡片 move 后的历史双写调用点models/swimlanes.js / models/lists.js移除看板级折叠的兼容注释背景文档同目录可延伸阅读PERSISTENCE_AUDIT.md完整系统审计与 ARCHITECTURE_IMPROVEMENTS.md详细实施指南。【免费下载链接】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),仅供参考