From a23b10959c709262c080a9da03d5794b60f341b7 Mon Sep 17 00:00:00 2001 From: "jif2.zhang" Date: Tue, 27 Oct 2026 17:28:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=90=8C=E6=AD=A5):=20=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E8=A1=A5=E4=B8=8A=20Task.status=20=E4=B8=8E?= =?UTF-8?q?=20Project.icon=20=E2=80=94=E2=80=94=20=E6=AD=A4=E5=89=8D?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E4=B8=A2=E5=BC=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象: 任务点完成后被自动移入 Done 分组, 但详情页状态仍是「未开始」。 根因: /api/powersync/upload 的 **更新分支** 漏了 status 字段 —— Task 更新分支逐个处理了 title/description/kind/markDate/extras/sortOrder/ assigneeId/doneAt/archived*/columnId/parentId, 唯独没有 d.status (离线新建分支是处理了的, 所以只有"改已有任务"这条路丢状态)。 客户端 api/task.js 本地写入时 status 与 columnId 是一起算、一起写的 (改状态会按列绑定归位到 Done 列), connector 上行回读整行, 所以 payload 里 status 一定存在 —— 是服务端 prisma.task.update 时没把它放进 data。 结果: 列变更落地、状态被丢弃; 下行同步以服务端为权威, 又把本地 status 覆盖 回去, 于是"看板在 Done、详情未开始"。 数据实证 (生产 PG): 9 条顶层任务处于 Done 列 (boundStatus=done) 但 status 非 done (8 条 todo + 1 条 doing), 且全部 doneAt 有值; 反向 (status=done 却 不在 done 列) 0 条 —— 单向故障, 排除下行覆盖/并发冲突。这 9 条的 columnId 与 doneAt 在同一个 update 里都写成功了, 唯独 status 没有, 只可能是它压根没进 data。 修复 (稳妥方案, 复用唯一口径): - resolveStatus.js 新增 resolveTaskUpload(): 客户端上行的是**本地整行**, 字段 总是存在, 不能用"字段是否存在"判断变更, 必须先与服务端现值比较, 再交给 resolveTaskWrite 裁决。不这样做会踩 T-BD-16: 任务从 Plan 拖到未绑定状态的 Action 列时, 整行里 status 仍是 todo, 若当作"本次变更"提交, 场景 B 会把任务 从 Action 归位回 Plan (拖过去又被弹回来)。 - powersync.js Task 更新分支改为调用 resolveTaskUpload, 并把 task.column 与 columnId 一起指向目标列 (否则场景 B 会拿旧列判断, 同样误归位); doneAt 与最终状态保持一致 (进入 done 记时 / 离开 done 清空, 口径同 REST)。 - Project 更新分支补 icon (客户端本地写了 icon, 服务端同样丢弃), 校验口径与 REST 写接口一致: 必须 data:image/ 前缀, 长度 <= 300_000。 - 上传处理器新增按项目缓存的 loadColumns (ops 已按表序排列, BoardColumn 先于 Task 落地, 故 Task 阶段读到的列是本批次最终结果)。 用例 (10 项, 含关键回归与旧思路对照): 仅换列到未绑定列保持列与状态 / 换列到绑定列状态跟随 / 仅改状态归位 / 无变化返回 null / 脏数据自愈 / 子任务豁免 / 无匹配列不移动 / 对照证明旧思路会把 Action 里的任务弹回 Plan。 存量脏数据 (9 条) 需另行订正为 done, 不在本提交内。 --- server/routes/powersync.js | 55 +++++++++++++++++++++++++++-- server/task-domain/resolveStatus.js | 29 +++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/server/routes/powersync.js b/server/routes/powersync.js index 0a6b095..fca5653 100644 --- a/server/routes/powersync.js +++ b/server/routes/powersync.js @@ -7,6 +7,7 @@ import jwt from 'jsonwebtoken' import { prisma } from '../db.js' import { JWT_SECRET, authRequired } from '../middleware.js' import { genUuid, normProjectCode, allocProjectCode, withTaskNumber } from '../task-domain/identity.js' +import { resolveTaskUpload } from '../task-domain/resolveStatus.js' const router = Router() @@ -96,6 +97,17 @@ async function applyPut(op, ctx) { if (d.taskKinds !== undefined) { data.taskKinds = Array.isArray(d.taskKinds) ? d.taskKinds.map((k) => String(k).slice(0, 20)).filter(Boolean) : null } + // 项目图标: data URL (客户端已裁剪为 96x96 JPEG); 校验口径同 REST 写接口 + if (d.icon !== undefined) { + if (d.icon === null || d.icon === '') { + data.icon = null + } else { + const s = String(d.icon) + if (!s.startsWith('data:image/')) throw new Error('图标格式非法') + if (s.length > 300_000) throw new Error('图标过大') + data.icon = s + } + } if (Object.keys(data).length) await prisma.project.update({ where: { uuid }, data }) return {} } @@ -183,18 +195,43 @@ async function applyPut(op, ctx) { if (d.extras !== undefined) data.extras = cleanExtras(d.extras) if (d.sortOrder !== undefined) data.sortOrder = Number(d.sortOrder) || 0 if (d.assigneeId !== undefined) data.assigneeId = d.assigneeId ? Number(d.assigneeId) : null - if (d.doneAt !== undefined) data.doneAt = isoTime(d.doneAt) // 归档状态 (T-ARC): 客户端本地归档/恢复走这里 if (d.archived !== undefined) data.archived = !!d.archived if (d.archivedAt !== undefined) data.archivedAt = isoTime(d.archivedAt) if (d.archiveSource !== undefined) { data.archiveSource = ['manual', 'auto'].includes(d.archiveSource) ? d.archiveSource : null } + + // 列 / 状态: 复用 REST 写接口的同一裁决口径 (server/task-domain/resolveStatus.js) + // 客户端上行的是本地整行 (含 status 与 columnId), 变更检测与裁决 + // 统一封装在 resolveTaskUpload 里 (那边有详细注释与用例) + let nextColumnId if (d.columnId !== undefined) { const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnId } }) if (!col || col.projectId !== existing.projectId) throw new Error('看板列不存在') - data.columnId = col.id + nextColumnId = col.id } + if (d.status !== undefined && !TASK_STATUSES.includes(d.status)) { + throw new Error('任务状态取值非法') + } + const resolved = resolveTaskUpload( + existing, + { columnId: nextColumnId, status: d.status }, + await ctx.loadColumns(existing.projectId), + ) + if (resolved) { + data.status = resolved.status + data.columnId = resolved.columnId + // doneAt 与最终状态保持一致 (口径同 REST 写接口): 进入 done 记时, 离开 done 清空 + if (resolved.status === 'done') { + if (existing.status !== 'done') data.doneAt = isoTime(d.doneAt) || new Date() + } else { + data.doneAt = null + } + } else if (d.doneAt !== undefined) { + data.doneAt = isoTime(d.doneAt) + } + if (d.parentId !== undefined) { if (!d.parentId) { data.parentId = null @@ -355,7 +392,19 @@ router.post('/upload', authRequired, async (req, res, next) => { memberCache.set(projectId, ok) return ok } - const ctx = { userId, assertMember } + // 项目列缓存: 列/状态裁决需要全部列; 同批内按项目缓存 (ops 已按表序排列, + // BoardColumn 先于 Task 落地, 故 Task 阶段读到的列是本次批次的最终结果) + const columnCache = new Map() + const loadColumns = async (projectId) => { + if (columnCache.has(projectId)) return columnCache.get(projectId) + const rows = await prisma.boardColumn.findMany({ + where: { projectId }, + orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + }) + columnCache.set(projectId, rows) + return rows + } + const ctx = { userId, assertMember, loadColumns } // 依赖顺序: 父表先落地 (Project → BoardColumn → ProjectMember → Task → TaskMember/TaskTag); 删除反向 const rank = (o) => TABLE_RANK[o?.table] ?? 99 diff --git a/server/task-domain/resolveStatus.js b/server/task-domain/resolveStatus.js index bcc3993..a69eed5 100644 --- a/server/task-domain/resolveStatus.js +++ b/server/task-domain/resolveStatus.js @@ -83,3 +83,32 @@ export function resolveTaskWrite(task, patch, columns) { return { status, columnId } } + +/** + * 离线同步上传路径专用: 客户端上行的是**本地整行**, 字段总是存在, 因此不能用 + * "字段是否存在"判断变更 —— 必须先与服务端现值比较, 得出"这次真正改了什么", + * 再交给 resolveTaskWrite 裁决。 + * + * 不这样做会踩的坑 (T-BD-16): 任务从 Plan 拖到未绑定状态的 Action 列, 客户端 + * 整行里 status 仍是 todo; 若把 status 当作"本次变更"提交, 场景 B 会把任务从 + * Action 归位回第一个绑定 todo 的 Plan —— 用户拖到 Action 又被弹回去。 + * + * @param {object} existing 服务端现有任务 (含 columnId/status/parentId) + * @param {object} incoming 本次上行值 { columnId?: number, status?: string } + * 外键需调用方先由 uuid 解析为内部 int id + * @param {object[]} columns 项目全部列 + * @returns {{ status: string, columnId: number } | null} null = 列/状态无变化 + */ +export function resolveTaskUpload(existing, incoming, columns) { + const nextColumnId = incoming?.columnId !== undefined ? incoming.columnId : existing.columnId + const patch = {} + if (nextColumnId !== existing.columnId) patch.columnId = nextColumnId + if (incoming?.status !== undefined && incoming.status !== existing.status) patch.status = incoming.status + if (!Object.keys(patch).length) return null + + // task.column 必须与 columnId 一起指向目标列, 否则场景 B 的 + // "当前列已匹配该状态" 会拿旧列判断, 同样导致误归位 + const targetCol = (columns || []).find((c) => c.id === nextColumnId) || null + const r = resolveTaskWrite({ ...existing, columnId: nextColumnId, column: targetCol }, patch, columns) + return { status: r.status, columnId: r.columnId } +}