chore(ops): 增加生产库备份/回滚与按 isTest 清理脚本

只入库通用运维能力,不提交写死手机号的一次性清理脚本与 dump。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-23 22:03:35 +08:00
parent a3f17cbf94
commit 7304c7a8e1
9 changed files with 1366 additions and 1 deletions
+3 -1
View File
@@ -22,7 +22,9 @@
"prisma:migrate-wecom-push": "ts-node --transpile-only prisma/migrate-wecom-message-push.ts",
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts",
"prisma:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts"
"prisma:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts",
"inventory:test-data": "ts-node --transpile-only scripts/inventory-test-data.ts",
"cleanup:test-data": "ts-node --transpile-only scripts/cleanup-prod-test-data.ts"
},
"dependencies": {
"@alicloud/dysmsapi20170525": "^4.6.0",
@@ -0,0 +1,448 @@
/**
* 生产/本地测试数据清理脚本
*
* 默认 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());
@@ -0,0 +1,189 @@
/**
* 只读盘点:白名单 / isTest / 限测商品 / 交叉风险
*
* 用法:
* cd server/dukang-api
* pnpm inventory:test-data
* pnpm inventory:test-data -- --json > inventory.json
*/
import '../src/load-env';
import { PrismaClient } from '@prisma/client';
import { writeFileSync } from 'fs';
import { resolve } from 'path';
import {
SEED_TEST_PHONES,
collectTestDataScope,
countTestData,
detectCrossRisks,
} from './test-data-cleanup.shared';
const prisma = new PrismaClient();
const jsonMode = process.argv.includes('--json');
const outArg = process.argv.find((a) => a.startsWith('--out='));
const outPath = outArg ? outArg.slice('--out='.length) : '';
async function main() {
const counts = await countTestData(prisma);
const scope = await collectTestDataScope(prisma);
const risks = await detectCrossRisks(prisma, scope);
const [users, storeAccounts, partners, stores, orders, redeems, limitedProducts] =
await Promise.all([
prisma.user.findMany({
where: { isTest: true },
select: { id: true, userNo: true, phone: true, nickname: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
prisma.storeAccount.findMany({
where: { isTest: true },
select: { id: true, phone: true, name: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
prisma.partnerAccount.findMany({
where: { isTest: true },
select: { id: true, phone: true, name: true, companyName: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
prisma.store.findMany({
where: { isTest: true },
select: { id: true, name: true, phone: true, cityName: true, status: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
prisma.order.findMany({
where: { id: { in: scope.testOrderIds } },
select: { id: true, orderNo: true, userId: true, payAmount: true, status: true, isTest: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: 200,
}),
prisma.redeemRecord.findMany({
where: { id: { in: scope.testRedeemIds } },
select: { id: true, redeemNo: true, storeId: true, userId: true, amount: true, isTest: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: 200,
}),
prisma.commonProductItem.findMany({
where: { visibilityWhitelistEnabled: true },
select: { id: true, name: true, skuCode: true, status: true },
orderBy: { sortOrder: 'asc' },
}),
]);
const whitelistLinked = await Promise.all(
scope.whitelistPhones.map(async (row) => {
const phone = row.phone;
const [user, storeAccount, partner, store] = await Promise.all([
prisma.user.findFirst({ where: { phone }, select: { id: true, userNo: true, isTest: true } }),
prisma.storeAccount.findFirst({ where: { phone }, select: { id: true, name: true, isTest: true } }),
prisma.partnerAccount.findFirst({ where: { phone }, select: { id: true, name: true, isTest: true } }),
prisma.store.findFirst({ where: { phone }, select: { id: true, name: true, isTest: true } }),
]);
return { ...row, user, storeAccount, partner, store };
}),
);
const report = {
generatedAt: new Date().toISOString(),
databaseUrlHost: (process.env.DATABASE_URL ?? '').replace(/:([^:@/]+)@/, ':***@'),
seedTestPhones: SEED_TEST_PHONES,
counts,
whitelist: whitelistLinked,
testUsers: users,
testStoreAccounts: storeAccounts,
testPartners: partners,
testStores: stores,
testOrders: orders,
testRedeems: redeems,
limitedProducts,
limitedProductsWithFormalOrders: scope.limitedProductsWithFormalOrders,
crossRisks: risks,
scopeSummary: {
testUserIds: scope.testUserIds.map(String),
testStoreAccountIds: scope.testStoreAccountIds.map(String),
deletableTestStoreAccountIds: scope.deletableTestStoreAccountIds.map(String),
promotedTestStoreAccountIds: scope.promotedTestStoreAccountIds.map(String),
testPartnerIds: scope.testPartnerIds.map(String),
deletableTestPartnerIds: scope.deletableTestPartnerIds.map(String),
promotedTestPartnerIds: scope.promotedTestPartnerIds.map(String),
testStoreIds: scope.testStoreIds.map(String),
deletableTestStoreIds: scope.deletableTestStoreIds.map(String),
promotedTestStoreIds: scope.promotedTestStoreIds.map(String),
testOrderIds: scope.testOrderIds.map(String),
testRedeemIds: scope.testRedeemIds.map(String),
limitedProductIds: scope.limitedProductIds.map(String),
deletableLimitedProductIds: scope.limitedProductIds
.filter((id) => !scope.limitedProductsWithFormalOrders.some((p) => p.productId === id))
.map(String),
},
};
const serialized = JSON.stringify(
report,
(_key, value) => (typeof value === 'bigint' ? value.toString() : value),
2,
);
if (outPath) {
const abs = resolve(outPath);
writeFileSync(abs, serialized, 'utf8');
console.log(`已写入 ${abs}`);
}
if (jsonMode) {
console.log(serialized);
return;
}
console.log('=== 杜康好客 · 测试数据盘点 ===');
console.log(`时间: ${report.generatedAt}`);
console.log(`库: ${report.databaseUrlHost}`);
console.log('');
console.log('【计数】');
console.log(` 白名单手机号: ${counts.whitelistCount}`);
console.log(` 测试用户: ${counts.testUsers}`);
console.log(` 测试门店账号: ${counts.testStoreAccounts}`);
console.log(` 测试合伙人: ${counts.testPartners}`);
console.log(` 测试门店: ${counts.testStores}`);
console.log(` 测试订单(isTest): ${counts.testOrders} / 待删订单(含测试用户): ${scope.testOrderIds.length}`);
console.log(` 测试核销(isTest): ${counts.testRedeems} / 待删核销: ${scope.testRedeemIds.length}`);
console.log(` 限测商品: ${counts.limitedProducts}`);
console.log(` 仍开「仅白名单可见」的门店: ${counts.visibilityStores}`);
console.log('');
console.log('【白名单手机号】');
for (const row of whitelistLinked) {
const flags = [
row.user ? `user#${row.user.id}${row.user.isTest ? '(test)' : ''}` : null,
row.storeAccount ? `storeAcct#${row.storeAccount.id}` : null,
row.partner ? `partner#${row.partner.id}` : null,
row.store ? `store#${row.store.id}` : null,
]
.filter(Boolean)
.join(', ');
console.log(` ${row.phone} ${row.note ?? ''} ${flags || '(无关联账号)'}`);
}
console.log('');
console.log('【限测商品】');
for (const p of limitedProducts) {
const blocked = scope.limitedProductsWithFormalOrders.find((x) => x.productId === p.id);
console.log(
` #${p.id} ${p.name} (${p.skuCode})${blocked ? ` — 跳过删除,正式订单 ${blocked.formalOrderCount}` : ' — 将删除'}`,
);
}
console.log('');
console.log('【交叉风险】');
if (!risks.length) {
console.log(' (无)');
} else {
for (const risk of risks) {
console.log(` [${risk.code}] ${risk.message} (${risk.details.length} 条)`);
}
}
console.log('');
console.log('确认后执行: pnpm cleanup:test-data -- --apply --yes');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,332 @@
import { Prisma, PrismaClient } from '@prisma/client';
export const SEED_TEST_PHONES = [
'13800000001',
'13700000001',
'13700000002',
'13910000001',
'13910000002',
] as const;
export type TestDataScope = {
whitelistPhones: { id: bigint; phone: string; note: string | null }[];
testUserIds: bigint[];
testStoreAccountIds: bigint[];
deletableTestStoreAccountIds: bigint[];
promotedTestStoreAccountIds: bigint[];
testPartnerIds: bigint[];
deletableTestPartnerIds: bigint[];
promotedTestPartnerIds: bigint[];
testStoreIds: bigint[];
deletableTestStoreIds: bigint[];
promotedTestStoreIds: bigint[];
testOrderIds: bigint[];
testRedeemIds: bigint[];
limitedProductIds: bigint[];
limitedProductsWithFormalOrders: { productId: bigint; name: string; formalOrderCount: number }[];
};
export type CrossRisk = {
code: string;
message: string;
details: unknown[];
};
export async function collectTestDataScope(prisma: PrismaClient): Promise<TestDataScope> {
const whitelistPhones = await prisma.commonTestWhitelistPhone.findMany({
orderBy: { createdAt: 'asc' },
select: { id: true, phone: true, note: true },
});
const [testUsers, testStoreAccounts, testPartners, testStores] = await Promise.all([
prisma.user.findMany({ where: { isTest: true }, select: { id: true } }),
prisma.storeAccount.findMany({ where: { isTest: true }, select: { id: true } }),
prisma.partnerAccount.findMany({ where: { isTest: true }, select: { id: true } }),
prisma.store.findMany({ where: { isTest: true }, select: { id: true } }),
]);
const testUserIds = testUsers.map((r) => r.id);
const testStoreAccountIds = testStoreAccounts.map((r) => r.id);
const testPartnerIds = testPartners.map((r) => r.id);
const testStoreIds = testStores.map((r) => r.id);
const testOrders = await prisma.order.findMany({
where: {
OR: [
{ isTest: true },
...(testUserIds.length ? [{ userId: { in: testUserIds } }] : []),
],
},
select: { id: true },
});
let testOrderIds = testOrders.map((r) => r.id);
if (testOrderIds.length) {
const linkedReshipments = await prisma.order.findMany({
where: { originOrderId: { in: testOrderIds } },
select: { id: true },
});
testOrderIds = [...new Set([...testOrderIds, ...linkedReshipments.map((r) => r.id)])];
}
const testRedeems = await prisma.redeemRecord.findMany({
where: {
OR: [
{ isTest: true },
...(testUserIds.length ? [{ userId: { in: testUserIds } }] : []),
],
},
select: { id: true },
});
const testRedeemIds = testRedeems.map((r) => r.id);
const storesWithFormalRedeems = testStoreIds.length
? await prisma.redeemRecord.findMany({
where: {
storeId: { in: testStoreIds },
isTest: false,
...(testUserIds.length ? { userId: { notIn: testUserIds } } : {}),
},
select: { storeId: true },
distinct: ['storeId'],
})
: [];
const promotedTestStoreIds = storesWithFormalRedeems.map((r) => r.storeId);
const promotedSet = new Set(promotedTestStoreIds.map(String));
const deletableTestStoreIds = testStoreIds.filter((id) => !promotedSet.has(id.toString()));
const partnerStores = testPartnerIds.length
? await prisma.store.findMany({
where: { partnerAccountId: { in: testPartnerIds } },
select: { id: true, partnerAccountId: true, isTest: true },
})
: [];
const deletableTestPartnerIds: bigint[] = [];
const promotedTestPartnerIds: bigint[] = [];
for (const partnerId of testPartnerIds) {
const stores = partnerStores.filter((s) => s.partnerAccountId === partnerId);
const hasRetainedStore = stores.some((s) => !deletableTestStoreIds.some((id) => id === s.id));
if (hasRetainedStore || stores.length === 0) promotedTestPartnerIds.push(partnerId);
else deletableTestPartnerIds.push(partnerId);
}
const accountBindings = testStoreAccountIds.length
? await prisma.storeAccountStore.findMany({
where: { storeAccountId: { in: testStoreAccountIds } },
select: { storeAccountId: true, storeId: true },
})
: [];
const deletableTestStoreAccountIds: bigint[] = [];
const promotedTestStoreAccountIds: bigint[] = [];
for (const accountId of testStoreAccountIds) {
const bindings = accountBindings.filter((b) => b.storeAccountId === accountId);
const hasRetainedStore = bindings.some((b) => !deletableTestStoreIds.some((id) => id === b.storeId));
if (hasRetainedStore || bindings.length === 0) promotedTestStoreAccountIds.push(accountId);
else deletableTestStoreAccountIds.push(accountId);
}
const limitedProducts = await prisma.commonProductItem.findMany({
where: { visibilityWhitelistEnabled: true },
select: { id: true, name: true },
});
const limitedProductIds = limitedProducts.map((p) => p.id);
const limitedProductsWithFormalOrders: TestDataScope['limitedProductsWithFormalOrders'] = [];
for (const product of limitedProducts) {
const formalOrderCount = await prisma.order.count({
where: {
productId: product.id,
isTest: false,
...(testUserIds.length ? { userId: { notIn: testUserIds } } : {}),
},
});
if (formalOrderCount > 0) {
limitedProductsWithFormalOrders.push({
productId: product.id,
name: product.name,
formalOrderCount,
});
}
}
return {
whitelistPhones,
testUserIds,
testStoreAccountIds,
deletableTestStoreAccountIds,
promotedTestStoreAccountIds,
testPartnerIds,
deletableTestPartnerIds,
promotedTestPartnerIds,
testStoreIds,
deletableTestStoreIds,
promotedTestStoreIds,
testOrderIds,
testRedeemIds,
limitedProductIds,
limitedProductsWithFormalOrders,
};
}
export async function detectCrossRisks(
prisma: PrismaClient,
scope: TestDataScope,
): Promise<CrossRisk[]> {
const risks: CrossRisk[] = [];
if (scope.testPartnerIds.length) {
const formalStoresUnderTestPartner = await prisma.store.findMany({
where: {
partnerAccountId: { in: scope.testPartnerIds },
isTest: false,
},
select: { id: true, name: true, phone: true, partnerAccountId: true },
});
if (formalStoresUnderTestPartner.length) {
risks.push({
code: 'TEST_PARTNER_FORMAL_STORES',
message: '测试合伙人名下仍有正式门店',
details: formalStoresUnderTestPartner,
});
}
}
if (scope.testStoreIds.length) {
const formalRedeemsAtTestStore = await prisma.redeemRecord.findMany({
where: {
storeId: { in: scope.testStoreIds },
isTest: false,
...(scope.testUserIds.length ? { userId: { notIn: scope.testUserIds } } : {}),
},
select: { id: true, redeemNo: true, storeId: true, userId: true, amount: true },
take: 50,
});
if (formalRedeemsAtTestStore.length) {
risks.push({
code: 'TEST_STORE_FORMAL_REDEEMS',
message: '测试门店上有正式用户核销;这些门店将改为取消测试标记并保留,不删除',
details: formalRedeemsAtTestStore,
});
}
}
if (scope.testOrderIds.length) {
const paidWineryItems = await prisma.wineryBillItem.findMany({
where: {
orderId: { in: scope.testOrderIds },
wineryBill: { status: 'PAID' },
},
select: {
id: true,
orderNo: true,
wineryBill: { select: { billNo: true, status: true } },
},
take: 50,
});
if (paidWineryItems.length) {
risks.push({
code: 'PAID_WINERY_BILL_TEST_ORDERS',
message: '酒厂账单已打款且含测试订单',
details: paidWineryItems,
});
}
const paidLogisticsItems = await prisma.logisticsBillItem.findMany({
where: {
orderId: { in: scope.testOrderIds },
logisticsBill: { status: 'PAID' },
},
select: {
id: true,
orderNo: true,
logisticsBill: { select: { billNo: true, status: true } },
},
take: 50,
});
if (paidLogisticsItems.length) {
risks.push({
code: 'PAID_LOGISTICS_BILL_TEST_ORDERS',
message: '物流账单已打款且含测试订单',
details: paidLogisticsItems,
});
}
}
if (scope.testRedeemIds.length) {
const paidPayouts = await prisma.storePayout.findMany({
where: {
redeemRecordId: { in: scope.testRedeemIds },
status: 'PAID',
},
select: {
id: true,
redeemRecordId: true,
storeBill: { select: { billNo: true, status: true } },
},
take: 50,
});
if (paidPayouts.length) {
risks.push({
code: 'PAID_STORE_PAYOUT_TEST_REDEEMS',
message: '门店打款已支付且含测试核销',
details: paidPayouts,
});
}
}
if (scope.limitedProductsWithFormalOrders.length) {
risks.push({
code: 'LIMITED_PRODUCT_FORMAL_ORDERS',
message: '限测商品已有正式订单,将跳过删除',
details: scope.limitedProductsWithFormalOrders,
});
}
return risks;
}
export async function countTestData(prisma: PrismaClient) {
const [
whitelistCount,
testUsers,
testStoreAccounts,
testPartners,
testStores,
testOrders,
testRedeems,
limitedProducts,
visibilityStores,
visibilityProducts,
] = await Promise.all([
prisma.commonTestWhitelistPhone.count(),
prisma.user.count({ where: { isTest: true } }),
prisma.storeAccount.count({ where: { isTest: true } }),
prisma.partnerAccount.count({ where: { isTest: true } }),
prisma.store.count({ where: { isTest: true } }),
prisma.order.count({ where: { isTest: true } }),
prisma.redeemRecord.count({ where: { isTest: true } }),
prisma.commonProductItem.count({ where: { visibilityWhitelistEnabled: true } }),
prisma.store.count({ where: { visibilityWhitelistEnabled: true } }),
prisma.commonProductItem.count({ where: { visibilityWhitelistEnabled: true } }),
]);
return {
whitelistCount,
testUsers,
testStoreAccounts,
testPartners,
testStores,
testOrders,
testRedeems,
limitedProducts,
visibilityStores,
visibilityProducts,
};
}
export function idsEmpty(ids: bigint[]): boolean {
return ids.length === 0;
}
export function inIds(ids: bigint[]): Prisma.BigIntFilter | undefined {
return ids.length ? { in: ids } : undefined;
}
+5
View File
@@ -25,6 +25,8 @@ function resolveAppEnv(): 'local' | 'staging' | 'production' {
}
const appEnv = resolveAppEnv();
const preserveDatabaseUrl =
process.env.FORCE_DATABASE_URL === '1' ? process.env.DATABASE_URL : undefined;
const layers =
appEnv === 'staging'
@@ -45,3 +47,6 @@ if (!process.env.NODE_ENV) {
if (!process.env.APP_ENV) {
process.env.APP_ENV = appEnv;
}
if (preserveDatabaseUrl) {
process.env.DATABASE_URL = preserveDatabaseUrl;
}