// 任务域同步标识与编号 (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 }