
Wekan 数据持久化架构升级实战泳道高度与列表宽度的 Per-Board/Per-User 分离方案【免费下载链接】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基于 Meteor 的开源看板数据持久化架构的一次重要升级记录与实施指南。它对应仓库 docs/Security/PerUserDataAudit2025-12-23/COMPLETION_SUMMARY.md 及其配套文档该目录下共 14 份文档构成完整的审计—架构—实施—验证链条。核心主题是将泳道swimlane高度与列表list宽度从每个用户各自存储per-user迁移为整个看板共享per-board并在此基础上明确全部看板数据的持久化归属。读者阅读本文后将能理解 Wekan 的数据分类原则、掌握两个新 Schema 字段的精确含义与校验规则、熟悉用户模型重构的两种实现方案、获得可复制的数据迁移脚本与回滚流程以及一套可直接照做的多用户测试清单。一、背景为什么需要这次架构审计在升级之前Wekan 对看板布局维度的存储存在归属错位问题泳道高度swimlane height存储在user.profile.swimlaneHeights中属于每个用户私有列表宽度list width存储在user.profile.listWidths中同样属于每个用户私有。后果是同一块看板上用户 A 看到的泳道高 300px用户 B 看到的却是 400px列表宽度也因人而异。看板的物理布局本应是所有协作者共享的事实shared layout却被打散到了各个用户的 profile 里既造成协作困惑也带来数据冗余和跨用户写入的复杂度。2025-12-23 完成的第一阶段Phase 1审计与整改核心决策是泳道高度、列表宽度改为 per-board存于看板实体文档内所有用户共享折叠状态、标签文字显隐等个性化偏好保持 per-user存于用户 profile / Cookie / localStorage。本次整改在仓库中的落点非常清晰仅修改两个模型文件 models/swimlanes.js 与 models/lists.js为泳道新增height字段、为列表新增width字段二者均带自定义校验规则且保持向后兼容optional: true。详细的变更记录可参见 COMPLETION_SUMMARY.md 与 CURRENT_STATUS.md。二、数据分类矩阵Per-Board 与 Per-User 的权威边界整个架构的核心是一张数据分类矩阵完整版见 DATA_PERSISTENCE_ARCHITECTURE.md。它定义了所有持久化数据的唯一事实来源authoritative source of truth。2.1 Per-Board共享数据存于 MongoDB 实体文档所有用户看到同一值实体字段存储位置格式泳道 Swimlane标题 titleMongoDBString泳道颜色 colorMongoDBString调色板 / 自定义十六进制色泳道高度 height新增MongoDBNumber-1自动50-2000 固定像素泳道位置 sortMongoDBNumber小数列表 List标题 titleMongoDBString列表颜色 colorMongoDBString调色板 / 自定义十六进制色列表宽度 width新增MongoDBNumber100-1000 像素列表位置 sortMongoDBNumber小数列表WIP 限制MongoDBObject {enabled, value, soft}列表置顶 starredMongoDBBoolean卡片 Card标题 / 颜色 / 描述MongoDBString / String / String卡片位置 sortMongoDBNumber小数卡片listId / swimlaneIdMongoDBString清单 Checklist标题 / sort / hideCheckedItems / hideAllItemsMongoDBString / Number / Boolean / Boolean清单项 ChecklistItem标题 / sort / isFinishedMongoDBString / Number / Boolean2.2 Per-User私有数据存于用户 profile 或浏览器本地仅本人可见实体字段存储位置用户折叠泳道 collapsedSwimlanesuser.profile.collapsedSwimlanes[boardId][swimlaneId]用户折叠列表 collapsedListsuser.profile.collapsedLists[boardId][listId]用户隐藏迷你卡片标签文字user.profile.hideMiniCardLabelText[boardId]未登录访客折叠状态Cookiewekan-collapsed-swimlanes、wekan-collapsed-lists与 localStorage对于未登录用户折叠状态通过 Cookie / localStorage 持久化见 QUICK_REFERENCE.md 中wekan-collapsed-lists、wekan-card-collapsed、wekan-hide-minicard-label-{boardId}等键名的说明且客户端启动时会自动校验并清理损坏数据。2.3 修复前后的数据形态对比修复前错误同一块看板、同一个泳道不同用户各自持有一份高度——// 泳道文档per-board 实体里没有 height { _id: swim123, title: Development, boardId: board123 } // 用户 A 的私有数据swim123 高度 300 { _id: userA, profile: { swimlaneHeights: { board123: { swim123: 300 } } } } // 用户 B 的私有数据swim123 高度 400 { _id: userB, profile: { swimlaneHeights: { board123: { swim123: 400 } } } }修复后正确高度进入泳道文档所有用户看到一致的 300pxprofile 里只保留纯个人偏好——// 泳道文档per-board全看板共享 { _id: swim123, title: Development, boardId: board123, height: 300 } // 用户 A只保存自己的折叠偏好 { _id: userA, profile: { collapsedSwimlanes: { board123: { swim123: false } } } } // 用户 B只保存自己的折叠偏好 { _id: userB, profile: { collapsedSwimlanes: { board123: { swim123: true } } } }这种共享布局 私有偏好的双轨设计正是本次审计要确立的关注点分离separation of concerns。完整示例见 CURRENT_STATUS.md 的 Data Examples 一节。三、Schema 变更两个新字段及其校验规则3.1 泳道新增height字段文档中的规格见 IMPLEMENTATION_GUIDE.md与实际仓库代码完全一致。实际代码位于 models/swimlanes.jsheight: { /** * The height of the swimlane in pixels. * -1 auto-height (default) * 50-2000 fixed height in pixels */ type: Number, optional: true, defaultValue: -1, custom() { const h this.value; if (h ! -1 (h 50 || h 2000)) { return heightOutOfRange; } }, },要点-1表示自动高度默认值50-2000为固定像素值optional: true保证向后兼容——旧数据没有该字段也不会校验失败custom()校验返回heightOutOfRange错误码任何超出-1或50-2000范围的写入都会被 SimpleSchema 拒绝代码注释第 141-143 行明确声明折叠状态仅存于profile.collapsedSwimlanes与 localStorage高度是 per-board 共享字段这一注释与架构文档相互印证。3.2 列表新增width字段实际代码位于 models/lists.jswidth: { /** * The width of the list in pixels (100-1000). * #6465: default width is 220 pixels (was 272) so more lists fit on * screen; kept in sync with DEFAULT_LIST_WIDTH in models/lib/listWidth.js. */ type: Number, optional: true, defaultValue: 220, custom() { const w this.value; if (w 100 || w 1000) { return widthOutOfRange; } }, },要点校验规则为100-1000 像素越界返回widthOutOfRange默认值需要特别注意审计文档2025-12-23记录默认值为272但当前仓库代码中默认值已调整为220见代码注释中的 #6465 改动说明并且与 models/lib/listWidth.js 中的DEFAULT_LIST_WIDTH 220、MIN_LIST_WIDTH 200常量保持同步。如果读者在此时间点之后部署应以仓库实际代码为准optional: true同样保证旧列表文档不受影响。3.3 已验证无需改动的模型审计结论见 COMPLETION_SUMMARY.md 与 SCHEMA_CHANGES_VERIFICATION.md模型字段结论models/cards.jssort、swimlaneId、listId✅ 原本就是 per-board无需修改models/checklists.jssort、hideCheckedChecklistItems、hideAllChecklistItems✅ 原本就是 per-board无需修改models/checklistItems.jssort、isFinished✅ 原本就是 per-board无需修改也就是说卡片、清单、清单项的位置sort小数排序值从一开始就正确地存在于实体文档中本次审计只是确认了它们的归属并未做任何代码改动。四、数据流两类数据各自如何读写4.1 Per-Board 数据流以泳道高度为例1. 用户在 UI 中调整泳道高度 2. 客户端调用Swimlanes.update(swimlaneId, { $set: { height: 300 } }) 3. MongoDB 收到更新请求 4. Schema 校验height 必须为 -1 或处于 50-2000 区间 5. 更新写入 swimlanes 集合{ _id, title, height: 300, ... } 6. 通过响应式数据源ReactiveCache / 订阅广播 7. 看板上所有用户都看到更新后的高度 8. 页面刷新后仍持久保留 9. 浏览器重启后仍持久保留4.2 Per-User 数据流以折叠状态为例1. 用户在 UI 中折叠泳道 2. 客户端判断登录状态 3. 已登录 a. 调用 Meteor.call(setCollapsedSwimlane, boardId, swimlaneId, true) b. 服务端更新用户 profile{ profile: { collapsedSwimlanes: { ... } } } c. 写入 users 集合 4. 未登录 a. 客户端写入 Cookiewekan-collapsed-swimlanes 5. 下次页面加载 a. 客户端从 profile已登录或 Cookie未登录读取 b. 恢复保存的 UI 状态 6. 折叠状态对其他用户不可见两条数据流的完整图示见 DATA_PERSISTENCE_ARCHITECTURE.md 的 Data Flow 一节。注意实际仓库中Swimlanes/Lists的updateAsync/insertAsync异步 APIMeteor 3 风格示例代码中的同步写法在阅读时应结合当前版本 API 调整。五、用户模型重构Phase 2两种实现方案第一阶段只完成了 Schema 层的改动。用户模型models/users.js中的 per-user 高度/宽度读写方法仍是遗留问题这属于尚未开始的 Phase 2。通过源码检索可以确认这些方法目前仍然存在getListWidths()models/users.js——读取profile.listWidthsgetListWidth(boardId, listId)models/users.js——从 per-user 数据返回宽度getSwimlaneHeights()models/users.js、getSwimlaneHeight(boardId, listId)models/users.jssetListWidth(boardId, listId, width)models/users.js——写入profile.listWidthssetSwimlaneHeight(boardId, swimlaneId, height)models/users.js——写入profile.swimlaneHeights。同时用户 Schema 中仍保留profile.listWidthsmodels/users.js与profile.swimlaneHeightsmodels/users.js两个字段定义。这些正是 IMPLEMENTATION_GUIDE.md 第 2 节要求重构的对象。方案 A新增独立的持久化辅助模块推荐在models/lib/persistenceHelpers.js中集中封装读写逻辑与实体文档直接交互// 从泳道文档读取高度per-board 存储 export const getSwimlaneHeight (swimlaneId) { const swimlane Swimlanes.findOne(swimlaneId); return swimlane swimlane.height ! undefined ? swimlane.height : -1; }; // 从列表文档读取宽度per-board 存储 export const getListWidth (listId) { const list Lists.findOne(listId); return list list.width ! undefined ? list.width : 272; }; // 写入泳道高度先校验再更新 export const setSwimlaneHeight (swimlaneId, height) { if (height ! -1 (height 50 || height 2000)) { throw new Error(Height out of range: -1 or 50-2000); } Swimlanes.update(swimlaneId, { $set: { height } }); }; // 写入列表宽度先校验再更新 export const setListWidth (listId, width) { if (width 100 || width 1000) { throw new Error(Width out of range: 100-1000); } Lists.update(listId, { $set: { width } }); };方案 B直接重构 users.js 中的方法将原有 per-user 查找逻辑替换为对实体文档的读取// 旧逻辑删除从 this.getListWidths()[boardId][listId] 取值 // 新逻辑直接读列表文档 getListWidth(listId) { const list ReactiveCache.getList({ _id: listId }); return list list.width ? list.width : 272; }, // 旧逻辑删除从 this.getSwimlaneHeights()[boardId][swimlaneId] 取值 // 新逻辑直接读泳道文档 getSwimlaneHeight(swimlaneId) { const swimlane ReactiveCache.getSwimlane(swimlaneId); return swimlane swimlane.height ? swimlane.height : -1; }, // 写入宽度直接落到列表文档 setListWidth(listId, width) { Lists.update(listId, { $set: { width } }); }, // 写入高度直接落到泳道文档 setSwimlaneHeight(swimlaneId, height) { Swimlanes.update(swimlaneId, { $set: { height } }); },必须保留的 per-user 方法以下方法不应随高度/宽度一起迁移它们就是 per-user 数据的正确归宿// 折叠泳道per-user getCollapsedSwimlanes() { ... }, setCollapsedSwimlane(boardId, swimlaneId, collapsed) { ... }, isCollapsedSwimlane(boardId, swimlaneId) { ... }, // 折叠列表per-user getCollapsedLists() { ... }, setCollapsedList(boardId, listId, collapsed) { ... }, isCollapsedList(boardId, listId) { ... }, // 隐藏迷你卡片标签文字per-user getHideMiniCardLabelText(boardId) { ... }, setHideMiniCardLabelText(boardId, hidden) { ... },同时应从 users.js 的 Schema 中移除profile.listWidths与profile.swimlaneHeights两个字段定义。客户端与 Meteor 方法层面的调整读取由Meteor.user().getListWidth(boardId, listId)改为Lists.findOne(listId)?.width || 默认值写入由Meteor.call(setListWidth, boardId, listId, 300)改为Lists.update(listId, { $set: { width: 300 } })移除setListWidth、setSwimlaneHeight这两个以用户 profile 为目标的 Meteor 方法应被删除。六、数据迁移Phase 3模板脚本与回滚方案6.1 迁移脚本模板IMPLEMENTATION_GUIDE.md 第 4 节提供了完整模板创建于server/migrations/migrateToPerBoardStorage.js。当前仓库的 server/migrations 目录下已有ensureValidSwimlaneIds.js、migrateAttachments.js等 6 个既有迁移但尚无migrateToPerBoardStorage.js——这与文档Phase 3 待办的状态一致。核心逻辑如下const MIGRATION_NAME migrate-to-per-board-height-width-storage; Migrations new Mongo.Collection(migrations); Meteor.startup(() { const existingMigration Migrations.findOne({ name: MIGRATION_NAME }); if (!existingMigration) { try { // 1. 把 user.profile.swimlaneHeights 迁移到 swimlane.height Meteor.users.find().forEach(user { const swimlaneHeights user.profile?.swimlaneHeights || {}; Object.keys(swimlaneHeights).forEach(boardId { Object.keys(swimlaneHeights[boardId]).forEach(swimlaneId { const height swimlaneHeights[boardId][swimlaneId]; if (height -1 || (height 50 height 2000)) { Swimlanes.update( { _id: swimlaneId, boardId }, { $set: { height } }, { multi: false }, ); } }); }); }); // 2. 把 user.profile.listWidths 迁移到 list.width Meteor.users.find().forEach(user { const listWidths user.profile?.listWidths || {}; Object.keys(listWidths).forEach(boardId { Object.keys(listWidths[boardId]).forEach(listId { const width listWidths[boardId][listId]; if (width 100 width 1000) { Lists.update( { _id: listId, boardId }, { $set: { width } }, { multi: false }, ); } }); }); }); // 3. 记录迁移结果 Migrations.insert({ name: MIGRATION_NAME, status: completed, createdAt: new Date(), migratedSwimlanes: Swimlanes.find({ height: { $exists: true, $ne: -1 } }).count(), migratedLists: Lists.find({ width: { $exists: true, $ne: 272 } }).count(), }); console.log(✅ Migration to per-board height/width storage completed); } catch (error) { console.error(❌ Migration failed:, error); Migrations.insert({ name: MIGRATION_NAME, status: failed, error: error.message, createdAt: new Date(), }); } } });迁移脚本的关键设计通过migrations集合记录执行状态保证幂等重复启动不会二次迁移写入前校验范围非法值如高度 25、宽度 50直接跳过不会污染实体文档迁移后记录migratedSwimlanes/migratedLists计数便于验证。6.2 回滚方案IMPLEMENTATION_GUIDE.md 第 6 节给出的回滚流程分三步# 1. 迁移前先备份 MongoDB mongodump -d wekan -o backup-wekan-before-migration # 2. 如遇问题从备份恢复 mongorestore -d wekan backup-wekan-before-migration/wekan # 3. 回退代码恢复旧的 swimlanes.js、lists.js、users.js由于两个新字段都是optional: true即使迁移中途失败旧数据也原样保留在用户 profile 中不存在数据丢失风险——这是本次设计向后兼容性的核心保证。七、安全与协作收益7.1 数据隔离无跨用户泄漏用户 A 的折叠偏好永远不会写入用户 B 的 profile用户 A 的折叠/显隐设置不会影响用户 B 的视图每个用户拥有独立的私有数据空间。7.2 共享布局的一致性高度/宽度共享 → 所有用户看到相同的泳道、列表尺寸位置sort共享 → 所有用户看到相同的卡片顺序颜色共享 → 所有用户看到相同的视觉样式。7.3 性能与可维护性高度/宽度直接随实体文档查询返回减少了对每个用户 profile 的额外查找reduced per-user lookups数据归属清晰后代码路径更短Schema 校验由 SimpleSchema 统一执行数据库效率与可维护性同步提升。需要说明的是文档中性能提升属于架构推论的定性描述仓库内没有对应的基准测试数据读者应将其理解为设计意图而非实测结论。安全影响分析的完整论述见 DATA_PERSISTENCE_ARCHITECTURE.md 的 Security Implications 一节。八、测试清单从 Schema 到多用户场景8.1 Schema 校验测试用例预期Swimlanes.insert({ height: -1 })✅ 接受自动高度Swimlanes.insert({ height: 50 })✅ 接受最小值Swimlanes.insert({ height: 2000 })✅ 接受最大值Swimlanes.insert({ height: 25 })❌ 拒绝heightOutOfRangeSwimlanes.insert({ height: 3000 })❌ 拒绝heightOutOfRangeLists.insert({ width: 100 })✅ 接受最小值Lists.insert({ width: 500 })✅ 接受中间值Lists.insert({ width: 1000 })✅ 接受最大值Lists.insert({ width: 50 })❌ 拒绝widthOutOfRangeLists.insert({ width: 2000 })❌ 拒绝widthOutOfRange8.2 持久化与多用户测试Per-Board共享调整泳道高度 → 所有用户都看到变化调整列表宽度 → 所有用户都看到变化跨列表移动卡片 → 所有用户都看到变化刷新页面 / 更换浏览器 → 变化仍然保留。Per-User私有用户 A 折叠泳道 → 用户 B 看到的是展开状态用户 A 隐藏标签 → 用户 B 仍看到标签登出 → Cookie 维持折叠状态换用户登录 → 看不到上一个用户的折叠偏好。迁移测试在含旧 per-user 数据的库上运行迁移所有高度/宽度正确落入实体文档确认profile.swimlaneHeights/profile.listWidths可安全移除。完整清单见 SCHEMA_CHANGES_VERIFICATION.md 与 DATA_PERSISTENCE_ARCHITECTURE.md 的 Testing Checklist。8.3 手工验证命令# 在 meteor shell 中验证 Schema 校验 meteor shell Swimlanes.insert({ boardId: test, height: -1 }) // 应成功 Swimlanes.insert({ boardId: test, height: 25 }) // 应失败 # 直接检查数据库 mongo wekan db.swimlanes.findOne() // 检查 height 字段是否存在 db.lists.findOne() // 检查 width 字段是否存在九、文档体系与阅读路径该审计目录共 14 份文档入口 docs/Security/PerUserDataAudit2025-12-23/README.md 提供了导航。推荐的阅读顺序场景文档用时快速了解现状CURRENT_STATUS.md5 分钟完整架构规范DATA_PERSISTENCE_ARCHITECTURE.md15 分钟动手实施Phase 2-4IMPLEMENTATION_GUIDE.md20 分钟验证已完成的改动SCHEMA_CHANGES_VERIFICATION.md10 分钟快速查表与排障QUICK_REFERENCE.md3 分钟里程碑总结COMPLETION_SUMMARY.md—此外QUICK_REFERENCE.md 还记录了本次审计的前置工作移除了泳道/列表上板级的collapsed字段及其 REST API 端点、删除了Swimlanes的collapse()变更方法并引入了userPositionHistory集合卡片移动历史、检查点/恢复点与 server/migrations/ensureValidSwimlaneIds.js 启动期迁移为无swimlaneId的卡片自动分配默认泳道、把孤儿卡片救入 Rescued Data 泳道。这些内容与本次 per-board/per-user 分离属于同一轮持久化审计的组成部分但本文主题聚焦于高度/宽度归属相关细节可自行查阅该文档。十、当前状态与后续路线截至文档记录日期2025-12-23各阶段状态如下阶段内容状态Phase 1Schema 变更swimlanes.heightlists.width含校验✅ 已完成Phase 2用户模型重构users.js 方法改读实体文档移除 profile 中的高度/宽度存储⏳ 待办估算 2-4 小时Phase 3数据迁移脚本user.profile.listWidths → list.width、user.profile.swimlaneHeights → swimlane.height⏳ 待办估算 1-2 小时Phase 4UI 集成更新客户端代码、Meteor 方法与订阅多用户联调⏳ 待办估算 4-6 小时仓库源码与文档相互印证了已完成/待办的边界两个新字段确实存在于 models/swimlanes.js 与 models/lists.js而 models/users.js 中的 per-user 读写方法L1713-L2700和profile.listWidths/profile.swimlaneHeightsSchema 定义L894、L953仍在——Phase 2/3 的路线图清晰可执行。结语这次持久化架构审计解决了一个真实的协作一致性问题看板的物理布局必须是团队共享的事实而个人偏好必须保持私密。Phase 1 通过两个带校验的 Schema 字段确立了这一边界且全程保持向后兼容——旧数据在迁移前不会被触碰迁移脚本幂等可重跑回滚方案三步即可完成。对于需要二次开发或自托管 Wekan 的团队本文所述的数据分类矩阵、校验规则、重构方案、迁移模板与测试清单可以直接作为后续 Phase 2-4 实施的施工图。【免费下载链接】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),仅供参考