feat(0.2.1): M2 服务端同步接入 —— /api/powersync token+upload 幂等上行, 项目/任务/拷贝链路接入 uuid 与编号分配 (identity 助手)
This commit is contained in:
@@ -16,6 +16,7 @@ import checkinRouter from './routes/checkin.js'
|
|||||||
import checklistsRouter from './routes/checklists.js'
|
import checklistsRouter from './routes/checklists.js'
|
||||||
import projectsRouter, { columnRouter } from './routes/projects.js'
|
import projectsRouter, { columnRouter } from './routes/projects.js'
|
||||||
import tasksRouter from './routes/tasks.js'
|
import tasksRouter from './routes/tasks.js'
|
||||||
|
import powersyncRouter from './routes/powersync.js'
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 3001
|
const PORT = Number(process.env.PORT) || 3001
|
||||||
const DIST_DIR = path.join(process.cwd(), 'dist')
|
const DIST_DIR = path.join(process.cwd(), 'dist')
|
||||||
@@ -33,6 +34,8 @@ app.use('/api/auth', authRouter)
|
|||||||
// OTA 接口是公开的 (检查/下载更新不需要登录), 必须挂载在带 authRequired 的 router 之前,
|
// OTA 接口是公开的 (检查/下载更新不需要登录), 必须挂载在带 authRequired 的 router 之前,
|
||||||
// 否则 /api/ota/* 请求会先进入 dataRouter 等并被 authRequired 以 401 拦截
|
// 否则 /api/ota/* 请求会先进入 dataRouter 等并被 authRequired 以 401 拦截
|
||||||
app.use('/api', otaRouter)
|
app.use('/api', otaRouter)
|
||||||
|
// PowerSync: token 签发 + 离线上行队列 (自带 authRequired, 同样必须挂在带 authRequired 的业务 router 之前)
|
||||||
|
app.use('/api/powersync', powersyncRouter)
|
||||||
app.use('/api', dataRouter)
|
app.use('/api', dataRouter)
|
||||||
app.use('/api', newsRouter)
|
app.use('/api', newsRouter)
|
||||||
app.use('/api', alertsRouter)
|
app.use('/api', alertsRouter)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,6 +3,7 @@ import { Router } from 'express'
|
|||||||
import { prisma } from '../db.js'
|
import { prisma } from '../db.js'
|
||||||
import { authRequired } from '../middleware.js'
|
import { authRequired } from '../middleware.js'
|
||||||
import { loadProjectForUser, loadColumns, loadMemberIds } from '../task-domain/access.js'
|
import { loadProjectForUser, loadColumns, loadMemberIds } from '../task-domain/access.js'
|
||||||
|
import { genUuid, withUuid, normProjectCode, allocProjectCode } from '../task-domain/identity.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
router.use(authRequired)
|
router.use(authRequired)
|
||||||
@@ -21,7 +22,7 @@ const MAX_NAME = 20
|
|||||||
/** 建 4 个默认列 */
|
/** 建 4 个默认列 */
|
||||||
async function createDefaultColumns(projectId) {
|
async function createDefaultColumns(projectId) {
|
||||||
await prisma.boardColumn.createMany({
|
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 user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true } })
|
||||||
const name = `${user?.name || '我'}的任务`
|
const name = `${user?.name || '我'}的任务`
|
||||||
const project = await prisma.project.create({
|
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)
|
await createDefaultColumns(project.id)
|
||||||
return project
|
return project
|
||||||
@@ -55,6 +63,8 @@ export function normTaskKinds(v) {
|
|||||||
function projectSummary(p) {
|
function projectSummary(p) {
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
|
uuid: p.uuid,
|
||||||
|
code: p.code,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
ownerId: p.ownerId,
|
ownerId: p.ownerId,
|
||||||
isPersonal: p.isPersonal,
|
isPersonal: p.isPersonal,
|
||||||
@@ -92,8 +102,27 @@ router.post('/', async (req, res, next) => {
|
|||||||
if (!name) return res.status(400).json({ error: '项目名称不能为空' })
|
if (!name) return res.status(400).json({ error: '项目名称不能为空' })
|
||||||
if (name.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` })
|
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({
|
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)
|
await createDefaultColumns(project.id)
|
||||||
const full = await prisma.project.findUnique({
|
const full = await prisma.project.findUnique({
|
||||||
@@ -135,7 +164,7 @@ router.put('/:id', async (req, res, next) => {
|
|||||||
const userId = req.user.sub
|
const userId = req.user.sub
|
||||||
const project = await loadProjectForUser(req.params.id, userId)
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
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 = {}
|
const data = {}
|
||||||
if (name !== undefined) {
|
if (name !== undefined) {
|
||||||
const t = String(name).trim()
|
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} 字` })
|
if (t.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` })
|
||||||
data.name = t
|
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 (taskKinds !== undefined) {
|
||||||
if (!Array.isArray(taskKinds)) return res.status(400).json({ error: '任务类型格式非法' })
|
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: '该用户已是项目成员' })
|
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 } })
|
res.json({ ok: true, member: { userId: target.id, name: target.name } })
|
||||||
} catch (e) { next(e) }
|
} catch (e) { next(e) }
|
||||||
})
|
})
|
||||||
|
|||||||
+13
-10
@@ -8,6 +8,7 @@ import {
|
|||||||
import { resolveTaskWrite } from '../task-domain/resolveStatus.js'
|
import { resolveTaskWrite } from '../task-domain/resolveStatus.js'
|
||||||
import { filterVisible } from '../task-domain/retention.js'
|
import { filterVisible } from '../task-domain/retention.js'
|
||||||
import { copyTask } from '../task-domain/copyTask.js'
|
import { copyTask } from '../task-domain/copyTask.js'
|
||||||
|
import { genUuid, withUuid, withTaskNumber } from '../task-domain/identity.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
router.use(authRequired)
|
router.use(authRequired)
|
||||||
@@ -101,8 +102,8 @@ router.post('/projects/:id/tasks', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const firstCol = columns[0]
|
const firstCol = columns[0]
|
||||||
const row = await prisma.task.create({
|
const taskData = {
|
||||||
data: {
|
uuid: req.body?.uuid || genUuid(),
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
columnId: firstCol.id,
|
columnId: firstCol.id,
|
||||||
parentId: pid,
|
parentId: pid,
|
||||||
@@ -115,14 +116,16 @@ router.post('/projects/:id/tasks', async (req, res, next) => {
|
|||||||
creatorId: userId,
|
creatorId: userId,
|
||||||
extras: [],
|
extras: [],
|
||||||
members: {
|
members: {
|
||||||
create: (Array.isArray(memberIds) ? memberIds : [])
|
create: withUuid((Array.isArray(memberIds) ? memberIds : [])
|
||||||
.map(Number).filter((id) => memberSet.has(id))
|
.map(Number).filter((id) => memberSet.has(id))
|
||||||
.map((uid) => ({ userId: uid })),
|
.map((uid) => ({ userId: uid }))),
|
||||||
},
|
},
|
||||||
tags: { create: (Array.isArray(tags) ? tags : []).filter(Boolean).map((n) => ({ name: String(n).slice(0, 20) })) },
|
tags: { create: withUuid((Array.isArray(tags) ? tags : []).filter(Boolean).map((n) => ({ name: String(n).slice(0, 20) }))) },
|
||||||
},
|
}
|
||||||
include: TASK_INCLUDE,
|
// 项目内自增编号 (展示 `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) })
|
res.json({ item: shape(row) })
|
||||||
} catch (e) { next(e) }
|
} catch (e) { next(e) }
|
||||||
})
|
})
|
||||||
@@ -250,7 +253,7 @@ router.put('/tasks/:id', async (req, res, next) => {
|
|||||||
await prisma.taskMember.deleteMany({ where: { taskId: task.id } })
|
await prisma.taskMember.deleteMany({ where: { taskId: task.id } })
|
||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
await prisma.taskMember.createMany({
|
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 } })
|
await prisma.taskTag.deleteMany({ where: { taskId: task.id } })
|
||||||
const names = body.tags.map((n) => String(n).trim()).filter(Boolean).slice(0, 10)
|
const names = body.tags.map((n) => String(n).trim()).filter(Boolean).slice(0, 10)
|
||||||
if (names.length) {
|
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) }))) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// 任务模块: 跨项目拷贝 (T-CP-*)
|
// 任务模块: 跨项目拷贝 (T-CP-*)
|
||||||
// 拷贝而非移动: 新建记录 + 深拷贝子任务, 源任务零改动.
|
// 拷贝而非移动: 新建记录 + 深拷贝子任务, 源任务零改动.
|
||||||
import { prisma } from '../db.js'
|
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 droppedAssignee = source.assigneeId && !memberSet.has(source.assigneeId) ? source.assignee : null
|
||||||
|
|
||||||
const baseData = (extra) => ({
|
const baseData = (extra) => ({
|
||||||
|
uuid: genUuid(),
|
||||||
projectId: targetProject.id,
|
projectId: targetProject.id,
|
||||||
title: source.title,
|
title: source.title,
|
||||||
description: source.description || '',
|
description: source.description || '',
|
||||||
@@ -50,33 +52,39 @@ export async function copyTask({ source, targetProject, targetColumns, operatorI
|
|||||||
doneAt: source.status === 'done' ? (source.doneAt || new Date()) : null,
|
doneAt: source.status === 'done' ? (source.doneAt || new Date()) : null,
|
||||||
sortOrder: source.sortOrder ?? 0,
|
sortOrder: source.sortOrder ?? 0,
|
||||||
tags: {
|
tags: {
|
||||||
create: (source.tags || []).map((t) => ({ name: t.name })),
|
create: withUuid((source.tags || []).map((t) => ({ name: t.name }))),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const newTask = await prisma.task.create({
|
const { value: newTask } = await withTaskNumber(targetProject.id, (number) =>
|
||||||
|
prisma.task.create({
|
||||||
data: {
|
data: {
|
||||||
...baseData({ columnId: pickColumn(source.columnId) }),
|
...baseData({ columnId: pickColumn(source.columnId) }),
|
||||||
|
number,
|
||||||
members: {
|
members: {
|
||||||
create: keptMemberIds.map((uid) => ({ userId: uid })),
|
create: withUuid(keptMemberIds.map((uid) => ({ userId: uid }))),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
// 子任务: 同法复制, parentId 指向新父任务
|
// 子任务: 同法复制, parentId 指向新父任务
|
||||||
for (const child of source.children || []) {
|
for (const child of source.children || []) {
|
||||||
const childMembers = (child.members || [])
|
const childMembers = (child.members || [])
|
||||||
.filter((m) => memberSet.has(m.userId))
|
.filter((m) => memberSet.has(m.userId))
|
||||||
.map((m) => m.userId)
|
.map((m) => m.userId)
|
||||||
await prisma.task.create({
|
await withTaskNumber(targetProject.id, (number) =>
|
||||||
|
prisma.task.create({
|
||||||
data: {
|
data: {
|
||||||
...baseData({
|
...baseData({
|
||||||
columnId: pickColumn(child.columnId),
|
columnId: pickColumn(child.columnId),
|
||||||
parentId: newTask.id,
|
parentId: newTask.id,
|
||||||
}),
|
}),
|
||||||
members: { create: childMembers.map((uid) => ({ userId: uid })) },
|
number,
|
||||||
|
members: { create: withUuid(childMembers.map((uid) => ({ userId: uid }))) },
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user