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 | 标题 title | MongoDB | String |
| 泳道 | 颜色 color | MongoDB | String(调色板 / 自定义十六进制色) |
| 泳道 | 高度 height(新增) | MongoDB | Number(-1=自动,50-2000 固定像素) |
| 泳道 | 位置 sort | MongoDB | Number(小数) |
| 列表 List | 标题 title | MongoDB | String |
| 列表 | 颜色 color | MongoDB | String(调色板 / 自定义十六进制色) |
| 列表 | 宽度 width(新增) | MongoDB | Number(100-1000 像素) |
| 列表 | 位置 sort | MongoDB | Number(小数) |
| 列表 | WIP 限制 | MongoDB | Object {enabled, value, soft} |
| 列表 | 置顶 starred | MongoDB | Boolean |
| 卡片 Card | 标题 / 颜色 / 描述 | MongoDB | String / String / String |
| 卡片 | 位置 sort | MongoDB | Number(小数) |
| 卡片 | listId / swimlaneId | MongoDB | String |
| 清单 Checklist | 标题 / sort / hideCheckedItems / hideAllItems | MongoDB | String / Number / Boolean / Boolean |
| 清单项 ChecklistItem | 标题 / sort / isFinished | MongoDB | String / Number / Boolean |
2.2 Per-User(私有数据,存于用户 profile 或浏览器本地,仅本人可见)
| 实体 | 字段 | 存储位置 |
|---|---|---|
| 用户 | 折叠泳道 collapsedSwimlanes | user.profile.collapsedSwimlanes[boardId][swimlaneId] |
| 用户 | 折叠列表 collapsedLists | user.profile.collapsedLists[boardId][listId] |
| 用户 | 隐藏迷你卡片标签文字 | user.profile.hideMiniCardLabelText[boardId] |
| 未登录访客 | 折叠状态 | Cookie(wekan-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 } } } }修复后(正确):高度进入泳道文档,所有用户看到一致的 300px;profile 里只保留纯个人偏好——
// 泳道文档: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.js:
height: { /** * 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.js:
width: { /** * 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.js | sort、swimlaneId、listId | ✅ 原本就是 per-board,无需修改 |
| models/checklists.js | sort、hideCheckedChecklistItems、hideAllChecklistItems | ✅ 原本就是 per-board,无需修改 |
| models/checklistItems.js | sort、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. 客户端写入 Cookie:wekan-collapsed-swimlanes 5. 下次页面加载: a. 客户端从 profile(已登录)或 Cookie(未登录)读取 b. 恢复保存的 UI 状态 6. 折叠状态对其他用户不可见两条数据流的完整图示见 DATA_PERSISTENCE_ARCHITECTURE.md 的 "Data Flow" 一节。注意实际仓库中Swimlanes/Lists的updateAsync/insertAsync异步 API(Meteor 3 风格),示例代码中的同步写法在阅读时应结合当前版本 API 调整。
五、用户模型重构(Phase 2):两种实现方案
第一阶段只完成了 Schema 层的改动。用户模型(models/users.js)中的 per-user 高度/宽度读写方法仍是遗留问题,这属于尚未开始的 Phase 2。通过源码检索可以确认这些方法目前仍然存在:
getListWidths()(models/users.js)——读取profile.listWidths;getListWidth(boardId, listId)(models/users.js)——从 per-user 数据返回宽度;getSwimlaneHeights()(models/users.js)、getSwimlaneHeight(boardId, listId)(models/users.js);setListWidth(boardId, listId, width)(models/users.js)——写入profile.listWidths;setSwimlaneHeight(boardId, swimlaneId, height)(models/users.js)——写入profile.swimlaneHeights。
同时,用户 Schema 中仍保留'profile.listWidths'(models/users.js)与'profile.swimlaneHeights'(models/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 }) | ❌ 拒绝(heightOutOfRange) |
Swimlanes.insert({ height: 3000 }) | ❌ 拒绝(heightOutOfRange) |
Lists.insert({ width: 100 }) | ✅ 接受(最小值) |
Lists.insert({ width: 500 }) | ✅ 接受(中间值) |
Lists.insert({ width: 1000 }) | ✅ 接受(最大值) |
Lists.insert({ width: 50 }) | ❌ 拒绝(widthOutOfRange) |
Lists.insert({ width: 2000 }) | ❌ 拒绝(widthOutOfRange) |
8.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.md | 5 分钟 |
| 完整架构规范 | DATA_PERSISTENCE_ARCHITECTURE.md | 15 分钟 |
| 动手实施(Phase 2-4) | IMPLEMENTATION_GUIDE.md | 20 分钟 |
| 验证已完成的改动 | SCHEMA_CHANGES_VERIFICATION.md | 10 分钟 |
| 快速查表与排障 | QUICK_REFERENCE.md | 3 分钟 |
| 里程碑总结 | 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 1 | Schema 变更(swimlanes.height+lists.width,含校验) | ✅ 已完成 |
| Phase 2 | 用户模型重构(users.js 方法改读实体文档,移除 profile 中的高度/宽度存储) | ⏳ 待办(估算 2-4 小时) |
| Phase 3 | 数据迁移脚本(user.profile.listWidths → list.width、user.profile.swimlaneHeights → swimlane.height) | ⏳ 待办(估算 1-2 小时) |
| Phase 4 | UI 集成(更新客户端代码、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),仅供参考