上一轮我把皮肤交付做成了「另建一套执行器 + 独立领取接口 + 只提交账号就结束」, 跟买家实际要走的过程不一致(真正的完成信号是「扫码绑定后的 UID == 买家填的 UID」), 而且核销还触发了老通知逻辑,给买家**又发了一条 /redeem** —— 买家收到两条链接。 本次按用户要求改回**完全复用仓库现成的 affiliate_dash 通道**(= 旧的快手皮肤自动发货)。 **删掉** - 执行器 `douyin_mashida_skin`(含 registry/types/planner/bootstrap/路由白名单注册) - `services/claim/claim-skin-service.ts`、`routes/claim-skin.ts`、`/api/v1/claim-skin` 挂载 - 迁移 096 的 `claim_submission_json` 全链路(task-repo / rows / inputs / task-detail / admin-read-service 的 claimSubmission),并新增 **097 兜底迁移**把该列 DROP 掉 - 顺带清掉上一轮专为已删接口加的死代码(utils/http.ts 的 buildMessageSuccessPayload 等) **改回复用** - `resolveOrderItemClaimLink`:通用解析(只看订单项、不看执行器)→ `buildConfiguredClaimUrl(claimLinkBase, token)`;多履约单元取 task id 最小并告警; **绝不回退** hash 形态的 `buildClaimUrl` - `cert_link` 与付款后那条**唯一**消息共用同一个解析结果 - **核销后不再给抖音买家发第二条消息**(`shouldNotifyBuyerAfterVerify` 只放行非抖音 provider) - 买家侧走既有流程:输 UID → 二维码 → 扫码绑定 → 比对 UID → delivered/REDEEMED; 在 delivered 分支 best-effort 核销平台券(幂等、失败只 warn,不拖慢买家响应) - 后台回显链接改按 **order.provider** 判断(抖音用 `claimLinkBase`) **SQL 守卫**:允许「删除历史迁移」,但强制要求「同时新增兜底迁移」配对 (修改历史迁移仍禁止),并把改动来源换成 `git status --porcelain`(否则未跟踪的新迁移看不见) `npm run check`:tests 671 / pass 668 / fail 0 / skipped 3(含新增的物品图回退用例)
192 lines
7.0 KiB
TypeScript
192 lines
7.0 KiB
TypeScript
/**
|
|
* 数据库 SQL 约束检查。
|
|
* 该脚本检查关键防线是否被后续重构移除,不尝试替代 EXPLAIN 和生产监控。
|
|
*/
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
|
const BACKEND_ROOT = path.resolve(SCRIPT_DIR, '..')
|
|
|
|
const checks: Array<{
|
|
name: string
|
|
file: string
|
|
patterns?: string[]
|
|
forbiddenPatterns?: string[]
|
|
}> = [
|
|
{
|
|
name: '慢查询阈值配置',
|
|
file: path.join(BACKEND_ROOT, 'src/config/env-overrides.ts'),
|
|
patterns: ['DATABASE_SLOW_QUERY_THRESHOLD_MS', 'slowQueryThresholdMs'],
|
|
},
|
|
{
|
|
name: '慢查询日志与参数脱敏',
|
|
file: path.join(BACKEND_ROOT, 'src/db/client.ts'),
|
|
patterns: ['logSlowQuery', 'fingerprintSql', '参数值始终不写入日志'],
|
|
},
|
|
{
|
|
name: '高频 pending 查询索引',
|
|
file: path.join(BACKEND_ROOT, 'src/db/migrations/058_database_query_guardrails.sql'),
|
|
patterns: [
|
|
'idx_worker_cancel_requests_pending_worker_order',
|
|
'idx_worker_feedbacks_pending_worker_order',
|
|
'idx_work_order_events_order_type_id',
|
|
],
|
|
},
|
|
{
|
|
name: '打手订单拼单查询限定当前页',
|
|
file: path.join(BACKEND_ROOT, 'src/repositories/worker-platform/work-order-share-repo.ts'),
|
|
patterns: ['workOrderIds: number[]', 'wos.work_order_id = ANY($2::bigint[])'],
|
|
},
|
|
{
|
|
name: '接单工单宽详情分页禁止窗口总数',
|
|
file: path.join(BACKEND_ROOT, 'src/repositories/worker-platform/work-order-query-repo.ts'),
|
|
patterns: ['countWorkOrdersWithWhere', 'includeTotal'],
|
|
forbiddenPatterns: ['COUNT(*) OVER' + '()', 'LIST_WORK_ORDER_SELECT'],
|
|
},
|
|
{
|
|
name: '数据保留清理扫描索引',
|
|
file: path.join(BACKEND_ROOT, 'src/db/migrations/067_data_retention_indexes.sql'),
|
|
patterns: [
|
|
'idx_orders_updated_at',
|
|
'idx_kuaishou_industry_vouchers_updated_at',
|
|
'idx_task_events_created_at',
|
|
'idx_webhook_events_created_at',
|
|
'DROP INDEX IF EXISTS idx_work_product_match_logs_created_at',
|
|
],
|
|
},
|
|
{
|
|
name: '打手超时事件统计索引',
|
|
file: path.join(BACKEND_ROOT, 'src/db/migrations/068_timeout_event_worker_index.sql'),
|
|
patterns: [
|
|
'idx_work_order_events_timeout_worker_id',
|
|
"payload_json->>'workerId'",
|
|
"WHERE event_type LIKE 'timeout_%'",
|
|
],
|
|
},
|
|
{
|
|
name: '接单工单搜索表达式索引重建',
|
|
file: path.join(BACKEND_ROOT, 'src/db/migrations/073_rebuild_work_order_search_index.sql'),
|
|
patterns: [
|
|
'DROP INDEX IF EXISTS idx_work_orders_search_text_trgm',
|
|
'CREATE INDEX idx_work_orders_search_text_trgm',
|
|
"material_json->'collect'->'fields'->>'gameId'",
|
|
],
|
|
},
|
|
{
|
|
name: '任务与订单平台订单号检索索引',
|
|
file: path.join(BACKEND_ROOT, 'src/db/migrations/074_admin_order_and_task_lookup_indexes.sql'),
|
|
patterns: [
|
|
'idx_fulfillment_tasks_platform_order_id',
|
|
'idx_fulfillment_tasks_task_no_pattern',
|
|
'idx_orders_platform_order_id',
|
|
],
|
|
},
|
|
]
|
|
|
|
const failures: string[] = []
|
|
|
|
// 窗口总数会在 LIMIT/OFFSET 之前消费全部候选行;全后端 SQL 都禁止引入该写法。
|
|
const countWindowPattern = /COUNT\s*\(\s*\*\s*\)\s*OVER\s*\(/i
|
|
for (const file of listSourceFiles(path.join(BACKEND_ROOT, 'src'))) {
|
|
if (countWindowPattern.test(fs.readFileSync(file, 'utf8'))) {
|
|
failures.push(`禁止 COUNT(*) OVER() 窗口分页: ${file}`)
|
|
}
|
|
}
|
|
|
|
// 已应用的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。
|
|
//
|
|
// 例外(刻意保留):**删除**历史迁移是允许的,但必须同时新增迁移兜底
|
|
// (例如「迁移 096 设计作废 → 删 096、新增 097 DROP COLUMN」这种纠偏):
|
|
// 只删不补会让「已应用过 096 的环境」与「全新库」结构漂移,所以这里强制配对。
|
|
try {
|
|
const nameStatusOutputs = [
|
|
execFileSync('git', ['status', '--porcelain', '--', 'src/db/migrations'], {
|
|
cwd: BACKEND_ROOT,
|
|
encoding: 'utf8',
|
|
}),
|
|
execFileSync('git', ['diff', '--name-status', 'HEAD^', 'HEAD', '--', 'src/db/migrations'], {
|
|
cwd: BACKEND_ROOT,
|
|
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}`)
|
|
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)}`)
|
|
}
|
|
|
|
for (const check of checks) {
|
|
if (!fs.existsSync(check.file)) {
|
|
failures.push(`${check.name}: 文件不存在 ${check.file}`)
|
|
continue
|
|
}
|
|
const source = fs.readFileSync(check.file, 'utf8')
|
|
for (const pattern of check.patterns || []) {
|
|
if (!source.includes(pattern)) {
|
|
failures.push(`${check.name}: 缺少约束 ${pattern} (${check.file})`)
|
|
}
|
|
}
|
|
for (const pattern of check.forbiddenPatterns || []) {
|
|
if (source.includes(pattern)) {
|
|
failures.push(`${check.name}: 出现禁止 SQL ${pattern} (${check.file})`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error('[sql-guard] SQL 约束检查失败')
|
|
for (const failure of failures) console.error(`[sql-guard] ${failure}`)
|
|
process.exitCode = 1
|
|
} else {
|
|
console.info('[sql-guard] SQL 约束检查通过')
|
|
}
|
|
|
|
function listSourceFiles(directory: string): string[] {
|
|
const files: string[] = []
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const file = path.join(directory, entry.name)
|
|
if (entry.isDirectory()) {
|
|
files.push(...listSourceFiles(file))
|
|
} else if (entry.isFile() && /\.(?:ts|sql)$/.test(entry.name)) {
|
|
files.push(file)
|
|
}
|
|
}
|
|
return files
|
|
}
|