/** * 生产/本地测试数据清理脚本 * * 默认 dry-run;加 --apply --yes 才写库。 * * 用法: * cd server/dukang-api * pnpm inventory:test-data * pnpm cleanup:test-data * pnpm cleanup:test-data -- --apply --yes * APP_ENV=production pnpm cleanup:test-data -- --apply --yes */ import '../src/load-env'; import { Prisma, PrismaClient } from '@prisma/client'; import { collectTestDataScope, countTestData, detectCrossRisks, } from './test-data-cleanup.shared'; const prisma = new PrismaClient(); const apply = process.argv.includes('--apply'); const assumeYes = process.argv.includes('--yes'); const skipRiskBlock = process.argv.includes('--force'); const jsonMode = process.argv.includes('--json'); type DeleteStats = Record; function bump(stats: DeleteStats, key: string, n: number) { stats[key] = (stats[key] ?? 0) + n; } async function recalcStoreBill(prismaTx: Prisma.TransactionClient, billId: bigint) { const payouts = await prismaTx.storePayout.findMany({ where: { storeBillId: billId }, select: { redeemAmount: true, payoutAmount: true }, }); if (!payouts.length) { await prismaTx.storeBill.delete({ where: { id: billId } }); return 'deleted'; } const redeemAmount = payouts.reduce((s, p) => s + Number(p.redeemAmount), 0); const payoutAmount = payouts.reduce((s, p) => s + Number(p.payoutAmount), 0); const bill = await prismaTx.storeBill.findUniqueOrThrow({ where: { id: billId } }); const rate = Number(bill.settlementRate); await prismaTx.storeBill.update({ where: { id: billId }, data: { redeemCount: payouts.length, redeemAmount, payoutAmount, settlementRate: rate, }, }); return 'updated'; } async function recalcWineryBill(prismaTx: Prisma.TransactionClient, billId: bigint) { const items = await prismaTx.wineryBillItem.findMany({ where: { wineryBillId: billId } }); if (!items.length) { await prismaTx.wineryBill.delete({ where: { id: billId } }); return 'deleted'; } const orderAmount = items.reduce((s, i) => s + Number(i.payAmount), 0); const wineryAmount = items.reduce((s, i) => s + Number(i.wineryAmount), 0); await prismaTx.wineryBill.update({ where: { id: billId }, data: { orderCount: items.length, orderAmount, wineryAmount, }, }); return 'updated'; } async function recalcLogisticsBill(prismaTx: Prisma.TransactionClient, billId: bigint) { const items = await prismaTx.logisticsBillItem.findMany({ where: { logisticsBillId: billId } }); if (!items.length) { await prismaTx.logisticsBill.delete({ where: { id: billId } }); return 'deleted'; } const bottleCount = items.reduce((s, i) => s + i.quantity, 0); const logisticsAmount = items.reduce((s, i) => s + Number(i.logisticsAmount), 0); await prismaTx.logisticsBill.update({ where: { id: billId }, data: { orderCount: items.length, bottleCount, logisticsAmount, }, }); return 'updated'; } async function runCleanup(scope: Awaited>) { const stats: DeleteStats = {}; const deletableLimitedProductIds = scope.limitedProductIds.filter( (id) => !scope.limitedProductsWithFormalOrders.some((p) => p.productId === id), ); const { testUserIds, deletableTestStoreAccountIds, promotedTestStoreAccountIds, deletableTestPartnerIds, promotedTestPartnerIds, deletableTestStoreIds, promotedTestStoreIds, testOrderIds, testRedeemIds, } = scope; await prisma.$transaction( async (tx) => { // --- analytics / logs --- if (testUserIds.length) { bump(stats, 'logUserAnalytics', (await tx.logUserAnalytics.deleteMany({ where: { userId: { in: testUserIds } } })).count); bump(stats, 'logPromoEvent(user)', (await tx.logPromoEvent.deleteMany({ where: { userId: { in: testUserIds } } })).count); } if (deletableTestStoreIds.length) { bump(stats, 'logStoreAnalytics', (await tx.logStoreAnalytics.deleteMany({ where: { storeId: { in: deletableTestStoreIds } } })).count); } if (deletableTestStoreAccountIds.length) { bump(stats, 'logStoreAnalytics(acct)', (await tx.logStoreAnalytics.deleteMany({ where: { storeAccountId: { in: deletableTestStoreAccountIds } } })).count); } if (deletableTestPartnerIds.length) { bump(stats, 'logPartnerAnalytics', (await tx.logPartnerAnalytics.deleteMany({ where: { partnerAccountId: { in: deletableTestPartnerIds } } })).count); } if (testOrderIds.length) { bump(stats, 'logPromoEvent(order)', (await tx.logPromoEvent.deleteMany({ where: { orderId: { in: testOrderIds } } })).count); const wineryItems = await tx.wineryBillItem.findMany({ where: { orderId: { in: testOrderIds } }, select: { wineryBillId: true }, }); const wineryBillIds = [...new Set(wineryItems.map((i) => i.wineryBillId))]; bump(stats, 'wineryBillItem', (await tx.wineryBillItem.deleteMany({ where: { orderId: { in: testOrderIds } } })).count); const logisticsItems = await tx.logisticsBillItem.findMany({ where: { orderId: { in: testOrderIds } }, select: { logisticsBillId: true }, }); const logisticsBillIds = [...new Set(logisticsItems.map((i) => i.logisticsBillId))]; bump(stats, 'logisticsBillItem', (await tx.logisticsBillItem.deleteMany({ where: { orderId: { in: testOrderIds } } })).count); for (const billId of wineryBillIds) { const action = await recalcWineryBill(tx, billId); if (action === 'deleted') bump(stats, 'wineryBill', 1); else bump(stats, 'wineryBillRecalc', 1); } for (const billId of logisticsBillIds) { const action = await recalcLogisticsBill(tx, billId); if (action === 'deleted') bump(stats, 'logisticsBill', 1); else bump(stats, 'logisticsBillRecalc', 1); } } // --- redeem chain --- if (testRedeemIds.length) { const payouts = await tx.storePayout.findMany({ where: { redeemRecordId: { in: testRedeemIds } }, select: { id: true, storeBillId: true }, }); const payoutIds = payouts.map((p) => p.id); const billIds = [...new Set(payouts.map((p) => p.storeBillId).filter((id): id is bigint => id != null))]; if (payoutIds.length) { bump(stats, 'storeWithdrawPayoutItem', (await tx.storeWithdrawPayoutItem.deleteMany({ where: { storePayoutId: { in: payoutIds } } })).count); bump(stats, 'storePayout', (await tx.storePayout.deleteMany({ where: { id: { in: payoutIds } } })).count); } for (const billId of billIds) { const action = await recalcStoreBill(tx, billId); if (action === 'deleted') bump(stats, 'storeBill', 1); else bump(stats, 'storeBillRecalc', 1); } bump(stats, 'storeRating', (await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: testRedeemIds } } })).count); bump(stats, 'redeemPendingRecord', (await tx.redeemPendingRecord.deleteMany({ where: { redeemRecordId: { in: testRedeemIds } } })).count); bump(stats, 'redeemRecordAllocation', (await tx.redeemRecordAllocation.deleteMany({ where: { redeemRecordId: { in: testRedeemIds } } })).count); bump(stats, 'redeemRecord', (await tx.redeemRecord.deleteMany({ where: { id: { in: testRedeemIds } } })).count); } if (deletableTestStoreIds.length || deletableTestStoreAccountIds.length) { bump( stats, 'redeemPendingRecord(store)', ( await tx.redeemPendingRecord.deleteMany({ where: { OR: [ ...(deletableTestStoreIds.length ? [{ storeId: { in: deletableTestStoreIds } }] : []), ...(deletableTestStoreAccountIds.length ? [{ storeAccountId: { in: deletableTestStoreAccountIds } }] : []), ], }, }) ).count, ); } if (deletableTestStoreIds.length || deletableTestStoreAccountIds.length) { bump( stats, 'storeWithdrawRequest', ( await tx.storeWithdrawRequest.deleteMany({ where: { OR: [ ...(deletableTestStoreIds.length ? [{ storeId: { in: deletableTestStoreIds } }] : []), ...(deletableTestStoreAccountIds.length ? [{ storeAccountId: { in: deletableTestStoreAccountIds } }] : []), ], }, }) ).count, ); } // --- orders / coupons / invoices --- if (testOrderIds.length) { bump(stats, 'userInvoice', (await tx.userInvoice.deleteMany({ where: { orderId: { in: testOrderIds } } })).count); } if (testUserIds.length) { bump(stats, 'userInvoice(user)', (await tx.userInvoice.deleteMany({ where: { userId: { in: testUserIds } } })).count); bump(stats, 'benefitCoupon', (await tx.benefitCoupon.deleteMany({ where: { userId: { in: testUserIds } } })).count); } if (testOrderIds.length) { bump(stats, 'benefitCoupon(order)', (await tx.benefitCoupon.deleteMany({ where: { orderId: { in: testOrderIds } } })).count); bump(stats, 'orderDelivery', (await tx.orderDelivery.deleteMany({ where: { orderId: { in: testOrderIds } } })).count); bump(stats, 'order', (await tx.order.deleteMany({ where: { id: { in: testOrderIds } } })).count); } // --- promote mixed entities before deleting pure test ones --- if (promotedTestStoreIds.length) { bump( stats, 'store.promoted', ( await tx.store.updateMany({ where: { id: { in: promotedTestStoreIds } }, data: { isTest: false, visibilityWhitelistEnabled: false }, }) ).count, ); } if (promotedTestStoreAccountIds.length) { bump( stats, 'storeAccount.promoted', ( await tx.storeAccount.updateMany({ where: { id: { in: promotedTestStoreAccountIds } }, data: { isTest: false }, }) ).count, ); } if (promotedTestPartnerIds.length) { bump( stats, 'partnerAccount.promoted', ( await tx.partnerAccount.updateMany({ where: { id: { in: promotedTestPartnerIds } }, data: { isTest: false }, }) ).count, ); } // --- store subtree (deletable only) --- if (deletableTestStoreIds.length) { bump(stats, 'storeVisibilityPhone', (await tx.storeVisibilityPhone.deleteMany({ where: { storeId: { in: deletableTestStoreIds } } })).count); bump(stats, 'storePackage', (await tx.storePackage.deleteMany({ where: { storeId: { in: deletableTestStoreIds } } })).count); bump(stats, 'storePackageChangeRequest', (await tx.storePackageChangeRequest.deleteMany({ where: { storeId: { in: deletableTestStoreIds } } })).count); bump(stats, 'storeInfoChangeRequest', (await tx.storeInfoChangeRequest.deleteMany({ where: { storeId: { in: deletableTestStoreIds } } })).count); bump(stats, 'storeAccountStore', (await tx.storeAccountStore.deleteMany({ where: { storeId: { in: deletableTestStoreIds } } })).count); bump(stats, 'store', (await tx.store.deleteMany({ where: { id: { in: deletableTestStoreIds } } })).count); } if (deletableTestStoreAccountIds.length) { await tx.storeAccount.updateMany({ where: { parentAccountId: { in: deletableTestStoreAccountIds } }, data: { parentAccountId: null }, }); bump(stats, 'storeAccountStore(acct)', (await tx.storeAccountStore.deleteMany({ where: { storeAccountId: { in: deletableTestStoreAccountIds } } })).count); bump(stats, 'storeAccount', (await tx.storeAccount.deleteMany({ where: { id: { in: deletableTestStoreAccountIds } } })).count); } // --- partners (deletable only) --- if (deletableTestPartnerIds.length) { bump(stats, 'partnerBill', (await tx.partnerBill.deleteMany({ where: { partnerAccountId: { in: deletableTestPartnerIds } } })).count); await tx.partnerAccount.updateMany({ where: { parentAccountId: { in: deletableTestPartnerIds } }, data: { parentAccountId: null }, }); await tx.partnerAccount.updateMany({ where: { id: { in: deletableTestPartnerIds } }, data: { managedWarehouseId: null }, }); await tx.cityWarehouse.updateMany({ where: { partnerAccountId: { in: deletableTestPartnerIds } }, data: { partnerAccountId: null }, }); bump(stats, 'partnerAccount', (await tx.partnerAccount.deleteMany({ where: { id: { in: deletableTestPartnerIds } } })).count); } // --- users --- if (testUserIds.length) { await tx.user.updateMany({ where: { mergedIntoUserId: { in: testUserIds } }, data: { mergedIntoUserId: null }, }); await tx.user.updateMany({ where: { referrerUserId: { in: testUserIds } }, data: { referrerUserId: null }, }); bump(stats, 'userPromoAttribution', (await tx.userPromoAttribution.deleteMany({ where: { userId: { in: testUserIds } } })).count); bump(stats, 'commonPromoCode(owner)', (await tx.commonPromoCode.updateMany({ where: { ownerUserId: { in: testUserIds } }, data: { ownerUserId: null } })).count); bump(stats, 'user', (await tx.user.deleteMany({ where: { id: { in: testUserIds } } })).count); } // --- limited products --- if (deletableLimitedProductIds.length) { bump( stats, 'commonProductVisibilityPhone', (await tx.commonProductVisibilityPhone.deleteMany({ where: { productId: { in: deletableLimitedProductIds } } })).count, ); bump(stats, 'commonProductItem', (await tx.commonProductItem.deleteMany({ where: { id: { in: deletableLimitedProductIds } } })).count); } // --- whitelist + visibility flags --- bump(stats, 'commonTestWhitelistPhone', (await tx.commonTestWhitelistPhone.deleteMany()).count); bump( stats, 'store.visibilityOff', ( await tx.store.updateMany({ where: { visibilityWhitelistEnabled: true }, data: { visibilityWhitelistEnabled: false }, }) ).count, ); bump( stats, 'product.visibilityOff', ( await tx.commonProductItem.updateMany({ where: { visibilityWhitelistEnabled: true }, data: { visibilityWhitelistEnabled: false }, }) ).count, ); }, { timeout: 600_000 }, ); return stats; } async function main() { const before = await countTestData(prisma); const scope = await collectTestDataScope(prisma); const risks = await detectCrossRisks(prisma, scope); const blockingRisks = risks.filter((r) => { if (r.code === 'LIMITED_PRODUCT_FORMAL_ORDERS' || r.code === 'TEST_STORE_FORMAL_REDEEMS') { return false; } if (r.code === 'TEST_PARTNER_FORMAL_STORES') return true; if (skipRiskBlock) return false; return ( r.code === 'PAID_WINERY_BILL_TEST_ORDERS' || r.code === 'PAID_LOGISTICS_BILL_TEST_ORDERS' || r.code === 'PAID_STORE_PAYOUT_TEST_REDEEMS' ); }); const plan = { mode: apply ? 'apply' : 'dry-run', before, scope: { testUsers: scope.testUserIds.length, testStoreAccounts: scope.testStoreAccountIds.length, deletableTestStoreAccounts: scope.deletableTestStoreAccountIds.length, promotedTestStoreAccounts: scope.promotedTestStoreAccountIds.length, testPartners: scope.testPartnerIds.length, deletableTestPartners: scope.deletableTestPartnerIds.length, promotedTestPartners: scope.promotedTestPartnerIds.length, testStores: scope.testStoreIds.length, deletableTestStores: scope.deletableTestStoreIds.length, promotedTestStores: scope.promotedTestStoreIds.length, testOrders: scope.testOrderIds.length, testRedeems: scope.testRedeemIds.length, whitelistPhones: scope.whitelistPhones.length, limitedProducts: scope.limitedProductIds.length, deletableLimitedProducts: scope.limitedProductIds.length - scope.limitedProductsWithFormalOrders.length, }, crossRisks: risks, blockingRisks: blockingRisks.map((r) => r.code), }; if (jsonMode) { console.log(JSON.stringify(plan, null, 2)); } else { console.log('=== 杜康好客 · 测试数据清理 ==='); console.log(`模式: ${plan.mode}`); console.log(`库: ${(process.env.DATABASE_URL ?? '').replace(/:([^:@/]+)@/, ':***@')}`); console.log('待处理:', plan.scope); if (risks.length) { console.log('交叉风险:'); for (const r of risks) console.log(` [${r.code}] ${r.message}`); } } if (blockingRisks.length) { console.error('存在阻塞性交叉风险,已中止。请查看 inventory 报告或加 --force(已打款账单类风险)。'); process.exit(2); } if (!apply) { console.log('\n(dry-run) 未修改数据库。确认后加: --apply --yes'); return; } if (!assumeYes) { console.error('写库需同时传 --apply --yes'); process.exit(2); } const stats = await runCleanup(scope); const after = await countTestData(prisma); const result = { stats, after }; if (jsonMode) { console.log(JSON.stringify(result, null, 2)); } else { console.log('\n删除统计:', stats); console.log('清理后计数:', after); console.log('完成 ✅'); } } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect());