#!/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.js', 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(` 用法: node scripts/seed-dev-data.js [--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`') }