diff --git a/server/index.js b/server/index.js index c4ff9c9..8c3a87c 100644 --- a/server/index.js +++ b/server/index.js @@ -16,6 +16,7 @@ import checkinRouter from './routes/checkin.js' import checklistsRouter from './routes/checklists.js' import projectsRouter, { columnRouter } from './routes/projects.js' import tasksRouter from './routes/tasks.js' +import powersyncRouter from './routes/powersync.js' const PORT = Number(process.env.PORT) || 3001 const DIST_DIR = path.join(process.cwd(), 'dist') @@ -33,6 +34,8 @@ app.use('/api/auth', authRouter) // OTA 接口是公开的 (检查/下载更新不需要登录), 必须挂载在带 authRequired 的 router 之前, // 否则 /api/ota/* 请求会先进入 dataRouter 等并被 authRequired 以 401 拦截 app.use('/api', otaRouter) +// PowerSync: token 签发 + 离线上行队列 (自带 authRequired, 同样必须挂在带 authRequired 的业务 router 之前) +app.use('/api/powersync', powersyncRouter) app.use('/api', dataRouter) app.use('/api', newsRouter) app.use('/api', alertsRouter) diff --git a/server/routes/powersync.js b/server/routes/powersync.js new file mode 100644 index 0000000..4a7cdd4 --- /dev/null +++ b/server/routes/powersync.js @@ -0,0 +1,375 @@ +// 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 为客户端本地全量行; 父引用走 *Uuid 字段 (projectUuid / columnUuid / parentUuid / taskUuid) +// 返回 { 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.projectUuid } }) + 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.projectUuid } }) + 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.columnUuid !== undefined) { + const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnUuid } }) + if (!col || col.projectId !== existing.projectId) throw new Error('看板列不存在') + data.columnId = col.id + } + if (d.parentUuid !== undefined) { + if (!d.parentUuid) { + data.parentId = null + } else { + const parent = await prisma.task.findUnique({ where: { uuid: d.parentUuid } }) + 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.projectUuid } }) + 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.columnUuid) { + const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnUuid } }) + 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.parentUuid) { + const parent = await prisma.task.findUnique({ where: { uuid: d.parentUuid } }) + 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.taskUuid } }) + 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.taskUuid } }) + 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 diff --git a/server/routes/projects.js b/server/routes/projects.js index 6eecd6f..b21e331 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -3,6 +3,7 @@ import { Router } from 'express' import { prisma } from '../db.js' import { authRequired } from '../middleware.js' import { loadProjectForUser, loadColumns, loadMemberIds } from '../task-domain/access.js' +import { genUuid, withUuid, normProjectCode, allocProjectCode } from '../task-domain/identity.js' const router = Router() router.use(authRequired) @@ -21,7 +22,7 @@ const MAX_NAME = 20 /** 建 4 个默认列 */ async function createDefaultColumns(projectId) { await prisma.boardColumn.createMany({ - data: DEFAULT_COLUMNS.map((c) => ({ ...c, projectId, retention: 'forever' })), + data: withUuid(DEFAULT_COLUMNS.map((c) => ({ ...c, projectId, retention: 'forever' }))), }) } @@ -32,7 +33,14 @@ export async function ensurePersonalProject(userId) { const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true } }) const name = `${user?.name || '我'}的任务` const project = await prisma.project.create({ - data: { name, ownerId: userId, isPersonal: true, members: { create: { userId } } }, + data: { + name, + ownerId: userId, + isPersonal: true, + uuid: genUuid(), + code: await allocProjectCode(name), + members: { create: { uuid: genUuid(), userId } }, + }, }) await createDefaultColumns(project.id) return project @@ -55,6 +63,8 @@ export function normTaskKinds(v) { function projectSummary(p) { return { id: p.id, + uuid: p.uuid, + code: p.code, name: p.name, ownerId: p.ownerId, isPersonal: p.isPersonal, @@ -92,8 +102,27 @@ router.post('/', async (req, res, next) => { if (!name) return res.status(400).json({ error: '项目名称不能为空' }) if (name.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` }) + // 项目短编号: 可由客户端自拟 (2~8 位字母数字, 全局唯一), 未提供时按名称自动分配 + let code + const rawCode = req.body?.code + if (rawCode !== undefined && rawCode !== null && String(rawCode).trim() !== '') { + code = normProjectCode(rawCode) + if (!code) return res.status(400).json({ error: '项目编号须为 2~8 位字母数字' }) + const dup = await prisma.project.findUnique({ where: { code } }) + if (dup) return res.status(409).json({ error: `项目编号 ${code} 已被占用` }) + } else { + code = await allocProjectCode(name) + } + const project = await prisma.project.create({ - data: { name, ownerId: userId, isPersonal: false, members: { create: { userId } } }, + data: { + name, + ownerId: userId, + isPersonal: false, + uuid: genUuid(), + code, + members: { create: { uuid: genUuid(), userId } }, + }, }) await createDefaultColumns(project.id) const full = await prisma.project.findUnique({ @@ -135,7 +164,7 @@ router.put('/:id', async (req, res, next) => { const userId = req.user.sub const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) - const { name, archived, notes, taskKinds, icon } = req.body || {} + const { name, archived, notes, taskKinds, icon, code } = req.body || {} const data = {} if (name !== undefined) { const t = String(name).trim() @@ -143,6 +172,16 @@ router.put('/:id', async (req, res, next) => { if (t.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` }) data.name = t } + // 项目短编号: 可修改 (2~8 位字母数字, 全局唯一) + if (code !== undefined) { + const c = normProjectCode(code) + if (!c) return res.status(400).json({ error: '项目编号须为 2~8 位字母数字' }) + if (c !== project.code) { + const dup = await prisma.project.findUnique({ where: { code: c } }) + if (dup) return res.status(409).json({ error: `项目编号 ${c} 已被占用` }) + data.code = c + } + } // 任务类型标签: 整体替换; 空数组回落默认 if (taskKinds !== undefined) { if (!Array.isArray(taskKinds)) return res.status(400).json({ error: '任务类型格式非法' }) @@ -206,7 +245,7 @@ router.post('/:id/members', async (req, res, next) => { }) if (exist) return res.status(409).json({ error: '该用户已是项目成员' }) - await prisma.projectMember.create({ data: { projectId: project.id, userId: target.id } }) + await prisma.projectMember.create({ data: { uuid: genUuid(), projectId: project.id, userId: target.id } }) res.json({ ok: true, member: { userId: target.id, name: target.name } }) } catch (e) { next(e) } }) diff --git a/server/routes/tasks.js b/server/routes/tasks.js index b62f235..1ad8123 100644 --- a/server/routes/tasks.js +++ b/server/routes/tasks.js @@ -8,6 +8,7 @@ import { import { resolveTaskWrite } from '../task-domain/resolveStatus.js' import { filterVisible } from '../task-domain/retention.js' import { copyTask } from '../task-domain/copyTask.js' +import { genUuid, withUuid, withTaskNumber } from '../task-domain/identity.js' const router = Router() router.use(authRequired) @@ -101,28 +102,30 @@ router.post('/projects/:id/tasks', async (req, res, next) => { } const firstCol = columns[0] - const row = await prisma.task.create({ - data: { - projectId: project.id, - columnId: firstCol.id, - parentId: pid, - title: cleanTitle, - description: String(description || ''), - kind: kind ? String(kind).trim().slice(0, 20) : null, - markDate: mark, - status: firstCol.boundStatus || 'todo', - assigneeId: finalAssignee, - creatorId: userId, - extras: [], - members: { - create: (Array.isArray(memberIds) ? memberIds : []) - .map(Number).filter((id) => memberSet.has(id)) - .map((uid) => ({ userId: uid })), - }, - tags: { create: (Array.isArray(tags) ? tags : []).filter(Boolean).map((n) => ({ name: String(n).slice(0, 20) })) }, + const taskData = { + uuid: req.body?.uuid || genUuid(), + projectId: project.id, + columnId: firstCol.id, + parentId: pid, + title: cleanTitle, + description: String(description || ''), + kind: kind ? String(kind).trim().slice(0, 20) : null, + markDate: mark, + status: firstCol.boundStatus || 'todo', + assigneeId: finalAssignee, + creatorId: userId, + extras: [], + members: { + create: withUuid((Array.isArray(memberIds) ? memberIds : []) + .map(Number).filter((id) => memberSet.has(id)) + .map((uid) => ({ userId: uid }))), }, - include: TASK_INCLUDE, - }) + tags: { create: withUuid((Array.isArray(tags) ? tags : []).filter(Boolean).map((n) => ({ name: String(n).slice(0, 20) }))) }, + } + // 项目内自增编号 (展示 `code-number`): 服务端分配, 并发冲突时重取 max+1 重试 + const { value: row } = await withTaskNumber(project.id, (number) => + prisma.task.create({ data: { ...taskData, number }, include: TASK_INCLUDE }), + ) res.json({ item: shape(row) }) } catch (e) { next(e) } }) @@ -250,7 +253,7 @@ router.put('/tasks/:id', async (req, res, next) => { await prisma.taskMember.deleteMany({ where: { taskId: task.id } }) if (ids.length) { await prisma.taskMember.createMany({ - data: ids.map((uid) => ({ taskId: task.id, userId: uid })), + data: withUuid(ids.map((uid) => ({ taskId: task.id, userId: uid }))), }) } } @@ -258,7 +261,7 @@ router.put('/tasks/:id', async (req, res, next) => { await prisma.taskTag.deleteMany({ where: { taskId: task.id } }) const names = body.tags.map((n) => String(n).trim()).filter(Boolean).slice(0, 10) if (names.length) { - await prisma.taskTag.createMany({ data: names.map((name) => ({ taskId: task.id, name: name.slice(0, 20) })) }) + await prisma.taskTag.createMany({ data: withUuid(names.map((name) => ({ taskId: task.id, name: name.slice(0, 20) }))) }) } } diff --git a/server/task-domain/copyTask.js b/server/task-domain/copyTask.js index 1af23f5..fc8cf0b 100644 --- a/server/task-domain/copyTask.js +++ b/server/task-domain/copyTask.js @@ -1,6 +1,7 @@ // 任务模块: 跨项目拷贝 (T-CP-*) // 拷贝而非移动: 新建记录 + 深拷贝子任务, 源任务零改动. import { prisma } from '../db.js' +import { genUuid, withUuid, withTaskNumber } from './identity.js' /** * 拷贝一条任务(含子任务)到目标项目。 @@ -38,6 +39,7 @@ export async function copyTask({ source, targetProject, targetColumns, operatorI const droppedAssignee = source.assigneeId && !memberSet.has(source.assigneeId) ? source.assignee : null const baseData = (extra) => ({ + uuid: genUuid(), projectId: targetProject.id, title: source.title, description: source.description || '', @@ -50,33 +52,39 @@ export async function copyTask({ source, targetProject, targetColumns, operatorI doneAt: source.status === 'done' ? (source.doneAt || new Date()) : null, sortOrder: source.sortOrder ?? 0, tags: { - create: (source.tags || []).map((t) => ({ name: t.name })), + create: withUuid((source.tags || []).map((t) => ({ name: t.name }))), }, }) - const newTask = await prisma.task.create({ - data: { - ...baseData({ columnId: pickColumn(source.columnId) }), - members: { - create: keptMemberIds.map((uid) => ({ userId: uid })), + const { value: newTask } = await withTaskNumber(targetProject.id, (number) => + prisma.task.create({ + data: { + ...baseData({ columnId: pickColumn(source.columnId) }), + number, + members: { + create: withUuid(keptMemberIds.map((uid) => ({ userId: uid }))), + }, }, - }, - }) + }), + ) // 子任务: 同法复制, parentId 指向新父任务 for (const child of source.children || []) { const childMembers = (child.members || []) .filter((m) => memberSet.has(m.userId)) .map((m) => m.userId) - await prisma.task.create({ - data: { - ...baseData({ - columnId: pickColumn(child.columnId), - parentId: newTask.id, - }), - members: { create: childMembers.map((uid) => ({ userId: uid })) }, - }, - }) + await withTaskNumber(targetProject.id, (number) => + prisma.task.create({ + data: { + ...baseData({ + columnId: pickColumn(child.columnId), + parentId: newTask.id, + }), + number, + members: { create: withUuid(childMembers.map((uid) => ({ userId: uid }))) }, + }, + }), + ) } return { diff --git a/server/task-domain/identity.js b/server/task-domain/identity.js new file mode 100644 index 0000000..a0fdc66 --- /dev/null +++ b/server/task-domain/identity.js @@ -0,0 +1,74 @@ +// 任务域同步标识与编号 (0.2.1 离线编辑, 见 docs/V0.2/离线编辑模块/0.2.1实施方案.md) +// 约定: 任务域 6 表均带 uuid(客户端/服务端创建时生成, 上行按 uuid 幂等); +// Project.code 为用户自拟 2~8 位字母数字短编号(存储大写, 全局唯一); +// Task.number 为项目内自增编号, 服务端分配, 展示 `code-number`。 +import crypto from 'node:crypto' +import { prisma } from '../db.js' + +/** 同步标识: 服务端创建行时统一生成 uuid */ +export function genUuid() { + return crypto.randomUUID() +} + +/** 为多行数据补 uuid (createMany 前用) */ +export function withUuid(rows) { + return rows.map((r) => ({ ...r, uuid: r.uuid || genUuid() })) +} + +// 项目编号: 2~8 位字母数字, 存储统一大写 (任务展示 `编号-序号`, 不允许连字符) +const CODE_RE = /^[A-Z0-9]{2,8}$/ + +/** 归一化项目编号; 非法返回 null */ +export function normProjectCode(v) { + const s = String(v || '').trim().toUpperCase() + return CODE_RE.test(s) ? s : null +} + +function randCode(len) { + return crypto.randomBytes(len * 2).toString('hex').toUpperCase().replace(/[^A-Z0-9]/g, '').padEnd(len, 'X').slice(0, len) +} + +/** 从项目名推导候选编号: 取 ASCII 字母数字, 不足则随机补齐 */ +function suggestCode(name) { + const letters = String(name || '').toUpperCase().replace(/[^A-Z0-9]/g, '') + if (letters.length >= 2) return letters.slice(0, 8) + return (letters + randCode(8)).slice(0, 8) +} + +/** 分配一个未占用的项目编号: 优先按名称推导, 冲突则随机尾缀重试 */ +export async function allocProjectCode(name) { + const base = suggestCode(name) + for (let i = 0; i < 20; i++) { + const cand = i === 0 ? base : (base.slice(0, 5) + randCode(3)).slice(0, 8) + if (cand.length < 2 || !CODE_RE.test(cand)) continue + const dup = await prisma.project.findUnique({ where: { code: cand } }) + if (!dup) return cand + } + while (true) { + const cand = randCode(6) + const dup = await prisma.project.findUnique({ where: { code: cand } }) + if (!dup) return cand + } +} + +/** 任务项目内下一个编号 (max+1); 并发冲突由调用方捕获 P2002 重试 */ +export async function nextTaskNumber(projectId) { + const agg = await prisma.task.aggregate({ where: { projectId }, _max: { number: true } }) + return (agg._max.number ?? 0) + 1 +} + +/** Task.number 唯一冲突重试包装: fn(number) 返回 Promise; 冲突时重取编号, 最多 3 次 */ +export async function withTaskNumber(projectId, fn) { + let lastErr = null + for (let attempt = 0; attempt < 3; attempt++) { + const number = await nextTaskNumber(projectId) + try { + return { number, value: await fn(number) } + } catch (e) { + const target = Array.isArray(e?.meta?.target) ? e.meta.target.join(',') : String(e?.meta?.target || '') + lastErr = e + if (!(e?.code === 'P2002' && target.includes('number'))) throw e + } + } + throw lastErr +}