7304c7a8e1
只入库通用运维能力,不提交写死手机号的一次性清理脚本与 dump。 Co-authored-by: Cursor <cursoragent@cursor.com>
449 lines
18 KiB
TypeScript
449 lines
18 KiB
TypeScript
/**
|
|
* 生产/本地测试数据清理脚本
|
|
*
|
|
* 默认 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<string, number>;
|
|
|
|
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<ReturnType<typeof collectTestDataScope>>) {
|
|
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());
|