From 7304c7a8e1cb544d382a2fe23f33315218671c07 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 23 Aug 2026 22:03:35 +0800 Subject: [PATCH] =?UTF-8?q?chore(ops):=20=E5=A2=9E=E5=8A=A0=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E5=BA=93=E5=A4=87=E4=BB=BD/=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=E4=B8=8E=E6=8C=89=20isTest=20=E6=B8=85=E7=90=86=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只入库通用运维能力,不提交写死手机号的一次性清理脚本与 dump。 Co-authored-by: Cursor --- deploy/backup-prod-db.sh | 126 +++++ deploy/restore-prod-db.sh | 126 +++++ deploy/run-prod-cleanup.sh | 123 +++++ deploy/测试数据清理.md | 14 + server/dukang-api/package.json | 4 +- .../scripts/cleanup-prod-test-data.ts | 448 ++++++++++++++++++ .../dukang-api/scripts/inventory-test-data.ts | 189 ++++++++ .../scripts/test-data-cleanup.shared.ts | 332 +++++++++++++ server/dukang-api/src/load-env.ts | 5 + 9 files changed, 1366 insertions(+), 1 deletion(-) create mode 100644 deploy/backup-prod-db.sh create mode 100644 deploy/restore-prod-db.sh create mode 100644 deploy/run-prod-cleanup.sh create mode 100644 deploy/测试数据清理.md create mode 100644 server/dukang-api/scripts/cleanup-prod-test-data.ts create mode 100644 server/dukang-api/scripts/inventory-test-data.ts create mode 100644 server/dukang-api/scripts/test-data-cleanup.shared.ts diff --git a/deploy/backup-prod-db.sh b/deploy/backup-prod-db.sh new file mode 100644 index 0000000..e003347 --- /dev/null +++ b/deploy/backup-prod-db.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# backup-prod-db.sh — 将线上(production)数据库 mysqldump 落盘到 deploy/backups/ +# +# 与 sync-prod-db-to-local.sh 共用 SSH 隧道;仅导出,不覆盖本地库。 +# +# 用法: +# bash deploy/backup-prod-db.sh # 交互确认 +# bash deploy/backup-prod-db.sh --yes # 跳过确认 +# bash deploy/backup-prod-db.sh --dry-run # 只打印计划 +# +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +LOCAL_CONTAINER="dukang-v1-mysql" +TUNNEL_PORT=6019 +REMOTE_ENV_FILE="/opt/dukang/server/dukang-api/.env.production" +HOST_ALIAS="host.docker.internal" +ASSUME_YES=0 +DRY_RUN=0 + +for arg in "$@"; do + case "$arg" in + --yes|-y) ASSUME_YES=1 ;; + --dry-run) DRY_RUN=1 ;; + --help|-h) sed -n '3,12p' "$0"; exit 0 ;; + --tunnel-port=*) TUNNEL_PORT="${arg#*=}" ;; + --container=*) LOCAL_CONTAINER="${arg#*=}" ;; + --host-alias=*) HOST_ALIAS="${arg#*=}" ;; + *) echo "未知参数: $arg" >&2; exit 2 ;; + esac +done + +if [[ -f deploy.env ]]; then + # shellcheck disable=SC1091 + source deploy.env +fi +DEPLOY_HOST="${DEPLOY_HOST:?请在 deploy.env 配置 DEPLOY_HOST}" +DEPLOY_USER="${DEPLOY_USER:-root}" +DEPLOY_PORT="${DEPLOY_PORT:-22}" +SSH=(ssh -o StrictHostKeyChecking=accept-new -p "$DEPLOY_PORT") +if [[ -n "${DEPLOY_SSH_KEY:-}" ]]; then SSH+=(-i "$DEPLOY_SSH_KEY"); fi +TARGET="$DEPLOY_USER@$DEPLOY_HOST" + +echo "==> 读取线上数据库配置 ($TARGET:$REMOTE_ENV_FILE)" +PARSED="$("${SSH[@]}" "$TARGET" bash -s <<'NODEEOF' +cat > /tmp/_parse_db.js <<'JSEOF' +const fs=require("fs"); +const l=fs.readFileSync("/opt/dukang/server/dukang-api/.env.production","utf8").match(/^DATABASE_URL=(.*)$/m)[1].trim(); +let u=l; +if((u[0]==='"'&&u[u.length-1]==='"')||(u[0]==="'"&&u[u.length-1]==="'"))u=u.slice(1,-1); +const U=new URL(u); +const b=s=>Buffer.from(s).toString("base64"); +const path=U.pathname.replace(/^\//,"").split("?")[0]; +process.stdout.write([b(decodeURIComponent(U.username)),b(U.hostname),U.port||"3306",b(path),b(decodeURIComponent(U.password))].join("|")+"\n"); +JSEOF +node /tmp/_parse_db.js; rm -f /tmp/_parse_db.js +NODEEOF +)" +if [[ -z "$PARSED" ]]; then + echo "无法读取远端 DATABASE_URL,中止。" >&2; exit 1 +fi +IFS='|' read -r _U _H _P _DB _PASS <<< "$PARSED" +REMOTE_USER="$(echo "$_U" | base64 -d)" +REMOTE_HOST="$(echo "$_H" | base64 -d)" +REMOTE_PORT="$_P" +REMOTE_DB="$(echo "$_DB" | base64 -d)" +REMOTE_PASS="$(echo "$_PASS" | base64 -d)" + +mkdir -p backups +OUT="backups/prod-${REMOTE_DB}-$(date +%Y%m%d-%H%M%S).sql" + +echo +echo "备份计划:" +echo " 源(线上): $REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT/$REMOTE_DB" +echo " 目标文件: $OUT" +echo " 隧道: $TARGET -L $TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" +if [[ $DRY_RUN -eq 1 ]]; then + echo "(dry-run) 已结束,未做任何修改。" + exit 0 +fi + +if ! docker ps --format '{{.Names}}' | grep -qx "$LOCAL_CONTAINER"; then + echo "本地容器 $LOCAL_CONTAINER 未运行。请先执行: cd deploy && docker compose up -d" >&2 + exit 1 +fi + +if [[ $ASSUME_YES -ne 1 ]]; then + read -r -p "确认备份【线上 $REMOTE_DB】到 $OUT ? [y/N] " ans + [[ "$ans" == "y" || "$ans" == "Y" ]] || { echo "已取消。"; exit 0; } +fi + +CTL="/tmp/backup-prod-db-$$.sock" +echo "==> 建立 SSH 隧道" +"${SSH[@]}" -M -S "$CTL" -f -N -L "$TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" "$TARGET" + +ready=0 +for _ in 1 2 3 4 5 6 7 8 9 10; do + if docker exec -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \ + mysqladmin -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" ping >/dev/null 2>&1; then + ready=1; break + fi + sleep 1 +done +if [[ $ready -ne 1 ]]; then + echo "隧道未就绪,中止。" >&2 + "${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true + exit 1 +fi + +echo "==> mysqldump 导出中..." +docker exec -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \ + mysqldump -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" \ + --lock-tables=0 --add-drop-table --skip-triggers \ + --skip-routines --skip-events --column-statistics=0 --no-tablespaces --set-gtid-purged=OFF \ + --databases "$REMOTE_DB" > "$OUT" +RC=$? + +"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true + +if [[ $RC -ne 0 ]]; then + echo "备份失败 (mysqldump 退出码 $RC)。" >&2 + exit 1 +fi +echo "==> 备份完成 ✅ $OUT ($(wc -c < "$OUT") bytes)" +echo " 提示:请在阿里云 RDS 控制台再创建一次手动快照,作为整库回滚保险。" diff --git a/deploy/restore-prod-db.sh b/deploy/restore-prod-db.sh new file mode 100644 index 0000000..def38ab --- /dev/null +++ b/deploy/restore-prod-db.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# restore-prod-db.sh — 将本地 mysqldump 恢复到线上(production)数据库 +# +# 仅在用户明确要求恢复生产库时使用。 +# +# 用法: +# bash deploy/restore-prod-db.sh backups/prod-dukang_prod-YYYYMMDD-HHMMSS.sql +# bash deploy/restore-prod-db.sh --yes backups/prod-dukang_prod-YYYYMMDD-HHMMSS.sql +# bash deploy/restore-prod-db.sh --dry-run backups/prod-dukang_prod-YYYYMMDD-HHMMSS.sql +# +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +LOCAL_CONTAINER="dukang-v1-mysql" +TUNNEL_PORT=6021 +HOST_ALIAS="host.docker.internal" +ASSUME_YES=0 +DRY_RUN=0 +DUMP="" + +for arg in "$@"; do + case "$arg" in + --yes|-y) ASSUME_YES=1 ;; + --dry-run) DRY_RUN=1 ;; + --help|-h) sed -n '3,14p' "$0"; exit 0 ;; + --tunnel-port=*) TUNNEL_PORT="${arg#*=}" ;; + --container=*) LOCAL_CONTAINER="${arg#*=}" ;; + --host-alias=*) HOST_ALIAS="${arg#*=}" ;; + -*) echo "未知参数: $arg" >&2; exit 2 ;; + *) DUMP="$arg" ;; + esac +done + +if [[ -z "$DUMP" ]]; then + echo "请指定 dump 文件,例如: bash deploy/restore-prod-db.sh backups/prod-dukang_prod-20260823-202044.sql" >&2 + exit 2 +fi +if [[ ! -f "$DUMP" ]]; then + echo "文件不存在: $DUMP" >&2 + exit 1 +fi + +if [[ -f deploy.env ]]; then + # shellcheck disable=SC1091 + source deploy.env +fi +DEPLOY_HOST="${DEPLOY_HOST:?请在 deploy.env 配置 DEPLOY_HOST}" +DEPLOY_USER="${DEPLOY_USER:-root}" +DEPLOY_PORT="${DEPLOY_PORT:-22}" +SSH=(ssh -o StrictHostKeyChecking=accept-new -p "$DEPLOY_PORT") +if [[ -n "${DEPLOY_SSH_KEY:-}" ]]; then SSH+=(-i "$DEPLOY_SSH_KEY"); fi +TARGET="$DEPLOY_USER@$DEPLOY_HOST" + +echo "==> 读取线上数据库配置 ($TARGET)" +PARSED="$("${SSH[@]}" "$TARGET" bash -s <<'NODEEOF' +cat > /tmp/_parse_db.js <<'JSEOF' +const fs=require("fs"); +const l=fs.readFileSync("/opt/dukang/server/dukang-api/.env.production","utf8").match(/^DATABASE_URL=(.*)$/m)[1].trim(); +let u=l; +if((u[0]==='"'&&u[u.length-1]==='"')||(u[0]==="'"&&u[u.length-1]==="'"))u=u.slice(1,-1); +const U=new URL(u); +const b=s=>Buffer.from(s).toString("base64"); +const path=U.pathname.replace(/^\//,"").split("?")[0]; +process.stdout.write([b(decodeURIComponent(U.username)),b(U.hostname),U.port||"3306",b(path),b(decodeURIComponent(U.password))].join("|")+"\n"); +JSEOF +node /tmp/_parse_db.js; rm -f /tmp/_parse_db.js +NODEEOF +)" +if [[ -z "$PARSED" ]]; then + echo "无法读取远端 DATABASE_URL,中止。" >&2; exit 1 +fi +IFS='|' read -r _U _H _P _DB _PASS <<< "$PARSED" +REMOTE_USER="$(echo "$_U" | base64 -d)" +REMOTE_HOST="$(echo "$_H" | base64 -d)" +REMOTE_PORT="$_P" +REMOTE_DB="$(echo "$_DB" | base64 -d)" +REMOTE_PASS="$(echo "$_PASS" | base64 -d)" + +echo +echo "恢复计划:" +echo " 源文件: $DUMP ($(wc -c < "$DUMP") bytes)" +echo " 目标(线上): $REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT/$REMOTE_DB" +echo " 隧道: $TARGET -L $TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" +if [[ $DRY_RUN -eq 1 ]]; then + echo "(dry-run) 已结束,未做任何修改。" + exit 0 +fi + +if ! docker ps --format '{{.Names}}' | grep -qx "$LOCAL_CONTAINER"; then + echo "本地容器 $LOCAL_CONTAINER 未运行。" >&2 + exit 1 +fi + +if [[ $ASSUME_YES -ne 1 ]]; then + read -r -p "确认用该 dump 覆盖【线上 $REMOTE_DB】? [y/N] " ans + [[ "$ans" == "y" || "$ans" == "Y" ]] || { echo "已取消。"; exit 0; } +fi + +CTL="/tmp/restore-prod-db-$$.sock" +echo "==> 建立 SSH 隧道" +"${SSH[@]}" -M -S "$CTL" -f -N -L "$TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" "$TARGET" + +cleanup_tunnel() { + "${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true +} +trap cleanup_tunnel EXIT + +ready=0 +for _ in 1 2 3 4 5 6 7 8 9 10; do + if docker exec -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \ + mysqladmin -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" ping >/dev/null 2>&1; then + ready=1; break + fi + sleep 1 +done +if [[ $ready -ne 1 ]]; then + echo "隧道未就绪,中止。" >&2 + exit 1 +fi + +echo "==> 导入 dump 到线上 $REMOTE_DB ..." +docker exec -i -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \ + mysql -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" \ + --default-character-set=utf8mb4 < "$DUMP" +echo "==> 恢复完成 ✅ 线上 $REMOTE_DB 已回滚到 $DUMP" diff --git a/deploy/run-prod-cleanup.sh b/deploy/run-prod-cleanup.sh new file mode 100644 index 0000000..3f9784a --- /dev/null +++ b/deploy/run-prod-cleanup.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# run-prod-cleanup.sh — 经 SSH 隧道连接生产库,执行测试数据清理 +# +# 用法: +# bash deploy/run-prod-cleanup.sh # dry-run +# bash deploy/run-prod-cleanup.sh --apply --yes # 写库(默认带 --force 处理已打款测试核销) +# bash deploy/run-prod-cleanup.sh --inventory # 仅盘点 +# +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$SCRIPT_DIR" + +LOCAL_CONTAINER="dukang-v1-mysql" +TUNNEL_PORT=6020 +DOCKER_HOST_ALIAS="host.docker.internal" +CLIENT_HOST="127.0.0.1" +APPLY=0 +ASSUME_YES=0 +FORCE=1 +INVENTORY=0 +SCRIPT_NAME="" +EXTRA_ARGS=() + +for arg in "$@"; do + case "$arg" in + --apply) APPLY=1 ;; + --yes|-y) ASSUME_YES=1 ;; + --no-force) FORCE=0 ;; + --force) FORCE=1 ;; + --inventory) INVENTORY=1 ;; + --script=*) SCRIPT_NAME="${arg#*=}" ;; + --help|-h) + sed -n '3,12p' "$0" + exit 0 + ;; + *) EXTRA_ARGS+=("$arg") ;; + esac +done + +if [[ -f deploy.env ]]; then + # shellcheck disable=SC1091 + source deploy.env +fi +DEPLOY_HOST="${DEPLOY_HOST:?请在 deploy.env 配置 DEPLOY_HOST}" +DEPLOY_USER="${DEPLOY_USER:-root}" +DEPLOY_PORT="${DEPLOY_PORT:-22}" +SSH=(ssh -o StrictHostKeyChecking=accept-new -p "$DEPLOY_PORT") +if [[ -n "${DEPLOY_SSH_KEY:-}" ]]; then SSH+=(-i "$DEPLOY_SSH_KEY"); fi +TARGET="$DEPLOY_USER@$DEPLOY_HOST" + +if ! docker ps --format '{{.Names}}' | grep -qx "$LOCAL_CONTAINER"; then + echo "本地容器 $LOCAL_CONTAINER 未运行。" >&2 + exit 1 +fi + +PARSED="$("${SSH[@]}" "$TARGET" bash -s <<'NODEEOF' +cat > /tmp/_parse_db.js <<'JSEOF' +const fs=require("fs"); +const l=fs.readFileSync("/opt/dukang/server/dukang-api/.env.production","utf8").match(/^DATABASE_URL=(.*)$/m)[1].trim(); +let u=l; +if((u[0]==='"'&&u[u.length-1]==='"')||(u[0]==="'"&&u[u.length-1]==="'"))u=u.slice(1,-1); +const U=new URL(u); +const b=s=>Buffer.from(s).toString("base64"); +const path=U.pathname.replace(/^\//,"").split("?")[0]; +process.stdout.write([b(decodeURIComponent(U.username)),b(U.hostname),U.port||"3306",b(path),b(decodeURIComponent(U.password))].join("|")+"\n"); +JSEOF +node /tmp/_parse_db.js; rm -f /tmp/_parse_db.js +NODEEOF +)" +IFS='|' read -r _U _H _P _DB _PASS <<< "$PARSED" +REMOTE_USER="$(echo "$_U" | base64 -d)" +REMOTE_HOST="$(echo "$_H" | base64 -d)" +REMOTE_PORT="$_P" +REMOTE_DB="$(echo "$_DB" | base64 -d)" +REMOTE_PASS="$(echo "$_PASS" | base64 -d)" + +CTL="/tmp/run-prod-cleanup-$$.sock" +echo "==> 建立 SSH 隧道 $TARGET -L $TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" +"${SSH[@]}" -M -S "$CTL" -f -N -L "$TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" "$TARGET" + +cleanup_tunnel() { + "${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true +} +trap cleanup_tunnel EXIT + +ready=0 +for _ in 1 2 3 4 5 6 7 8 9 10; do + if docker exec -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \ + mysqladmin -h "$DOCKER_HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" ping >/dev/null 2>&1; then + ready=1; break + fi + sleep 1 +done +if [[ $ready -ne 1 ]]; then + echo "隧道未就绪,中止。" >&2 + exit 1 +fi + +ENC_PASS="$(node -e "console.log(encodeURIComponent(process.argv[1]))" "$REMOTE_PASS")" +export DATABASE_URL="mysql://${REMOTE_USER}:${ENC_PASS}@${CLIENT_HOST}:${TUNNEL_PORT}/${REMOTE_DB}?charset=utf8mb4" +export FORCE_DATABASE_URL=1 + +cd "$REPO_ROOT/server/dukang-api" +CMD=(pnpm) +if [[ -n "$SCRIPT_NAME" ]]; then + CMD+=(ts-node --transpile-only "scripts/${SCRIPT_NAME}.ts") + [[ $FORCE -eq 1 ]] && CMD+=(--force) + [[ $APPLY -eq 1 ]] && CMD+=(--apply) + [[ $ASSUME_YES -eq 1 ]] && CMD+=(--yes) + [[ ${#EXTRA_ARGS[@]} -gt 0 ]] && CMD+=("${EXTRA_ARGS[@]}") +elif [[ $INVENTORY -eq 1 ]]; then + CMD+=(inventory:test-data -- "${EXTRA_ARGS[@]}") +else + CMD+=(cleanup:test-data --) + [[ $FORCE -eq 1 ]] && CMD+=(--force) + [[ $APPLY -eq 1 ]] && CMD+=(--apply) + [[ $ASSUME_YES -eq 1 ]] && CMD+=(--yes) + CMD+=("${EXTRA_ARGS[@]}") +fi + +echo "==> 执行: DATABASE_URL=*** ${CMD[*]}" +"${CMD[@]}" diff --git a/deploy/测试数据清理.md b/deploy/测试数据清理.md new file mode 100644 index 0000000..6b2fb1c --- /dev/null +++ b/deploy/测试数据清理.md @@ -0,0 +1,14 @@ +# 测试数据清理(通用) + +按 HQ 白名单 + `isTest` 打标清理,**不写死手机号/订单号**。dump 落在 `deploy/backups/`(已 gitignore)。 + +## 流程 + +1. 备份:`bash deploy/backup-prod-db.sh --yes`;建议同时在 RDS 控制台打手动快照 +2. 同步到本地演练:`bash deploy/sync-prod-db-to-local.sh --yes` +3. 盘点(默认本地 `.env`):`pnpm --dir server/dukang-api inventory:test-data` +4. 本地 dry-run / apply:`pnpm --dir server/dukang-api cleanup:test-data`,确认后加 `--apply --yes` +5. 生产须口头允许后再执行:`bash deploy/run-prod-cleanup.sh`(dry-run)→ `--apply --yes` +6. 回滚:`bash deploy/restore-prod-db.sh --yes backups/prod-<库名>-<时间>.sql` + +已打款且混有测试流水时需 `--force`。有交叉风险(测试合伙人下仍有正式门店等)会中止。 diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index 410afb8..231a858 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -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", diff --git a/server/dukang-api/scripts/cleanup-prod-test-data.ts b/server/dukang-api/scripts/cleanup-prod-test-data.ts new file mode 100644 index 0000000..dc0c998 --- /dev/null +++ b/server/dukang-api/scripts/cleanup-prod-test-data.ts @@ -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; + +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()); diff --git a/server/dukang-api/scripts/inventory-test-data.ts b/server/dukang-api/scripts/inventory-test-data.ts new file mode 100644 index 0000000..54f136b --- /dev/null +++ b/server/dukang-api/scripts/inventory-test-data.ts @@ -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()); diff --git a/server/dukang-api/scripts/test-data-cleanup.shared.ts b/server/dukang-api/scripts/test-data-cleanup.shared.ts new file mode 100644 index 0000000..d627ed9 --- /dev/null +++ b/server/dukang-api/scripts/test-data-cleanup.shared.ts @@ -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 { + 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 { + 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; +} diff --git a/server/dukang-api/src/load-env.ts b/server/dukang-api/src/load-env.ts index 3455a88..d80390c 100644 --- a/server/dukang-api/src/load-env.ts +++ b/server/dukang-api/src/load-env.ts @@ -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; +}