后端迁移开发脚本
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import process from 'node:process'
|
||||
|
||||
import { runtimeConfig } from '../src/config/runtime.js'
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
import { runDatabaseMigrations } from '../src/db/migrate.js'
|
||||
import { updateClaimToken } from '../src/repositories/claim-token-repo.js'
|
||||
import { getFulfillmentProfileByKey, upsertSkuFulfillmentBinding } from '../src/repositories/fulfillment-profile-repo.js'
|
||||
import { createInventoryItems, releaseReservedInventoryItem } from '../src/repositories/inventory-repo.js'
|
||||
import { getTaskById, updateTask } from '../src/repositories/task-repo.js'
|
||||
import { ensureFulfillmentCatalogBootstrapped } from '../src/services/bootstrap/fulfillment-bootstrap-service.js'
|
||||
import { completeAdminTaskManualDispatch } from '../src/services/admin/admin-service.js'
|
||||
import { upsertOrderFromWebhook } from '../src/services/order/order-service.js'
|
||||
import { addHours, nowIso } from '../src/utils/time.js'
|
||||
|
||||
const RESET_TABLES = [
|
||||
'message_deliveries',
|
||||
'tencent_browser_contexts',
|
||||
'task_events',
|
||||
'claim_tokens',
|
||||
'task_inventory_bindings',
|
||||
'fulfillment_tasks',
|
||||
'order_items',
|
||||
'orders',
|
||||
'webhook_events',
|
||||
'product_match_rules',
|
||||
'inventory_items',
|
||||
]
|
||||
|
||||
const SEED_PROVIDER = 'seed'
|
||||
const SEED_PLATFORM = 'sandbox'
|
||||
const SEED_SHOP_ID = 'seed-shop'
|
||||
const SEED_SHOP_NAME = 'Seed Sandbox Shop'
|
||||
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (options.help) {
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
printPreview()
|
||||
|
||||
if (!options.apply) {
|
||||
console.log('\n当前为预览模式。确认后追加 `--apply`,需要清库时再加 `--reset`。')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await runDatabaseMigrations()
|
||||
await ensureFulfillmentCatalogBootstrapped()
|
||||
|
||||
if (options.reset) {
|
||||
await query(`TRUNCATE TABLE ${RESET_TABLES.join(', ')} RESTART IDENTITY CASCADE`)
|
||||
console.log('\n已先清空测试业务数据。')
|
||||
}
|
||||
|
||||
await ensureSeedBindings()
|
||||
await createSeedInventory()
|
||||
|
||||
const manualPending = await seedScenario({
|
||||
scenarioKey: 'manual_pending',
|
||||
orderLabel: 'SEED-MANUAL-PENDING',
|
||||
skuCode: 'seed-manual-pending',
|
||||
skuName: '开发种子-人工履约待处理',
|
||||
})
|
||||
|
||||
const manualCompleted = await seedScenario({
|
||||
scenarioKey: 'manual_completed',
|
||||
orderLabel: 'SEED-MANUAL-COMPLETED',
|
||||
skuCode: 'seed-manual-completed',
|
||||
skuName: '开发种子-人工履约已完成',
|
||||
afterCreate: async (result) => {
|
||||
const task = result.tasks[0]
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
await completeAdminTaskManualDispatch(task.id, {
|
||||
outcome: 'delivered',
|
||||
resultMessage: '开发种子数据:人工履约已完成',
|
||||
deliveryReference: 'SEED-MANUAL-REF-001',
|
||||
deliveredCredential: '开发种子数据:已通过人工渠道发放',
|
||||
})
|
||||
|
||||
return getTaskById(task.id)
|
||||
},
|
||||
})
|
||||
|
||||
const claimReady = await seedScenario({
|
||||
scenarioKey: 'claim_ready',
|
||||
orderLabel: 'SEED-CLAIM-READY',
|
||||
skuCode: 'seed-claim-ready',
|
||||
skuName: '开发种子-领取兑换可用',
|
||||
})
|
||||
|
||||
const claimClaimed = await seedScenario({
|
||||
scenarioKey: 'claim_claimed',
|
||||
orderLabel: 'SEED-CLAIM-CLAIMED',
|
||||
skuCode: 'seed-claim-claimed',
|
||||
skuName: '开发种子-已打开领取链接',
|
||||
afterCreate: async (result) => applyClaimedState(result.tasks[0]),
|
||||
})
|
||||
|
||||
const claimRoleConfirmed = await seedScenario({
|
||||
scenarioKey: 'claim_role_confirmed',
|
||||
orderLabel: 'SEED-CLAIM-ROLE',
|
||||
skuCode: 'seed-claim-role-confirmed',
|
||||
skuName: '开发种子-已确认角色',
|
||||
afterCreate: async (result) => applyRoleConfirmedState(result.tasks[0]),
|
||||
})
|
||||
|
||||
const claimRetryPending = await seedScenario({
|
||||
scenarioKey: 'claim_retry_pending',
|
||||
orderLabel: 'SEED-CLAIM-RETRY',
|
||||
skuCode: 'seed-claim-retry',
|
||||
skuName: '开发种子-等待重试',
|
||||
afterCreate: async (result) => applyRetryPendingState(result.tasks[0]),
|
||||
})
|
||||
|
||||
const claimExpired = await seedScenario({
|
||||
scenarioKey: 'claim_expired',
|
||||
orderLabel: 'SEED-CLAIM-EXPIRED',
|
||||
skuCode: 'seed-claim-expired',
|
||||
skuName: '开发种子-领取链接已过期',
|
||||
afterCreate: async (result) => applyExpiredState(result.tasks[0]),
|
||||
})
|
||||
|
||||
const claimWaitingInventory = await seedScenario({
|
||||
scenarioKey: 'claim_waiting_inventory',
|
||||
orderLabel: 'SEED-CLAIM-WAIT',
|
||||
skuCode: 'seed-claim-wait',
|
||||
skuName: '开发种子-等待库存',
|
||||
})
|
||||
|
||||
printResultSummary([
|
||||
manualPending,
|
||||
manualCompleted,
|
||||
claimReady,
|
||||
claimClaimed,
|
||||
claimRoleConfirmed,
|
||||
claimRetryPending,
|
||||
claimExpired,
|
||||
claimWaitingInventory,
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('\n写入开发种子数据失败:', error instanceof Error ? error.message : String(error || '未知错误'))
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await closeDb()
|
||||
}
|
||||
|
||||
async function ensureSeedBindings() {
|
||||
const timestamp = nowIso()
|
||||
const manualProfile = await getRequiredProfile('manual_review')
|
||||
const claimProfile = await getRequiredProfile('tencent_claim_redeem')
|
||||
|
||||
const bindings = [
|
||||
{ skuCode: 'seed-manual-pending', profileId: manualProfile.id, priority: 10 },
|
||||
{ skuCode: 'seed-manual-completed', profileId: manualProfile.id, priority: 20 },
|
||||
{ skuCode: 'seed-claim-ready', profileId: claimProfile.id, priority: 30 },
|
||||
{ skuCode: 'seed-claim-claimed', profileId: claimProfile.id, priority: 40 },
|
||||
{ skuCode: 'seed-claim-role-confirmed', profileId: claimProfile.id, priority: 50 },
|
||||
{ skuCode: 'seed-claim-retry', profileId: claimProfile.id, priority: 60 },
|
||||
{ skuCode: 'seed-claim-expired', profileId: claimProfile.id, priority: 70 },
|
||||
{ skuCode: 'seed-claim-wait', profileId: claimProfile.id, priority: 80 },
|
||||
]
|
||||
|
||||
for (const binding of bindings) {
|
||||
await upsertSkuFulfillmentBinding({
|
||||
skuCode: binding.skuCode,
|
||||
provider: SEED_PROVIDER,
|
||||
platform: SEED_PLATFORM,
|
||||
shopId: SEED_SHOP_ID,
|
||||
profileId: binding.profileId,
|
||||
enabled: true,
|
||||
priority: binding.priority,
|
||||
configJson: JSON.stringify({
|
||||
seededBy: 'scripts/seed-dev-data.ts',
|
||||
scenario: binding.skuCode,
|
||||
}),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function createSeedInventory() {
|
||||
const timestamp = nowIso()
|
||||
|
||||
await createInventoryItems([
|
||||
{
|
||||
skuCode: 'seed-claim-ready',
|
||||
displayValue: 'SEED-CLAIM-CODE-READY-001',
|
||||
batchNo: 'seed-20260409',
|
||||
credentialType: 'tencent_code',
|
||||
payload: {
|
||||
code: 'SEED-CLAIM-CODE-READY-001',
|
||||
scenario: 'claim_ready',
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
skuCode: 'seed-claim-claimed',
|
||||
displayValue: 'SEED-CLAIM-CODE-CLAIMED-001',
|
||||
batchNo: 'seed-20260409',
|
||||
credentialType: 'tencent_code',
|
||||
payload: {
|
||||
code: 'SEED-CLAIM-CODE-CLAIMED-001',
|
||||
scenario: 'claim_claimed',
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
skuCode: 'seed-claim-role-confirmed',
|
||||
displayValue: 'SEED-CLAIM-CODE-ROLE-001',
|
||||
batchNo: 'seed-20260409',
|
||||
credentialType: 'tencent_code',
|
||||
payload: {
|
||||
code: 'SEED-CLAIM-CODE-ROLE-001',
|
||||
scenario: 'claim_role_confirmed',
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
skuCode: 'seed-claim-retry',
|
||||
displayValue: 'SEED-CLAIM-CODE-RETRY-001',
|
||||
batchNo: 'seed-20260409',
|
||||
credentialType: 'tencent_code',
|
||||
payload: {
|
||||
code: 'SEED-CLAIM-CODE-RETRY-001',
|
||||
scenario: 'claim_retry_pending',
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
skuCode: 'seed-claim-expired',
|
||||
displayValue: 'SEED-CLAIM-CODE-EXPIRED-001',
|
||||
batchNo: 'seed-20260409',
|
||||
credentialType: 'tencent_code',
|
||||
payload: {
|
||||
code: 'SEED-CLAIM-CODE-EXPIRED-001',
|
||||
scenario: 'claim_expired',
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
skuCode: 'seed-spare-available',
|
||||
displayValue: 'SEED-SPARE-CODE-001',
|
||||
batchNo: 'seed-20260409',
|
||||
credentialType: 'tencent_code',
|
||||
payload: {
|
||||
code: 'SEED-SPARE-CODE-001',
|
||||
scenario: 'spare_available',
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async function seedScenario({ scenarioKey, orderLabel, skuCode, skuName, afterCreate = null }) {
|
||||
const result = await upsertOrderFromWebhook(buildSeedOrderEvent({
|
||||
scenarioKey,
|
||||
orderLabel,
|
||||
skuCode,
|
||||
skuName,
|
||||
}))
|
||||
|
||||
const task = typeof afterCreate === 'function'
|
||||
? await afterCreate(result)
|
||||
: result.tasks[0] || null
|
||||
|
||||
return {
|
||||
scenarioKey,
|
||||
orderId: result.order.id,
|
||||
platformOrderId: result.order.platform_order_id,
|
||||
taskId: task?.id || null,
|
||||
taskNo: task?.task_no || '',
|
||||
taskStatus: task?.task_status || '',
|
||||
deliveryStatus: task?.delivery_status || '',
|
||||
inventoryStatus: task?.inventory_status || '',
|
||||
inventoryItemId: Number(task?.primary_inventory_item_id || 0) || null,
|
||||
claimTokenId: Number(task?.primary_claim_token_id || 0) || null,
|
||||
executorKey: String(task?.executor_key || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
async function applyClaimedState(task) {
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
await updateTask(task.id, {
|
||||
task_status: 'claimed',
|
||||
user_action_status: 'claimed',
|
||||
browser_session_id: `seed-browser-${task.id}`,
|
||||
login_type: 'qq',
|
||||
claimed_at: now,
|
||||
updated_at: now,
|
||||
last_error: '',
|
||||
})
|
||||
|
||||
return getTaskById(task.id)
|
||||
}
|
||||
|
||||
async function applyRoleConfirmedState(task) {
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
const claimedAt = addHours(nowIso(), -1)
|
||||
const roleConfirmedAt = nowIso()
|
||||
await updateTask(task.id, {
|
||||
task_status: 'role_confirmed',
|
||||
user_action_status: 'role_confirmed',
|
||||
browser_session_id: `seed-browser-${task.id}`,
|
||||
login_type: 'qq',
|
||||
nickname: 'SeedHero',
|
||||
role_id: 'seed-role-1001',
|
||||
role_name: '开发角色-已确认',
|
||||
area: 'seed-area',
|
||||
partition_name: 'seed-partition',
|
||||
claimed_at: claimedAt,
|
||||
role_confirmed_at: roleConfirmedAt,
|
||||
updated_at: roleConfirmedAt,
|
||||
last_error: '',
|
||||
})
|
||||
|
||||
return getTaskById(task.id)
|
||||
}
|
||||
|
||||
async function applyRetryPendingState(task) {
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
const claimedAt = addHours(nowIso(), -2)
|
||||
const roleConfirmedAt = addHours(nowIso(), -1)
|
||||
const updatedAt = nowIso()
|
||||
await updateTask(task.id, {
|
||||
task_status: 'retry_pending',
|
||||
delivery_status: 'pending',
|
||||
inventory_status: 'reserved',
|
||||
user_action_status: 'role_confirmed',
|
||||
browser_session_id: `seed-browser-${task.id}`,
|
||||
login_type: 'qq',
|
||||
nickname: 'SeedRetryUser',
|
||||
role_id: 'seed-role-retry',
|
||||
role_name: '开发角色-待重试',
|
||||
area: 'seed-area',
|
||||
partition_name: 'seed-partition',
|
||||
claimed_at: claimedAt,
|
||||
role_confirmed_at: roleConfirmedAt,
|
||||
attempt_count: 2,
|
||||
last_error: '开发种子数据:模拟兑换失败,等待重试',
|
||||
updated_at: updatedAt,
|
||||
})
|
||||
|
||||
return getTaskById(task.id)
|
||||
}
|
||||
|
||||
async function applyExpiredState(task) {
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
if (task.primary_claim_token_id) {
|
||||
await updateClaimToken(task.primary_claim_token_id, {
|
||||
status: 'expired',
|
||||
expired_at: addHours(now, -2),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (task.primary_inventory_item_id) {
|
||||
await releaseReservedInventoryItem(task.primary_inventory_item_id, now)
|
||||
}
|
||||
|
||||
await updateTask(task.id, {
|
||||
task_status: 'expired',
|
||||
inventory_status: 'pending',
|
||||
user_action_status: 'expired',
|
||||
last_error: '领取链接已过期,预占库存项已释放',
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
return getTaskById(task.id)
|
||||
}
|
||||
|
||||
function buildSeedOrderEvent({ scenarioKey, orderLabel, skuCode, skuName }) {
|
||||
const timestamp = nowIso()
|
||||
|
||||
return {
|
||||
provider: SEED_PROVIDER,
|
||||
platform: SEED_PLATFORM,
|
||||
shopId: SEED_SHOP_ID,
|
||||
shopName: SEED_SHOP_NAME,
|
||||
platformOrderId: orderLabel,
|
||||
orderStatus: 'paid',
|
||||
payStatus: 'paid',
|
||||
buyerId: `${scenarioKey}-buyer`,
|
||||
buyerName: `开发种子-${scenarioKey}`,
|
||||
receiverContact: `seed+${scenarioKey}@example.com`,
|
||||
totalAmount: 100,
|
||||
currency: 'CNY',
|
||||
paidAt: timestamp,
|
||||
rawPayload: {
|
||||
source: 'seed-dev-data',
|
||||
scenarioKey,
|
||||
platformOrderId: orderLabel,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
skuCode,
|
||||
skuName,
|
||||
quantity: 1,
|
||||
spec: {
|
||||
title: skuName,
|
||||
scenarioKey,
|
||||
},
|
||||
snapshot: {
|
||||
title: skuName,
|
||||
scenarioKey,
|
||||
seeded: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async function getRequiredProfile(profileKey) {
|
||||
const profile = await getFulfillmentProfileByKey(profileKey)
|
||||
|
||||
if (!profile) {
|
||||
throw new Error(`缺少履约档案: ${profileKey}`)
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
return {
|
||||
apply: argv.includes('--apply'),
|
||||
reset: argv.includes('--reset'),
|
||||
help: argv.includes('-h') || argv.includes('--help'),
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
用法:
|
||||
npm run seed:dev-data -- [--apply] [--reset]
|
||||
|
||||
说明:
|
||||
基于当前 PostgreSQL fulfillment schema 生成一组可重复验证的开发种子数据。
|
||||
|
||||
场景:
|
||||
- manual_pending: 人工履约待处理
|
||||
- manual_completed: 人工履约已完成
|
||||
- claim_ready: 已预占库存并生成领取链接
|
||||
- claim_claimed: 用户已打开领取链接
|
||||
- claim_role_confirmed: 用户已确认角色
|
||||
- claim_retry_pending: 自动履约失败等待重试
|
||||
- claim_expired: 领取链接已过期
|
||||
- claim_waiting_inventory: 等待库存
|
||||
|
||||
参数:
|
||||
--apply 实际写入数据
|
||||
--reset 写入前先清空测试业务数据
|
||||
|
||||
数据库连接:
|
||||
${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'}
|
||||
`.trim())
|
||||
}
|
||||
|
||||
function printPreview() {
|
||||
console.log('准备生成开发种子数据:')
|
||||
console.log('- manual_pending -> 人工履约待处理')
|
||||
console.log('- manual_completed -> 人工履约已完成')
|
||||
console.log('- claim_ready -> 领取兑换已就绪')
|
||||
console.log('- claim_claimed -> 用户已打开领取链接')
|
||||
console.log('- claim_role_confirmed -> 用户已确认角色')
|
||||
console.log('- claim_retry_pending -> 自动履约失败等待重试')
|
||||
console.log('- claim_expired -> 领取链接已过期')
|
||||
console.log('- claim_waiting_inventory -> 等待库存')
|
||||
console.log(`- database: ${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'}`)
|
||||
}
|
||||
|
||||
function printResultSummary(rows) {
|
||||
console.log('\n开发种子数据已写入:')
|
||||
|
||||
for (const row of rows) {
|
||||
console.log(`- ${row.scenarioKey}`)
|
||||
console.log(` orderId=${row.orderId} platformOrderId=${row.platformOrderId}`)
|
||||
console.log(` taskId=${row.taskId || '-'} taskNo=${row.taskNo || '-'} executor=${row.executorKey || '-'}`)
|
||||
console.log(` taskStatus=${row.taskStatus || '-'} deliveryStatus=${row.deliveryStatus || '-'} inventoryStatus=${row.inventoryStatus || '-'}`)
|
||||
console.log(` inventoryItemId=${row.inventoryItemId || '-'} claimTokenId=${row.claimTokenId || '-'}`)
|
||||
}
|
||||
|
||||
console.log('\n建议验证入口:')
|
||||
console.log('- `/api/v1/admin/orders?platformOrderId=SEED-`')
|
||||
console.log('- `/api/v1/admin/tasks?taskNo=DT`')
|
||||
console.log('- `/api/v1/admin/inventory`')
|
||||
}
|
||||
Reference in New Issue
Block a user