news 2026/9/14 6:49:37

Wekan 看板数据持久化架构重构指南:泳道高度与列表宽度的 Per-Board 与 Per-User 分离实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Wekan 看板数据持久化架构重构指南:泳道高度与列表宽度的 Per-Board 与 Per-User 分离实践

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 构建的开源看板应用。2025-12-23 的数据持久化审计带来了一项关键架构决策:泳道(swimlane)高度与列表(list)宽度从"每个用户各自保存"改为"每个看板共享保存",而折叠状态、标签显隐等个人偏好则继续保留在用户维度。本文以 docs/Security/PerUserDataAudit2025-12-23/IMPLEMENTATION_GUIDE.md 为骨架,结合 models/swimlanes.js、models/lists.js、models/users.js 等仓库源码,完整讲解 Schema 变更、用户模型重构、客户端存储层改造、数据迁移脚本、测试清单与回滚方案。读完你将对"共享数据与私有数据如何分层设计"有可直接落地的完整认识。

一、为什么必须区分 Per-Board 与 Per-User

看板上有两类性质完全不同的数据:

  • 看板级(Per-Board)共享数据:泳道高度、列表宽度、卡片/清单/清单项的排序位置、所有标题、颜色、描述等。这些数据描述的是看板"本身长什么样",所有用户应当看到完全一致的视图。
  • 用户级(Per-User)私有数据:泳道/列表/卡片的折叠状态、迷你卡片标签文字的显隐等。这些是个人偏好,User A 的折叠不能影响 User B 的视野。

在本次重构之前,泳道高度(profile.swimlaneHeights)和列表宽度(profile.listWidths)被错误地存放在user.profile中,属于用户私有数据。这带来的直接问题是:同一看板上,不同用户看到的泳道高度、列表宽度可能不一致,甚至未登录的访客与登录成员看到的尺寸都不同。完整的分类矩阵与存储位置定义见 DATA_PERSISTENCE_ARCHITECTURE.md,其数据流章节明确给出了两种数据各自的写入与读取链路。

二、Schema 变更:把共享数据搬进业务文档

2.1 Swimlanes:新增height字段

在 models/swimlanes.js 中,泳道集合新增了height字段,源码实现与文档完全一致:

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固定高度(像素)
其他custom()校验拒绝,返回heightOutOfRange

模式文件底部(models/swimlanes.js)专门用注释明确了边界:折叠状态是 per-user 数据,只存于profile.collapsedSwimlanes与未登录用户的 localStorage;而height是 per-board 共享数据,存在swimlanes.height

2.2 Lists:新增width字段

models/lists.js 中列表集合新增width字段:

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'; } }, },

需要注意一个仓库实际状态与文档存在差异的点:文档编写时默认宽度是272,但当前仓库已按 issue #6465 将默认宽度收窄为220像素("让更多列表能显示在屏幕上")。这一默认值的单一来源被抽取到了 models/lib/listWidth.js,其中定义了:

  • DEFAULT_LIST_WIDTH = 220:所有未定制列表渲染的统一默认宽度;
  • MIN_LIST_WIDTH = 200:定制列表允许的最小宽度(低于 Schema 校验上限100,两者不一致是历史演进的结果);
  • isValidListWidth/normalizeListWidth/resolveListWidth:宽度校验、归一化与解析函数。

resolveListWidth的解析优先级(见 models/lib/listWidth.js)非常值得关注:

  1. 用户开启"所有列表同宽"模式(fixedEnabled)时,直接使用单一fixedWidth
  2. 否则取lists.width这个看板级共享宽度sharedWidth);
  3. 只有当看板开启"个人列表宽度"(personalMode,对应board.allowsPersonalListWidth)时,才回落到用户自己的personalWidth(登录用户存 profile,匿名用户存 localStorage)。

也就是说,当前仓库的宽度体系已经比审计文档更进一步:共享的lists.width只是默认来源,个人宽度变成显式开关后的可选覆盖。这恰好印证了文档"共享数据默认一致、私有数据按需隔离"的设计思想。

2.3 无需变更的部分

以下实体在重构前就已经使用看板级sort字段存储排序位置,本就属于 per-board 数据,无需任何改动:

实体排序字段位置
Cardscard.sort(decimal)models/cards.js
Checklistschecklist.sortmodels/checklists.js
ChecklistItemschecklistItem.sortmodels/checklistItems.js

以泳道排序为例,models/swimlanes.js 的copy助手在插入新泳道时会计算相邻两个 sort 值的平均数作为新 sort,正是"decimal 小数排序"策略的落地实现。

三、用户模型重构:从 profile 搬走共享数据

3.1 现状问题(仓库当前仍保留旧实现)

审计文档描述了要修改的四个用户方法,当前 models/users.js 中仍能看到重构前的实现,可作为"迁移前状态"的参照:

  • getListWidths()(models/users.js)直接读取this.profile.listWidths
  • getListWidth(boardId, listId)(models/users.js)优先查profile.listWidths[boardId][listId],否则回退到DEFAULT_LIST_WIDTH
  • getSwimlaneHeights()/getSwimlaneHeight()(models/users.js)同理读取profile.swimlaneHeights
  • setListWidth(boardId, listId, width)(models/users.js)与setSwimlaneHeight(boardId, swimlaneId, height)(models/users.js)把值写回profile.listWidths/profile.swimlaneHeights

与此同时,Schema 中profile.listWidths(models/users.js)与profile.swimlaneHeights(models/users.js)两个 blackbox 对象字段正是文档第 2 节要求删除的字段。

3.2 Option A:新建持久化助手模块(推荐)

文档推荐新建models/lib/persistenceHelpers.js,把读写逻辑收敛到业务文档上:

// Get swimlane height from swimlane document (per-board storage) export const getSwimlaneHeight = (swimlaneId) => { const swimlane = Swimlanes.findOne(swimlaneId); return swimlane && swimlane.height !== undefined ? swimlane.height : -1; }; // Get list width from list document (per-board storage) export const getListWidth = (listId) => { const list = Lists.findOne(listId); return list && list.width !== undefined ? list.width : 272; }; // Set swimlane height in swimlane document (per-board storage) 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 } }); }; // Set list width in list document (per-board storage) export const setListWidth = (listId, width) => { if (width < 100 || width > 1000) { throw new Error('Width out of range: 100-1000'); } Lists.update(listId, { $set: { width } }); };

3.3 Option B:直接修改用户方法

文档给出了四个方法的改造前后对照,核心是把"查 profile → 回退默认值"替换为"查业务文档 → 回退默认值",并移除boardId参数:

// 改造后:从 list 文档读取共享宽度 getListWidth(listId) { const list = ReactiveCache.getList({ _id: listId }); return list && list.width ? list.width : 272; }, // 改造后:从 swimlane 文档读取共享高度 getSwimlaneHeight(swimlaneId) { const swimlane = ReactiveCache.getSwimlane(swimlaneId); return swimlane && swimlane.height ? swimlane.height : -1; }, // 改造后:写入 list 文档 setListWidth(listId, width) { Lists.update(listId, { $set: { width } }); }, // 改造后:写入 swimlane 文档 setSwimlaneHeight(swimlaneId, height) { Swimlanes.update(swimlaneId, { $set: { height } }); },

这里引入的ReactiveCache是 Wekan 的响应式缓存层(imports/reactiveCache.js),它的getList/getSwimlane能减少对 MongoDB 的直接查询并保持响应式更新,客户端与服务端共用。这也是为何当前仓库的宽度解析模块被刻意设计成 Meteor-free 的纯 Node 模块(models/lib/listWidth.js),以便在纯 Node 环境下做单元测试(参见tests/listWidthDefaults.test.cjs)。

3.4 必须保留的 Per-User 存储方法

折叠状态与标签显隐继续留在用户 profile,这些方法原样保留:

// 折叠泳道(per-user) getCollapsedSwimlanes() { const { collapsedSwimlanes = {} } = this.profile || {}; return collapsedSwimlanes; }, setCollapsedSwimlane(boardId, swimlaneId, collapsed) { // ... update user.profile.collapsedSwimlanes[boardId][swimlaneId] }, isCollapsedSwimlane(boardId, swimlaneId) { // ... check user.profile.collapsedSwimlanes }, // 折叠列表(per-user) getCollapsedLists() { /* ... */ }, setCollapsedList(boardId, listId, collapsed) { /* ... */ }, isCollapsedList(boardId, listId) { /* ... */ }, // 隐藏迷你卡片标签文字(per-user) getHideMiniCardLabelText(boardId) { /* ... */ }, setHideMiniCardLabelText(boardId, hidden) { /* ... */ },

仓库中的实际实现印证了这一设计。models/users.js 的setCollapsedList/setCollapsedSwimlane都在写profile.collapsedLists/profile.collapsedSwimlanes,且写入时用!!collapsed强制布尔化;对应的服务端方法setListCollapsedStatesetSwimlaneCollapsedState(server/models/users.js)先校验参数与登录态,再写入当前用户的 profile。Schema 侧profile.collapsedLists(models/users.js)、profile.collapsedSwimlanes(models/users.js)以及profile.hideMiniCardLabelText均为 per-user 的 blackbox 对象,这些不在删除范围

3.5 从用户 Schema 删除的字段

// REMOVE from schema: 'profile.listWidths': { ... }, // Now stored in list.width 'profile.swimlaneHeights': { ... }, // Now stored in swimlane.height

四、客户端存储访问层改造

4.1 读写路径替换

UI 层获取/设置宽度高度的方式从"走用户 profile"改为"直接操作业务文档":

// OLD(废弃):从用户 profile 读取 const width = Meteor.user().getListWidth(boardId, listId); // NEW:从 list 文档读取 const width = Lists.findOne(listId)?.width || 272; // OLD(废弃):通过 Meteor 方法写用户 profile Meteor.call('setListWidth', boardId, listId, 300); // NEW:直接更新 list 文档 Lists.update(listId, { $set: { width: 300 } });

对应地,文档要求删除setListWidthsetSwimlaneHeight这两个更新用户 profile 的 Meteor 方法(docs/Security/PerUserDataAudit2025-12-23/IMPLEMENTATION_GUIDE.md 有完整示意)。需要提醒的是:当前 server/models/users.js 仍存在applySwimlaneHeightapplySwimlaneHeightToStorageapplyListWidthToStorage等旧方法,它们内部仍调用user.setSwimlaneHeight/setSwimlaneHeightToStorage/setListWidthToStorage——这些属于文档标注的TODO(待重构)部分,重构时应一并清理,避免新旧两条写入路径并存导致数据不一致。

4.2 未登录用户的客户端存储

架构文档 DATA_PERSISTENCE_ARCHITECTURE.md 明确了未登录用户的 per-user 状态保存位置:

数据存储介质Key
折叠泳道Cookiewekan-collapsed-swimlanes
折叠列表Cookiewekan-collapsed-lists
卡片详情折叠Cookiewekan-card-collapsed
隐藏迷你卡片标签localStoragewekan-hide-minicard-label-{boardId}

折叠类数据走 Cookie 而非 localStorage,是因为未登录用户看公共看板时服务端渲染也需要读取该状态。

五、数据迁移脚本

5.1 脚本骨架

文档提供了完整可运行的迁移脚本,核心逻辑放在Meteor.startup中、用migrations集合防重:

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 { // Migrate swimlane heights from user.profile to 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]; // Validate height if (height === -1 || (height >= 50 && height <= 2000)) { Swimlanes.update( { _id: swimlaneId, boardId }, { $set: { height } }, { multi: false } ); } }); }); }); // Migrate list widths from user.profile to 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]; // Validate width if (width >= 100 && width <= 1000) { Lists.update( { _id: listId, boardId }, { $set: { width } }, { multi: false } ); } }); }); }); // Record successful migration 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(), }); } } });

几个值得注意的实现要点:

  • { _id, boardId }联合条件更新Swimlanes.update({ _id: swimlaneId, boardId }, ...)防止把 A 看板的值错写进 B 看板同名文档;
  • 写入前校验:高度只迁移-150~2000范围内的值,宽度只迁移100~1000范围内的值,越界数据直接丢弃,符合"清理损坏数据"的迁移策略(见 DATA_PERSISTENCE_ARCHITECTURE.md);
  • 记录迁移结果:成功/失败都会写入migrations集合,且记录迁移数量,便于审计。

5.2 与仓库现有迁移模式的对应

仓库server/migrations/目录中已有成熟的迁移范式可参考。以 server/migrations/ensureValidSwimlaneIds.js 为例:使用MIGRATION_NAME常量(第 28 行)、用Migrations.findOne检查是否已执行(第 32 行)、完成后写入{ name, status: 'completed', migratedAt }记录(第 306-309 行)、异常时记录失败原因(第 330 行)。迁移脚本应遵循这一模式,避免每次启动重复执行。

六、测试清单

文档提供了完整的四组测试项,可直接作为 PR 验收依据:

Schema 校验测试

  • 泳道height = -1允许插入;height = 100允许插入
  • 泳道height = 25拒绝(< 50);height = 3000拒绝(> 2000)
  • 列表width = 272(当前仓库为 220)允许插入;width = 50拒绝(< 100);width = 2000拒绝(> 1000)

数据持久化测试

  • 调整泳道高度 → 写入 swimlane 文档;刷新页面高度保持
  • 不同用户加载同一看板 → 看到相同高度
  • 调整列表宽度 → 写入 list 文档;刷新保持;不同用户看到相同宽度

Per-User 隔离测试

  • User A 折叠泳道 → User B 仍看到展开状态
  • User A 隐藏标签 → User B 仍看到标签
  • 同一用户刷新 → 个人偏好恢复;切换账号 → 看不到上一个用户的偏好

迁移测试

  • 在含旧 per-user 数据的库上运行迁移
  • 所有泳道高度迁移到 swimlane 文档、所有列表宽度迁移到 list 文档
  • 确认后可安全删除user.profile.swimlaneHeightsuser.profile.listWidths

这些测试项与 DATA_PERSISTENCE_ARCHITECTURE.md 中的测试清单相互呼应,后者还额外覆盖了"损坏的 localStorage/profile 数据在启动时被清理"的场景。

七、回滚方案

如果迁移后出现问题,按以下顺序回退:

1. 迁移前先备份 MongoDB:

mongodump -d wekan -o backup-wekan-before-migration

2. 需要时从备份恢复:

mongorestore -d wekan backup-wekan-before-migration/wekan

3. 回退代码:恢复旧版的 models/swimlanes.js、models/lists.js、[models/users.js]。

回滚的核心是"备份先行",因为迁移脚本只搬运数据、不清除旧 profile 字段,所以备份恢复后不会丢失任何旧数据。

八、涉及文件汇总

文件变更状态
models/swimlanes.js新增height字段✅ 已完成(见第 125-140 行)
models/lists.js新增width字段✅ 已完成(见第 267-282 行,默认值已按 #6465 调整为 220)
models/lib/listWidth.js宽度默认值/校验/解析单一来源✅ 已完成
models/users.js重构高度/宽度读写方法、删除profile.listWidths/profile.swimlaneHeights⏳ TODO(当前仍保留旧实现)
server/migrations/migrateToPerBoardStorage.js迁移脚本⏳ TODO
DATA_PERSISTENCE_ARCHITECTURE.md架构文档✅ 已完成

九、Per-Board 与 Per-User 数据总览

✅ Per-Board 数据(所有用户看到相同值)

  • 泳道高度(swimlanes.height,-1 或 50~2000)
  • 列表宽度(lists.width,100~1000)
  • 卡片位置(card.sort
  • 清单位置(checklist.sort
  • 清单项位置(checklistItem.sort
  • 所有标题、颜色、描述

🔒 Per-User 数据(仅本人可见)

  • 折叠状态(泳道、列表、卡片)
  • 迷你卡片标签文字显隐
  • 存储于user.profile或 Cookie/localStorage

十、总结与后续工作

本次重构确立了清晰的职责边界:描述"看板长什么样"的数据进业务文档共享给所有人,描述"用户怎么看"的数据留在 profile/Cookie 私有给个人。仓库现状表明 Schema 与架构层已完成(泳道height、列表width字段已就位,宽度解析逻辑已收敛到 models/lib/listWidth.js),而用户方法的去 profile 化重构与数据迁移脚本仍是 TODO——下一步是依据本文第 3、5 节完成models/users.js的改造、按第 6 节清单测试,并参照 server/migrations/ensureValidSwimlaneIds.js 的既有范式落地迁移脚本,最后按第 7 节做好备份再上线。

【免费下载链接】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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 6:43:32

AI新闻预测系统:核心技术架构与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 6:41:20

MQTT已连接但语音无响应?一文拆解语音助手音频链路排查要点

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 6:37:55

嵌入式工程师的避坑指南:Linux、RTOS与调试工具的深刻教训

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华