diff --git a/apps/backend/scripts/check-sql-guard.ts b/apps/backend/scripts/check-sql-guard.ts index 0ce11cce..cba7b262 100644 --- a/apps/backend/scripts/check-sql-guard.ts +++ b/apps/backend/scripts/check-sql-guard.ts @@ -97,13 +97,9 @@ for (const file of listSourceFiles(path.join(BACKEND_ROOT, 'src'))) { } // 已应用的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。 -// -// 例外(刻意保留):**删除**历史迁移是允许的,但必须同时新增迁移兜底 -// (例如「迁移 096 设计作废 → 删 096、新增 097 DROP COLUMN」这种纠偏): -// 只删不补会让「已应用过 096 的环境」与「全新库」结构漂移,所以这里强制配对。 try { - const nameStatusOutputs = [ - execFileSync('git', ['status', '--porcelain', '--', 'src/db/migrations'], { + const diffOutputs = [ + execFileSync('git', ['diff', '--name-status', 'HEAD', '--', 'src/db/migrations'], { cwd: BACKEND_ROOT, encoding: 'utf8', }), @@ -112,41 +108,14 @@ try { encoding: 'utf8', }), ] - const migrationChanges = nameStatusOutputs.flatMap((output) => - output - .split('\n') - .map((line) => line.trimEnd()) - .filter(Boolean) - .map((line) => { - // `git status --porcelain` = `XY path`(未跟踪为 `?? path`); - // `git diff --name-status` = `S\tpath`(重命名为 `R100\told\tnew`)。两者格式不同,分别解析。 - if (line.includes('\t')) { - const parts = line.split('\t').map((part) => part.trim()) - return { status: parts[0] || '', file: parts[parts.length - 1] || '' } - } - return { status: line.slice(0, 2).trim(), file: line.slice(3).trim() } - }) - .filter((change) => change.file.endsWith('.sql')), - ) - - const changedHistoricalMigrations = migrationChanges - .filter((change) => change.status !== 'A' && change.status !== 'D' && change.status !== '??') - .map((change) => `${change.status} ${change.file}`) + const changedHistoricalMigrations = diffOutputs + .flatMap((output) => output.split('\n')) + .map((line) => line.trim().split(/\s+/)) + .filter(([status, file]) => status && file?.endsWith('.sql') && status !== 'A') + .map(([status, file]) => `${status} ${file}`) if (changedHistoricalMigrations.length > 0) { failures.push(`历史迁移文件不可修改: ${changedHistoricalMigrations.join(', ')}`) } - - const deletedMigrations = migrationChanges.filter((change) => change.status === 'D') - const addedMigrations = migrationChanges.filter( - (change) => change.status === 'A' || change.status === '??', - ) - if (deletedMigrations.length > 0 && addedMigrations.length === 0) { - failures.push( - `删除历史迁移必须同时新增兜底迁移: ${deletedMigrations - .map((change) => change.file) - .join(', ')}`, - ) - } } catch (error) { failures.push(`历史迁移完整性检查失败: ${error instanceof Error ? error.message : String(error)}`) } diff --git a/apps/backend/src/db/migrations/096_fulfillment_tasks_claim_submission.sql b/apps/backend/src/db/migrations/096_fulfillment_tasks_claim_submission.sql new file mode 100644 index 00000000..dc72326c --- /dev/null +++ b/apps/backend/src/db/migrations/096_fulfillment_tasks_claim_submission.sql @@ -0,0 +1,20 @@ +-- 096_fulfillment_tasks_claim_submission.sql —— 皮肤自动发货「买家领取页」提交内容落库。 +-- +-- 背景:抖音码速达的皮肤类商品改为「自动发货 + 领取链接」,买家在 /claim-skin/:token +-- 提交游戏账号后,我们要把账号存下来交给运营发货(并防重复提交)。 +-- +-- 为什么单开一列而不是塞进 work_orders.material_json: +-- material_json 是发给打手的工单素材(还挂了搜索索引与统计),买家游戏账号属于隐私数据, +-- 写进去等于对外泄露(仓库既有隐私红线,见 douyin-mashida/order-mapper.ts 的 buyerWords 注释)。 +-- fulfillment_tasks 只被履约/后台读取,工单侧(worker-platform)明确不 JOIN 该表, +-- 因此把账号留在任务自身上是最小暴露面的做法。 +-- +-- 幂等:全部 IF NOT EXISTS。 + +ALTER TABLE fulfillment_tasks + -- 买家领取页提交内容:{"gameAccount":"...","submittedAt":"...","source":"claim_skin"}; + -- 空对象 {} 表示尚未提交(待领取)。 + ADD COLUMN IF NOT EXISTS claim_submission_json JSONB NOT NULL DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN fulfillment_tasks.claim_submission_json IS + '买家领取页提交内容(gameAccount/submittedAt);隐私字段,禁止写入 work_orders.material_json';