feat(0.2.1): M2 服务端同步接入 —— /api/powersync token+upload 幂等上行, 项目/任务/拷贝链路接入 uuid 与编号分配 (identity 助手)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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