#!/usr/bin/env tsx /** * 为部署前已支付、已有可核销券但未进入订单级核销队列的订单补投任务。 * * 只处理明确可核销的券: * - 快手电子凭证:UNUSED 且发码回调成功; * - 抖音码速达:issued/synced 且同步成功。 * * 默认 dry-run。执行必须显式确认。 */ import process from 'node:process' import { closeDb, query } from '../src/db/client.js' import { loadAppConfigCacheFromDatabase } from '../src/services/config/app-config-store.js' import { getVoucherConsumePolicy } from '../src/services/fulfillment/voucher-consume-policy-service.js' import { scheduleVoucherConsumeForOrderReceived } from '../src/services/fulfillment/voucher-consume-queue-service.js' const CONFIRMATION = 'ENQUEUE_ORDER_RECEIVED' const options = parseOptions(process.argv.slice(2)) const execute = options.execute === 'true' const confirmation = String(options.confirm || '').trim() const days = normalizeInteger(options.days, 7, 1, 90) const batchSize = normalizeInteger(options.batchSize, 500, 1, 5000) const maxBatches = normalizeInteger(options.maxBatches, 1000, 1, 10000) const dateTo = normalizeDate(options.to) || today() const dateFrom = normalizeDate(options.from) || shiftDate(dateTo, -(days - 1)) const ELIGIBLE_ORDER_SQL = ` o.created_at >= $1::date AND o.created_at < ($2::date + INTERVAL '1 day') AND o.pay_status = 'paid' AND o.order_status NOT IN ('cancelled', 'closed', 'refunded') AND ( ( $3::boolean AND (o.provider IN ('91kaquan', 'open_91', '91') OR o.platform = 'kuaishou') AND EXISTS ( SELECT 1 FROM kuaishou_industry_vouchers voucher WHERE voucher.oid = o.platform_order_id AND voucher.status = 'UNUSED' AND voucher.send_callback_status = 'success' ) ) OR ( $4::boolean AND o.provider = 'douyin_mashida' AND EXISTS ( SELECT 1 FROM redeem_certificates certificate WHERE certificate.provider = 'douyin_mashida' AND ( certificate.local_order_id = o.id OR certificate.order_id = o.platform_order_id ) AND certificate.status IN ('issued', 'synced') AND certificate.sync_status = 'success' ) ) ) AND NOT EXISTS ( SELECT 1 FROM voucher_consume_jobs job WHERE job.order_id = o.id AND job.status IN ('pending', 'processing', 'succeeded') ) ` async function main() { if (dateFrom > dateTo) { throw new Error(`--from 不能晚于 --to:${dateFrom} > ${dateTo}`) } await loadAppConfigCacheFromDatabase() const policy = getVoucherConsumePolicy() const enabledProviders = (['kuaishou_industry', 'douyin_mashida'] as const).filter( (provider) => policy.platforms[provider].channels.order_received.enabled, ) if (enabledProviders.length === 0) { console.log( JSON.stringify( { mode: execute ? 'execute' : 'dry-run', dateFrom, dateTo, policyDisabled: true, enabledProviders, pendingOrders: 0, }, null, 2, ), ) return } const includeKuaishou = enabledProviders.includes('kuaishou_industry') const includeDouyin = enabledProviders.includes('douyin_mashida') const pending = await countEligibleOrders(includeKuaishou, includeDouyin) const startedAt = Date.now() if (!execute) { const samples = await previewEligibleOrders(includeKuaishou, includeDouyin) console.log( JSON.stringify( { mode: 'dry-run', dateFrom, dateTo, pendingOrders: pending, batchSize, estimatedBatches: Math.ceil(pending / batchSize), samples, durationMs: Date.now() - startedAt, hint: `确认后执行:npm run voucher:backfill-order-received -- --from=${dateFrom} --to=${dateTo} --execute --confirm=${CONFIRMATION}`, }, null, 2, ), ) return } if (confirmation !== CONFIRMATION) { throw new Error(`--confirm 必须是 ${CONFIRMATION}`) } let cursor = 0 let scanned = 0 let queued = 0 let batches = 0 while (batches < maxBatches) { const orderIds = await loadEligibleOrderIds(cursor, batchSize, includeKuaishou, includeDouyin) if (orderIds.length === 0) break const scheduled = await scheduleVoucherConsumeForOrderReceived(orderIds) scanned += orderIds.length queued += scheduled.queuedCount cursor = orderIds[orderIds.length - 1]! batches += 1 if (orderIds.length < batchSize) break } const remaining = await countEligibleOrders(includeKuaishou, includeDouyin) console.log( JSON.stringify( { mode: 'execute', dateFrom, dateTo, pendingBefore: pending, batches, scannedOrders: scanned, queuedJobs: queued, remainingOrders: remaining, durationMs: Date.now() - startedAt, }, null, 2, ), ) if (remaining > 0) { console.error(`仍有 ${remaining} 个订单未入队;请增大 --max-batches 后重跑(幂等)。`) process.exitCode = 2 } } async function countEligibleOrders(includeKuaishou: boolean, includeDouyin: boolean) { const result = await query<{ count: number }>( `SELECT COUNT(*)::int AS count FROM orders o WHERE ${ELIGIBLE_ORDER_SQL}`, [dateFrom, dateTo, includeKuaishou, includeDouyin], ) return Number(result.rows[0]?.count || 0) } async function loadEligibleOrderIds( afterId: number, limit: number, includeKuaishou: boolean, includeDouyin: boolean, ) { const result = await query<{ id: number }>( `SELECT o.id FROM orders o WHERE o.id > $5 AND ${ELIGIBLE_ORDER_SQL} ORDER BY o.id LIMIT $6`, [dateFrom, dateTo, includeKuaishou, includeDouyin, afterId, limit], ) return result.rows.map((row) => Number(row.id)) } async function previewEligibleOrders(includeKuaishou: boolean, includeDouyin: boolean) { const result = await query<{ id: number provider: string platform_order_id: string created_at: string }>( `SELECT o.id, o.provider, o.platform_order_id, o.created_at::text FROM orders o WHERE ${ELIGIBLE_ORDER_SQL} ORDER BY o.id LIMIT 10`, [dateFrom, dateTo, includeKuaishou, includeDouyin], ) return result.rows } function parseOptions(args: string[]) { const result: Record = {} for (const arg of args) { if (!arg.startsWith('--')) continue const body = arg.slice(2) const index = body.indexOf('=') const key = index === -1 ? body : body.slice(0, index) const value = index === -1 ? 'true' : body.slice(index + 1) result[key] = value result[toCamelCase(key)] = value } return result } function toCamelCase(value: string) { return value.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase()) } function normalizeInteger(value: unknown, fallback: number, min: number, max: number) { const parsed = Number(value) if (!Number.isInteger(parsed)) return fallback return Math.min(max, Math.max(min, parsed)) } function normalizeDate(value: unknown): string { const text = String(value || '').trim() return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '' } function today() { return new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }) } function shiftDate(value: string, days: number) { const date = new Date(`${value}T12:00:00+08:00`) date.setUTCDate(date.getUTCDate() + days) return date.toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }) } main() .catch((error) => { console.error(error instanceof Error ? error.message : error) process.exitCode = 1 }) .finally(() => closeDb())