fix(0.2.1): upload 契约对齐客户端 —— 外键列即父行 uuid (projectId/columnId/parentId/taskId); 附 Node 闭环验证脚本
This commit is contained in:
Generated
+64
@@ -32,9 +32,11 @@
|
||||
"ws": "^8.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@powersync/node": "^1.2.0",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"cheerio": "^1.2.0",
|
||||
"concurrently": "^9.0.0",
|
||||
"csv-parser": "^3.2.0",
|
||||
@@ -1633,6 +1635,45 @@
|
||||
"js-logger": "^1.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@powersync/node": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@powersync/node/-/node-1.2.0.tgz",
|
||||
"integrity": "sha512-wBPIMHxdOV7NYSQP2ozLkCku7ezC+SnMvveyetnZQ0LpI3jZCQ08hXDc+lyZFngkJaLMfj/BOkr/QF261lBcAw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@powersync/common": "2.4.0",
|
||||
"@powersync/shared-internals": "1.4.0",
|
||||
"comlink": "^4.4.2",
|
||||
"undici": "^7.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@powersync/common": "^2.4.0",
|
||||
"better-sqlite3": "12.x || 13.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"better-sqlite3": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@powersync/node/node_modules/@powersync/common": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@powersync/common/-/common-2.4.0.tgz",
|
||||
"integrity": "sha512-ngVogy+B8NQeI5l+0Es8qMxnGozUie8Q6R4+0/AHA3oBAhDG5UmeYw/LquTzZJSOPnCmvGgFSqovblV+JKzxKQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@powersync/node/node_modules/@powersync/shared-internals": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@powersync/shared-internals/-/shared-internals-1.4.0.tgz",
|
||||
"integrity": "sha512-OiqqDuW6tePW4Ed7C5IWutBKrtcqKuTMlVMQNwUZtKtqr7ZrqKlb+YP5l4niuz8mpqDYo3/HNU0dpc+3mneSBQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@powersync/common": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@powersync/react": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@powersync/react/-/react-1.10.0.tgz",
|
||||
@@ -3212,6 +3253,19 @@
|
||||
"integrity": "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
||||
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
@@ -5393,6 +5447,16 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.9.2",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
|
||||
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
|
||||
|
||||
@@ -54,9 +54,11 @@
|
||||
"ws": "^8.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@powersync/node": "^1.2.0",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"cheerio": "^1.2.0",
|
||||
"concurrently": "^9.0.0",
|
||||
"csv-parser": "^3.2.0",
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// 0.2.1 闭环验证脚本 (Node 客户端): 同步下行 -> 本地离线写入 -> 上行 -> 服务端编号 -> 同步回填
|
||||
// 用法: node scripts/verify-powersync-client.mjs [BASE_URL] [EMAIL] [PASSWORD]
|
||||
// BASE_URL 默认 http://bh.vps.honor3.com:13001
|
||||
// 流程:
|
||||
// 1. REST 登录 -> /api/powersync/token
|
||||
// 2. Node SDK 连接 PowerSync, 等待首次同步, 校验 ETL 数据 (项目/任务/编号)
|
||||
// 3. 本地新建一条验证任务 (number 为空, 模拟离线写入)
|
||||
// 4. 等待 upload 完成并同步回填 -> 本地行应获得服务端分配的 number
|
||||
// 5. 清理: 本地删除该任务 (上行级联删除), 恢复数据原状
|
||||
import 'dotenv/config'
|
||||
import crypto from 'node:crypto'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import { column, Schema, Table, PowerSyncDatabase, createConsoleLogger, LogLevels } from '@powersync/node'
|
||||
|
||||
const BASE = (process.argv[2] || 'http://bh.vps.honor3.com:13001').replace(/\/+$/, '')
|
||||
const EMAIL = process.argv[3] || 'admin@iboard.local'
|
||||
const PASSWORD = process.argv[4] || 'admin123'
|
||||
|
||||
const uuid = () => crypto.randomUUID()
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
// 与 src/lib/powersync/schema.js 同构的最小 schema (验证只用到 Project/Task/TaskTag)
|
||||
const AppSchema = new Schema({
|
||||
Project: new Table({
|
||||
uuid: column.text, code: column.text, name: column.text, ownerId: column.text,
|
||||
isPersonal: column.integer, archived: column.integer, notes: column.text,
|
||||
taskKinds: column.text, icon: column.text, createdAt: column.text, updatedAt: column.text,
|
||||
}),
|
||||
Task: new Table({
|
||||
uuid: column.text, projectId: column.text, columnId: column.text, parentId: column.text,
|
||||
title: column.text, description: column.text, kind: column.text, markDate: column.text,
|
||||
status: column.text, doneAt: column.text, assigneeId: column.text, creatorId: column.text,
|
||||
extras: column.text, sortOrder: column.integer, number: column.integer,
|
||||
createdAt: column.text, updatedAt: column.text,
|
||||
}),
|
||||
TaskTag: new Table({ uuid: column.text, taskId: column.text, name: column.text }),
|
||||
TaskMember: new Table({ uuid: column.text, taskId: column.text, userId: column.text }),
|
||||
ProjectMember: new Table({ uuid: column.text, projectId: column.text, userId: column.text, permission: column.text, createdAt: column.text }),
|
||||
BoardColumn: new Table({
|
||||
uuid: column.text, projectId: column.text, name: column.text, sortOrder: column.integer,
|
||||
boundStatus: column.text, retention: column.text, createdAt: column.text, updatedAt: column.text,
|
||||
}),
|
||||
User: new Table({ name: column.text, email: column.text }),
|
||||
})
|
||||
|
||||
async function api(path, { method = 'GET', token, body } = {}) {
|
||||
const res = await fetch(BASE + '/api' + path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(`${path} -> ${res.status}: ${data.error || '请求失败'}`)
|
||||
return data
|
||||
}
|
||||
|
||||
class Connector {
|
||||
constructor(appToken, endpoint) {
|
||||
// appToken: 应用 JWT (调业务 API 上行用); endpoint + sync JWT 由 fetchCredentials 提供
|
||||
this.appToken = appToken
|
||||
this.endpoint = endpoint
|
||||
}
|
||||
async fetchCredentials() {
|
||||
const cred = await api('/powersync/token', { method: 'POST', token: this.appToken })
|
||||
return { endpoint: cred.endpoint, token: cred.token }
|
||||
}
|
||||
async uploadData(database) {
|
||||
const batch = await database.getCrudBatch()
|
||||
if (!batch) return
|
||||
const ops = []
|
||||
for (let i = 0; i < batch.crud.length; i++) {
|
||||
const op = batch.crud[i]
|
||||
let data = op.opData || {}
|
||||
if (op.op !== 'DELETE') {
|
||||
const row = await database.getOptional(`SELECT * FROM "${op.table}" WHERE id = ?`, [op.id])
|
||||
if (row) data = row
|
||||
}
|
||||
ops.push({ id: String(i), table: op.table, op: op.op === 'DELETE' ? 'DELETE' : 'PUT', uuid: op.id, data })
|
||||
}
|
||||
const res = await fetch(BASE + '/api/powersync/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + this.appToken },
|
||||
body: JSON.stringify({ ops }),
|
||||
})
|
||||
const out = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error('upload failed: ' + JSON.stringify(out))
|
||||
const failed = (out.results || []).filter((r) => !r.ok)
|
||||
if (failed.length) console.log('[verify] upload 部分失败:', JSON.stringify(failed))
|
||||
await batch.complete()
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[verify] BASE = ${BASE}`)
|
||||
const login = await api('/auth/login', { method: 'POST', body: { email: EMAIL, password: PASSWORD } })
|
||||
console.log('[verify] 1. 登录 OK, user:', login.user.email)
|
||||
const cred = await api('/powersync/token', { method: 'POST', token: login.token })
|
||||
if (!cred.endpoint) throw new Error('POWERSYNC_URL 未配置 (服务端 /powersync/token 返回空 endpoint)')
|
||||
console.log('[verify] 2. 同步凭证 OK, endpoint:', cred.endpoint)
|
||||
|
||||
const dbFile = path.join(os.tmpdir(), `iboard-verify-${Date.now()}.db`)
|
||||
const logger = createConsoleLogger({ minLevel: LogLevels.debug })
|
||||
const db = new PowerSyncDatabase({
|
||||
schema: AppSchema,
|
||||
database: { dbFilename: dbFile, databaseWorkerLogLevel: LogLevels.debug },
|
||||
sync: { logLevel: LogLevels.debug },
|
||||
logger,
|
||||
})
|
||||
db.registerListener({
|
||||
statusChanged: (s) => {
|
||||
const c = s?.dataFlowStatus || {}
|
||||
console.log(`[verify] sync status: connected=${s?.connected} connecting=${s?.connecting} err=${c.downloadError?.message || c.uploadError?.message || '-'}`)
|
||||
},
|
||||
})
|
||||
try {
|
||||
await db.connect(new Connector(login.token, cred.endpoint))
|
||||
console.log('[verify] connect() 已建立')
|
||||
} catch (e) {
|
||||
console.error('[verify] connect 失败:', e?.message)
|
||||
throw e
|
||||
}
|
||||
await Promise.race([
|
||||
db.waitForFirstSync(),
|
||||
sleep(45000).then(() => { throw new Error('waitForFirstSync 超时 (45s)') }),
|
||||
]).catch(async (e) => {
|
||||
console.log('[verify] waitForFirstSync 未完成, 轮询内部状态...')
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await sleep(3000)
|
||||
const st = db.currentStatus
|
||||
const counts = {
|
||||
projects: (await db.getOptional('SELECT COUNT(*) AS n FROM Project'))?.n,
|
||||
tasks: (await db.getOptional('SELECT COUNT(*) AS n FROM Task'))?.n,
|
||||
}
|
||||
console.log(`[verify] poll#${i}:`, JSON.stringify({
|
||||
connected: st?.connected, connecting: st?.connecting, hasSynced: st?.hasSynced,
|
||||
lastSyncedAt: st?.lastSyncedAt, dl: st?.dataFlowStatus?.downloadError?.message,
|
||||
ul: st?.dataFlowStatus?.uploadError?.message, ...counts,
|
||||
}))
|
||||
if (st?.hasSynced && counts.projects > 0) {
|
||||
console.log('[verify] 轮询确认同步已完成')
|
||||
return
|
||||
}
|
||||
}
|
||||
throw e
|
||||
})
|
||||
console.log('[verify] 3. 首次同步完成')
|
||||
|
||||
const projects = await db.getAll('SELECT id, code, name FROM Project ORDER BY id')
|
||||
console.log(`[verify] 项目 ${projects.length} 个:`, projects.map((p) => `${p.code || '?'}(${p.name})`).join(', '))
|
||||
const tasks = await db.getAll('SELECT COUNT(*) AS n FROM Task')
|
||||
const numbered = await db.getAll('SELECT COUNT(*) AS n FROM Task WHERE number IS NOT NULL')
|
||||
console.log(`[verify] 任务 ${tasks[0].n} 条 (已有编号 ${numbered[0].n} 条)`)
|
||||
if (!projects.length || tasks[0].n === 0) throw new Error('同步数据为空, ETL 未生效?')
|
||||
|
||||
// 本地新建验证任务 (模拟离线写入: number 为空, 等服务端分配)
|
||||
const target = projects.find((p) => p.code) || projects[0]
|
||||
const firstCol = await db.get(`SELECT id, boundStatus FROM BoardColumn WHERE projectId = ? ORDER BY sortOrder, id LIMIT 1`, [target.id])
|
||||
const marker = `离线闭环验证-${Date.now()}`
|
||||
const newId = uuid()
|
||||
const ts = new Date().toISOString()
|
||||
await db.writeTransaction(async (tx) => {
|
||||
await tx.execute(
|
||||
`INSERT INTO Task (id, uuid, projectId, columnId, parentId, title, description, kind, markDate, status, doneAt, assigneeId, creatorId, extras, sortOrder, number, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, NULL, ?, '', NULL, NULL, ?, NULL, NULL, ?, '[]', 0, NULL, ?, ?)`,
|
||||
[newId, newId, target.id, firstCol.id, marker, firstCol.boundStatus || 'todo', String(login.user.id), ts, ts],
|
||||
)
|
||||
})
|
||||
console.log(`[verify] 4. 本地写入任务 "${marker}" (number 为空), 等待上行 + 服务端定号 + 同步回填...`)
|
||||
|
||||
let syncedBack = null
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await sleep(2000)
|
||||
const row = await db.getOptional('SELECT number, updatedAt FROM Task WHERE id = ?', [newId])
|
||||
const localCheck = await db.getOptional(`SELECT 1 AS x FROM ps_crud LIMIT 1`)
|
||||
if (!localCheck) {
|
||||
// 队列已清空 (上行完成); 等待下行回填 number
|
||||
if (row && row.number != null) { syncedBack = row; break }
|
||||
if (i > 10 && row && row.number == null) continue
|
||||
}
|
||||
}
|
||||
if (!syncedBack) throw new Error('超时: 未观测到服务端编号回填')
|
||||
console.log(`[verify] 5. 闭环 OK! 服务端分配编号: ${target.code}-${syncedBack.number} (项目 ${target.code})`)
|
||||
|
||||
// 直接从服务端核对 (REST 视角)
|
||||
const serverCheck = await api(`/tasks/${newId}`, { token: login.token })
|
||||
console.log(`[verify] 6. 服务端确认: 任务存在, number=${serverCheck.item.number}, uuid=${serverCheck.item.uuid.slice(0, 13)}...`)
|
||||
|
||||
// 清理验证数据 (本地删除 -> 上行删除)
|
||||
await db.execute('DELETE FROM Task WHERE id = ?', [newId])
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await sleep(2000)
|
||||
const left = await db.getOptional('SELECT 1 AS x FROM ps_crud LIMIT 1')
|
||||
if (!left) break
|
||||
}
|
||||
const gone = await api(`/tasks/${newId}`, { token: login.token }).catch(() => null)
|
||||
if (!gone) console.log('[verify] 7. 验证任务已从服务端清除')
|
||||
else throw new Error('验证任务删除未同步到服务端')
|
||||
|
||||
db.close()
|
||||
fs.rmSync(dbFile, { force: true })
|
||||
fs.rmSync(dbFile + '-journal', { force: true })
|
||||
console.log('[verify] 全部通过: 下行同步 / 离线写入 / 上行幂等 / 服务端编号 / 回填 / 删除同步')
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[verify] 失败:', e.message)
|
||||
process.exit(1)
|
||||
})
|
||||
+16
-15
@@ -31,7 +31,8 @@ router.post('/token', authRequired, async (req, res, next) => {
|
||||
// ---------- 上传队列 ----------
|
||||
// 契约: POST { ops: [{ id, table, op: 'PUT'|'DELETE', uuid, data }] }
|
||||
// table ∈ Project | BoardColumn | ProjectMember | Task | TaskMember | TaskTag
|
||||
// data 为客户端本地全量行; 父引用走 *Uuid 字段 (projectUuid / columnUuid / parentUuid / taskUuid)
|
||||
// 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]))
|
||||
@@ -120,7 +121,7 @@ async function applyPut(op, ctx) {
|
||||
if (Object.keys(data).length) await prisma.boardColumn.update({ where: { uuid }, data })
|
||||
return {}
|
||||
}
|
||||
const project = await prisma.project.findUnique({ where: { uuid: d.projectUuid } })
|
||||
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('列名非法')
|
||||
@@ -143,7 +144,7 @@ async function applyPut(op, ctx) {
|
||||
// 成员关系只增不改: 已存在即视为完成 (幂等)
|
||||
return { skipped: true }
|
||||
}
|
||||
const project = await prisma.project.findUnique({ where: { uuid: d.projectUuid } })
|
||||
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 非法')
|
||||
@@ -179,16 +180,16 @@ async function applyPut(op, ctx) {
|
||||
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 (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.parentUuid !== undefined) {
|
||||
if (!d.parentUuid) {
|
||||
if (d.parentId !== undefined) {
|
||||
if (!d.parentId) {
|
||||
data.parentId = null
|
||||
} else {
|
||||
const parent = await prisma.task.findUnique({ where: { uuid: d.parentUuid } })
|
||||
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
|
||||
@@ -198,13 +199,13 @@ async function applyPut(op, ctx) {
|
||||
return {}
|
||||
}
|
||||
// 离线新建: 服务端解析父引用并分配项目内编号
|
||||
const project = await prisma.project.findUnique({ where: { uuid: d.projectUuid } })
|
||||
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.columnUuid) {
|
||||
const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnUuid } })
|
||||
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 {
|
||||
@@ -216,8 +217,8 @@ async function applyPut(op, ctx) {
|
||||
columnId = first.id
|
||||
}
|
||||
let parentId = null
|
||||
if (d.parentUuid) {
|
||||
const parent = await prisma.task.findUnique({ where: { uuid: d.parentUuid } })
|
||||
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
|
||||
@@ -248,7 +249,7 @@ async function applyPut(op, ctx) {
|
||||
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 } })
|
||||
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 非法')
|
||||
@@ -263,7 +264,7 @@ async function applyPut(op, ctx) {
|
||||
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 } })
|
||||
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('标签名非法')
|
||||
|
||||
Reference in New Issue
Block a user