Compare commits
1 Commits
dev
..
清除测试数据-数据库
| Author | SHA1 | Date | |
|---|---|---|---|
| 7304c7a8e1 |
@@ -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 控制台再创建一次手动快照,作为整库回滚保险。"
|
||||||
@@ -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"
|
||||||
@@ -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[@]}"
|
||||||
@@ -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`。有交叉风险(测试合伙人下仍有正式门店等)会中止。
|
||||||
@@ -22,7 +22,9 @@
|
|||||||
"prisma:migrate-wecom-push": "ts-node --transpile-only prisma/migrate-wecom-message-push.ts",
|
"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: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: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": {
|
"dependencies": {
|
||||||
"@alicloud/dysmsapi20170525": "^4.6.0",
|
"@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;
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ function resolveAppEnv(): 'local' | 'staging' | 'production' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const appEnv = resolveAppEnv();
|
const appEnv = resolveAppEnv();
|
||||||
|
const preserveDatabaseUrl =
|
||||||
|
process.env.FORCE_DATABASE_URL === '1' ? process.env.DATABASE_URL : undefined;
|
||||||
|
|
||||||
const layers =
|
const layers =
|
||||||
appEnv === 'staging'
|
appEnv === 'staging'
|
||||||
@@ -45,3 +47,6 @@ if (!process.env.NODE_ENV) {
|
|||||||
if (!process.env.APP_ENV) {
|
if (!process.env.APP_ENV) {
|
||||||
process.env.APP_ENV = appEnv;
|
process.env.APP_ENV = appEnv;
|
||||||
}
|
}
|
||||||
|
if (preserveDatabaseUrl) {
|
||||||
|
process.env.DATABASE_URL = preserveDatabaseUrl;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user