// PowerSync 接入: 同步 token 签发 + 客户端上传队列落地 (0.2.1, 见 docs/V0.2/离线编辑模块/0.2.1实施方案.md) // 下行由 powersync-service 经 PG 逻辑复制完成; 本文件只负责: // 1. /token 为已登录用户签发 powersync-service 接受的 JWT (aud=powersync, 独立密钥可选) // 2. /upload 批量落地客户端离线写操作, 按 uuid 幂等 upsert, 服务端统一分配编号 import { Router } from 'express' 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' const router = Router() // ---------- 同步 token ---------- router.post('/token', authRequired, async (req, res, next) => { try { const user = await prisma.user.findUnique({ where: { id: req.user.sub }, select: { id: true, name: true, email: true }, }) if (!user) return res.status(401).json({ error: '用户不存在' }) const secret = process.env.POWERSYNC_JWT_SECRET || JWT_SECRET const token = jwt.sign( { sub: String(user.id), aud: 'powersync', name: user.name, email: user.email }, secret, { expiresIn: '12h' }, ) res.json({ token, endpoint: process.env.POWERSYNC_URL || '' }) } catch (e) { next(e) } }) // ---------- 上传队列 ---------- // 契约: POST { ops: [{ id, table, op: 'PUT'|'DELETE', uuid, data }] } // table ∈ Project | BoardColumn | ProjectMember | Task | TaskMember | TaskTag // data 为客户端本地全量行 (列名与客户端 schema 一致); 外键列 (projectId/columnId/parentId/taskId) // 的值即父行的本地 id = uuid, 服务端据此解析为内部自增 id // 返回 { results: [{ id, ok, skipped?, error? }] }, 单条失败不影响同批其它 op const TABLES = ['Project', 'BoardColumn', 'ProjectMember', 'Task', 'TaskMember', 'TaskTag'] const TABLE_RANK = Object.fromEntries(TABLES.map((t, i) => [t, i])) const COLUMN_RETENTIONS = ['never', 'today', '3d', 'week', 'month', 'forever'] const TASK_STATUSES = ['todo', 'doing', 'done'] function isoTime(v) { if (!v) return null const d = new Date(v) return Number.isNaN(d.getTime()) ? null : d } // LWW: 服务端已有行不早于客户端数据 => 跳过本次覆盖 function isStale(existing, data) { const t = isoTime(data?.updatedAt) if (!t) return false return existing.updatedAt && existing.updatedAt.getTime() >= t.getTime() } function cleanExtras(v) { if (!Array.isArray(v)) return [] return v.slice(0, 50).map((e) => ({ label: String(e?.label ?? '').slice(0, 40), value: String(e?.value ?? ''), url: e?.url ? String(e.url) : '', })) } function cleanNotes(v) { if (!Array.isArray(v)) return [] return v.slice(0, 50).map((n) => ({ title: String(n?.title ?? '').slice(0, 50), content: String(n?.content ?? '').slice(0, 20000), })) } async function applyPut(op, ctx) { const { userId, assertMember } = ctx const { table, uuid } = op const d = op.data || {} if (!uuid) throw new Error('缺少 uuid') if (table === 'Project') { const existing = await prisma.project.findUnique({ where: { uuid } }) if (existing) { if (!(await assertMember(existing.id))) throw new Error('无项目权限') if (isStale(existing, d)) return { skipped: true } const data = {} if (d.name !== undefined) { const t = String(d.name).trim() if (!t || t.length > 20) throw new Error('项目名称非法') data.name = t } if (d.archived !== undefined) data.archived = !!d.archived if (d.notes !== undefined) data.notes = cleanNotes(d.notes) if (d.taskKinds !== undefined) { data.taskKinds = Array.isArray(d.taskKinds) ? d.taskKinds.map((k) => String(k).slice(0, 20)).filter(Boolean) : null } if (Object.keys(data).length) await prisma.project.update({ where: { uuid }, data }) return {} } const name = String(d.name || '').trim() if (!name || name.length > 20) throw new Error('项目名称非法') const code = normProjectCode(d.code) || (await allocProjectCode(name)) const row = await prisma.project.create({ data: { uuid, code, name, ownerId: userId, isPersonal: !!d.isPersonal, archived: !!d.archived }, }) return { rowId: row.id, code: row.code } } if (table === 'BoardColumn') { const existing = await prisma.boardColumn.findUnique({ where: { uuid } }) if (existing) { if (!(await assertMember(existing.projectId))) throw new Error('无项目权限') if (isStale(existing, d)) return { skipped: true } const data = {} if (d.name !== undefined) { const t = String(d.name).trim() if (!t || t.length > 20) throw new Error('列名非法') data.name = t } if (d.sortOrder !== undefined) data.sortOrder = Number(d.sortOrder) || 0 if (d.boundStatus !== undefined) data.boundStatus = TASK_STATUSES.includes(d.boundStatus) ? d.boundStatus : null if (d.retention !== undefined && COLUMN_RETENTIONS.includes(d.retention)) data.retention = d.retention if (Object.keys(data).length) await prisma.boardColumn.update({ where: { uuid }, data }) return {} } const project = await prisma.project.findUnique({ where: { uuid: d.projectId } }) if (!project || !(await assertMember(project.id))) throw new Error('项目不存在或无权限') const name = String(d.name || '').trim() if (!name || name.length > 20) throw new Error('列名非法') await prisma.boardColumn.create({ data: { uuid, projectId: project.id, name, sortOrder: Number(d.sortOrder) || 0, boundStatus: TASK_STATUSES.includes(d.boundStatus) ? d.boundStatus : null, retention: COLUMN_RETENTIONS.includes(d.retention) ? d.retention : 'forever', }, }) return {} } if (table === 'ProjectMember') { const existing = await prisma.projectMember.findUnique({ where: { uuid } }) if (existing) { // 成员关系只增不改: 已存在即视为完成 (幂等) return { skipped: true } } const project = await prisma.project.findUnique({ where: { uuid: d.projectId } }) if (!project || !(await assertMember(project.id))) throw new Error('项目不存在或无权限') const target = Number(d.userId) if (!target) throw new Error('userId 非法') const dup = await prisma.projectMember.findUnique({ where: { projectId_userId: { projectId: project.id, userId: target } }, }) if (dup) return { skipped: true } await prisma.projectMember.create({ data: { uuid, projectId: project.id, userId: target, permission: d.permission === 'admin' ? 'admin' : 'member' }, }) return {} } if (table === 'Task') { const existing = await prisma.task.findUnique({ where: { uuid } }) if (existing) { if (!(await assertMember(existing.projectId))) throw new Error('无项目权限') if (isStale(existing, d)) return { skipped: true } const data = {} if (d.title !== undefined) { const t = String(d.title).trim() if (!t || t.length > 30) throw new Error('任务标题非法') data.title = t } if (d.description !== undefined) data.description = String(d.description || '') if (d.kind !== undefined) data.kind = d.kind ? String(d.kind).trim().slice(0, 20) : null if (d.markDate !== undefined) { const m = isoTime(d.markDate) if (d.markDate && !m) throw new Error('时间标记非法') data.markDate = m } 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) 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 } if (d.parentId !== undefined) { if (!d.parentId) { data.parentId = null } else { const parent = await prisma.task.findUnique({ where: { uuid: d.parentId } }) if (!parent || parent.projectId !== existing.projectId) throw new Error('父任务不存在') if (parent.parentId) throw new Error('仅支持一级子任务') data.parentId = parent.id } } if (Object.keys(data).length) await prisma.task.update({ where: { uuid }, data }) return {} } // 离线新建: 服务端解析父引用并分配项目内编号 const project = await prisma.project.findUnique({ where: { uuid: d.projectId } }) if (!project || !(await assertMember(project.id))) throw new Error('项目不存在或无权限') const title = String(d.title || '').trim() if (!title || title.length > 30) throw new Error('任务标题非法') let columnId = null if (d.columnId) { const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnId } }) if (!col || col.projectId !== project.id) throw new Error('看板列不存在') columnId = col.id } else { const first = await prisma.boardColumn.findFirst({ where: { projectId: project.id }, orderBy: { sortOrder: 'asc' }, }) if (!first) throw new Error('项目没有可用的看板列') columnId = first.id } let parentId = null if (d.parentId) { const parent = await prisma.task.findUnique({ where: { uuid: d.parentId } }) if (!parent || parent.projectId !== project.id) throw new Error('父任务不存在') if (parent.parentId) throw new Error('仅支持一级子任务') parentId = parent.id } const status = TASK_STATUSES.includes(d.status) ? d.status : 'todo' const base = { uuid, projectId: project.id, columnId, parentId, title, description: String(d.description || ''), kind: d.kind ? String(d.kind).trim().slice(0, 20) : null, markDate: isoTime(d.markDate), status, assigneeId: d.assigneeId ? Number(d.assigneeId) : null, creatorId: userId, extras: cleanExtras(d.extras), doneAt: status === 'done' ? (isoTime(d.doneAt) || new Date()) : null, sortOrder: Number(d.sortOrder) || 0, } const { value: row } = await withTaskNumber(project.id, (number) => prisma.task.create({ data: { ...base, number } }), ) return { rowId: row.id, number: row.number } } if (table === 'TaskMember') { const existing = await prisma.taskMember.findUnique({ where: { uuid } }) if (existing) return { skipped: true } const task = await prisma.task.findUnique({ where: { uuid: d.taskId } }) if (!task || !(await assertMember(task.projectId))) throw new Error('任务不存在或无权限') const uid = Number(d.userId) if (!uid) throw new Error('userId 非法') const dup = await prisma.taskMember.findUnique({ where: { taskId_userId: { taskId: task.id, userId: uid } }, }) if (dup) return { skipped: true } await prisma.taskMember.create({ data: { uuid, taskId: task.id, userId: uid } }) return {} } if (table === 'TaskTag') { const existing = await prisma.taskTag.findUnique({ where: { uuid } }) if (existing) return { skipped: true } const task = await prisma.task.findUnique({ where: { uuid: d.taskId } }) if (!task || !(await assertMember(task.projectId))) throw new Error('任务不存在或无权限') const name = String(d.name || '').trim().slice(0, 20) if (!name) throw new Error('标签名非法') const dup = await prisma.taskTag.findFirst({ where: { taskId: task.id, name } }) if (dup) return { skipped: true } await prisma.taskTag.create({ data: { uuid, taskId: task.id, name } }) return {} } throw new Error('未知表: ' + table) } async function applyDelete(op, ctx) { const { assertMember } = ctx const { table, uuid } = op if (!uuid) throw new Error('缺少 uuid') if (table === 'Project') { const row = await prisma.project.findUnique({ where: { uuid }, select: { id: true, isPersonal: true } }) if (!row) return { skipped: true } if (!(await assertMember(row.id))) throw new Error('无项目权限') if (row.isPersonal) throw new Error('个人默认项目不可删除') await prisma.project.delete({ where: { uuid } }) return {} } if (table === 'BoardColumn') { const row = await prisma.boardColumn.findUnique({ where: { uuid } }) if (!row) return { skipped: true } if (!(await assertMember(row.projectId))) throw new Error('无项目权限') await prisma.boardColumn.delete({ where: { uuid } }) return {} } if (table === 'ProjectMember') { const row = await prisma.projectMember.findUnique({ where: { uuid } }) if (!row) return { skipped: true } if (!(await assertMember(row.projectId))) throw new Error('无项目权限') await prisma.projectMember.delete({ where: { uuid } }) return {} } if (table === 'Task') { const row = await prisma.task.findUnique({ where: { uuid } }) if (!row) return { skipped: true } if (!(await assertMember(row.projectId))) throw new Error('无项目权限') await prisma.task.delete({ where: { uuid } }) return {} } if (table === 'TaskMember') { const row = await prisma.taskMember.findUnique({ where: { uuid } }) if (!row) return { skipped: true } const task = await prisma.task.findUnique({ where: { id: row.taskId }, select: { projectId: true } }) if (!task || !(await assertMember(task.projectId))) throw new Error('无项目权限') await prisma.taskMember.delete({ where: { uuid } }) return {} } if (table === 'TaskTag') { const row = await prisma.taskTag.findUnique({ where: { uuid } }) if (!row) return { skipped: true } const task = await prisma.task.findUnique({ where: { id: row.taskId }, select: { projectId: true } }) if (!task || !(await assertMember(task.projectId))) throw new Error('无项目权限') await prisma.taskTag.delete({ where: { uuid } }) return {} } throw new Error('未知表: ' + table) } router.post('/upload', authRequired, async (req, res, next) => { try { const userId = req.user.sub const ops = Array.isArray(req.body?.ops) ? req.body.ops : [] if (!ops.length) return res.status(400).json({ error: 'ops 不能为空' }) if (ops.length > 500) return res.status(400).json({ error: '单批 ops 过多 (上限 500)' }) const memberCache = new Map() const assertMember = async (projectId) => { if (!projectId) return false if (memberCache.has(projectId)) return memberCache.get(projectId) const ok = !!(await prisma.projectMember.findFirst({ where: { projectId, userId } })) memberCache.set(projectId, ok) return ok } const ctx = { userId, assertMember } // 依赖顺序: 父表先落地 (Project → BoardColumn → ProjectMember → Task → TaskMember/TaskTag); 删除反向 const rank = (o) => TABLE_RANK[o?.table] ?? 99 const puts = ops.filter((o) => o && o.op !== 'DELETE').sort((a, b) => rank(a) - rank(b)) const dels = ops.filter((o) => o && o.op === 'DELETE').sort((a, b) => rank(b) - rank(a)) const results = [] for (const op of puts) { try { const extra = await applyPut(op, ctx) results.push({ id: op.id, ok: true, ...extra }) } catch (e) { results.push({ id: op.id, ok: false, error: e.message }) } } for (const op of dels) { try { const extra = await applyDelete(op, ctx) results.push({ id: op.id, ok: true, ...extra }) } catch (e) { results.push({ id: op.id, ok: false, error: e.message }) } } res.json({ results }) } catch (e) { next(e) } }) export default router