#!/usr/bin/env bash set -euo pipefail BASE_URL="${BASE_URL:-http://127.0.0.1:8080}" CONFIG_PATH="${CONFIG_PATH:-etc/ppanel.yaml}" EMAIL_A="${EMAIL_A:-family_cleanup_$(date +%s)@example.com}" DEVICE_A="${DEVICE_A:-qa-cleanup-device-a-$(date +%s)}" DEVICE_B="${DEVICE_B:-qa-cleanup-device-b-$(date +%s)}" SEND_CODE_RETRY_WAIT="${SEND_CODE_RETRY_WAIT:-61}" SEND_CODE_MAX_RETRY="${SEND_CODE_MAX_RETRY:-8}" VERIFY_CODE_PROVIDER="${VERIFY_CODE_PROVIDER:-}" VERIFY_CODE_OVERRIDE="${VERIFY_CODE_OVERRIDE:-}" REGISTER_CODE_OVERRIDE="${REGISTER_CODE_OVERRIDE:-}" SECURITY_CODE_OVERRIDE="${SECURITY_CODE_OVERRIDE:-}" DELETE_ACCOUNT_CODE_OVERRIDE="${DELETE_ACCOUNT_CODE_OVERRIDE:-}" ALLOW_INTERACTIVE_INPUT="${ALLOW_INTERACTIVE_INPUT:-auto}" ENABLE_REDIS_CODE_FALLBACK="${ENABLE_REDIS_CODE_FALLBACK:-1}" REDIS_CLI_BIN="${REDIS_CLI_BIN:-redis-cli}" REDIS_HOST="${REDIS_HOST:-127.0.0.1}" REDIS_PORT="${REDIS_PORT:-6379}" REDIS_DB="${REDIS_DB:-0}" REDIS_PASSWORD="${REDIS_PASSWORD:-}" BIND_VERIFY_RETRY="${BIND_VERIFY_RETRY:-1}" bold() { printf "\033[1m%s\033[0m\n" "$*"; } info() { printf "[INFO] %s\n" "$*" >&2; } warn() { printf "[WARN] %s\n" "$*" >&2; } fail() { printf "[FAIL] %s\n" "$*" >&2; exit 1; } require_cmd() { command -v "$1" >/dev/null 2>&1 || fail "缺少命令: $1" } json_code() { jq -r '.code // ""' <<<"$1"; } json_msg() { jq -r '.msg // ""' <<<"$1"; } can_prompt_input() { if [[ "$ALLOW_INTERACTIVE_INPUT" == "1" ]]; then return 0 fi if [[ "$ALLOW_INTERACTIVE_INPUT" == "0" ]]; then return 1 fi [[ -t 0 ]] } redis_get_key_raw() { local key="$1" command -v "$REDIS_CLI_BIN" >/dev/null 2>&1 || return 1 local -a args args=("$REDIS_CLI_BIN" -h "$REDIS_HOST" -p "$REDIS_PORT" -n "$REDIS_DB" --raw) if [[ -n "$REDIS_PASSWORD" ]]; then args+=(-a "$REDIS_PASSWORD") fi "${args[@]}" GET "$key" 2>/dev/null || return 1 } get_verify_code_from_redis() { local email="$1" local typ="$2" local scene key raw code if [[ "$ENABLE_REDIS_CODE_FALLBACK" != "1" ]]; then return 1 fi case "$typ" in 1) scene="register" ;; 2) scene="security" ;; 4) scene="delete_account" ;; *) scene="unknown" ;; esac key="auth:verify:email:${scene}:${email}" raw="$(redis_get_key_raw "$key" || true)" [[ -n "$raw" ]] || return 1 code="$(jq -r '.code // ""' <<<"$raw" 2>/dev/null || true)" [[ -n "$code" && "$code" != "null" ]] || return 1 printf "%s" "$code" return 0 } api_request() { local method="$1" local path="$2" local body="${3:-}" local token="${4:-}" local -a args args=(-sS -X "$method" "${BASE_URL}${path}" -H "Content-Type: application/json") if [[ -n "$token" ]]; then args+=(-H "Authorization: ${token}") fi if [[ "$method" != "GET" ]]; then args+=(-d "$body") fi curl "${args[@]}" } assert_success() { local resp="$1" local step="$2" local code code="$(json_code "$resp")" if [[ "$code" != "200" ]]; then fail "${step} 失败, code=${code}, msg=$(json_msg "$resp"), resp=${resp}" fi } token_or_keep() { local resp="$1" local fallback="$2" local token token="$(jq -r '.data.token // ""' <<<"$resp")" if [[ -n "$token" && "$token" != "null" ]]; then printf "%s" "$token" else printf "%s" "$fallback" fi } device_login() { local identifier="$1" local user_agent="$2" local payload resp token payload="$(jq -nc --arg identifier "$identifier" --arg user_agent "$user_agent" '{identifier:$identifier,user_agent:$user_agent}')" resp="$(api_request "POST" "/v1/auth/login/device" "$payload")" assert_success "$resp" "设备登录(${identifier})" token="$(jq -r '.data.token // ""' <<<"$resp")" [[ -n "$token" && "$token" != "null" ]] || fail "设备登录(${identifier})返回空 token: ${resp}" printf "%s" "$token" } send_code_with_retry() { local email="$1" local typ="$2" local payload resp code retry payload="$(jq -nc --arg email "$email" --argjson type "$typ" '{email:$email,type:$type}')" retry=0 while true; do resp="$(api_request "POST" "/v1/common/send_code" "$payload")" code="$(json_code "$resp")" if [[ "$code" == "200" ]]; then printf "%s" "$resp" return 0 fi if [[ "$code" == "401" ]]; then retry=$((retry + 1)) if (( retry > SEND_CODE_MAX_RETRY )); then fail "发送验证码达到重试上限, email=${email}, type=${typ}, resp=${resp}" fi warn "验证码发送频率限制,等待 ${SEND_CODE_RETRY_WAIT}s 后重试(${retry}/${SEND_CODE_MAX_RETRY})..." sleep "${SEND_CODE_RETRY_WAIT}" continue fi fail "发送验证码失败, email=${email}, type=${typ}, code=${code}, msg=$(json_msg "$resp"), resp=${resp}" done } get_verify_code() { local email="$1" local typ="$2" local send_resp code send_resp="$(send_code_with_retry "$email" "$typ")" code="$(jq -r '.data.code // ""' <<<"$send_resp")" if [[ -n "$code" && "$code" != "null" ]]; then info "验证码已从接口返回(type=${typ})" printf "%s" "$code" return 0 fi code="$(get_verify_code_from_redis "$email" "$typ" || true)" if [[ -n "$code" ]]; then info "验证码已从 Redis 回读(type=${typ})" printf "%s" "$code" return 0 fi if [[ -n "$VERIFY_CODE_PROVIDER" ]]; then code="$("$VERIFY_CODE_PROVIDER" "$email" "$typ" || true)" if [[ -n "$code" ]]; then info "验证码由 VERIFY_CODE_PROVIDER 提供(type=${typ})" printf "%s" "$code" return 0 fi fi if [[ "$typ" == "1" && -n "$REGISTER_CODE_OVERRIDE" ]]; then printf "%s" "$REGISTER_CODE_OVERRIDE" return 0 fi if [[ "$typ" == "2" && -n "$SECURITY_CODE_OVERRIDE" ]]; then printf "%s" "$SECURITY_CODE_OVERRIDE" return 0 fi if [[ "$typ" == "4" && -n "$DELETE_ACCOUNT_CODE_OVERRIDE" ]]; then printf "%s" "$DELETE_ACCOUNT_CODE_OVERRIDE" return 0 fi if [[ -n "$VERIFY_CODE_OVERRIDE" ]]; then printf "%s" "$VERIFY_CODE_OVERRIDE" return 0 fi if ! can_prompt_input; then fail "验证码不可自动获取(type=${typ}, email=${email})" fi read -r -p "请输入邮箱 ${email} 的验证码(type=${typ}):" code [[ -n "$code" ]] || fail "验证码为空" printf "%s" "$code" } bind_email_with_verification() { local token="$1" local email="$2" local code="$3" local payload payload="$(jq -nc --arg email "$email" --arg code "$code" '{email:$email,code:$code}')" api_request "POST" "/v1/public/user/bind_email_with_verification" "$payload" "$token" } bind_email_with_verification_auto() { local token="$1" local email="$2" local typ="$3" local max_retry="${4:-$BIND_VERIFY_RETRY}" local attempt=0 code resp api_code while true; do code="$(get_verify_code "$email" "$typ")" resp="$(bind_email_with_verification "$token" "$email" "$code")" api_code="$(json_code "$resp")" if [[ "$api_code" == "200" ]]; then printf "%s" "$resp" return 0 fi if [[ "$api_code" == "70001" && "$attempt" -lt "$max_retry" ]]; then warn "绑定邮箱验证码校验失败,重新获取验证码重试($((attempt+1))/${max_retry})..." attempt=$((attempt + 1)) continue fi fail "绑定邮箱(${email}) 失败, code=${api_code}, msg=$(json_msg "$resp"), resp=${resp}" done } delete_account() { local token="$1" local email="$2" local code="$3" local payload resp payload="$(jq -nc --arg email "$email" --arg code "$code" '{email:$email,code:$code}')" resp="$(api_request "POST" "/v1/public/user/delete_account" "$payload" "$token")" assert_success "$resp" "注销账号(${email})" printf "%s" "$resp" } db_query_json() { local query="$1" GOCACHE=/tmp/go-build go run ./script/db_query.go --config "$CONFIG_PATH" --query "$query" } assert_db_field_equals() { local json="$1" local field="$2" local expected="$3" local actual actual="$(jq -r ".[0].${field} // \"\"" <<<"$json")" if [[ "$actual" != "$expected" ]]; then fail "DB校验失败: 字段 ${field} 期望=${expected}, 实际=${actual}, 数据=${json}" fi } main() { require_cmd curl require_cmd jq require_cmd go bold "家庭组注销清理校验脚本" info "BASE_URL=${BASE_URL}" info "CONFIG_PATH=${CONFIG_PATH}" info "EMAIL_A=${EMAIL_A}" info "DEVICE_A=${DEVICE_A}" info "DEVICE_B=${DEVICE_B}" local token_a token_b local bind_a_resp bind_b_resp del_b_resp del_a_resp local family_id owner_user_id member_user_id local code_delete local q_member_before q_member_after q_owner_after q_family_after bold "步骤1: A设备登录并绑定邮箱A" token_a="$(device_login "$DEVICE_A" "qa-cleanup-device-a")" bind_a_resp="$(bind_email_with_verification_auto "$token_a" "$EMAIL_A" 1)" token_a="$(token_or_keep "$bind_a_resp" "$token_a")" bold "步骤2: B设备登录并绑定邮箱A加入家庭" token_b="$(device_login "$DEVICE_B" "qa-cleanup-device-b")" bind_b_resp="$(bind_email_with_verification_auto "$token_b" "$EMAIL_A" 2)" token_b="$(token_or_keep "$bind_b_resp" "$token_b")" family_id="$(jq -r '.data.family_id // ""' <<<"$bind_b_resp")" owner_user_id="$(jq -r '.data.owner_user_id // ""' <<<"$bind_b_resp")" member_user_id="$(jq -r '.data.user_id // ""' <<<"$bind_b_resp")" [[ -n "$family_id" && "$family_id" != "null" ]] || fail "无法获取 family_id: ${bind_b_resp}" [[ -n "$owner_user_id" && "$owner_user_id" != "null" ]] || fail "无法获取 owner_user_id: ${bind_b_resp}" [[ -n "$member_user_id" && "$member_user_id" != "null" ]] || fail "无法获取 member_user_id: ${bind_b_resp}" q_member_before="$(db_query_json "SELECT status, role, family_id FROM user_family_member WHERE user_id = ${member_user_id} ORDER BY id DESC LIMIT 1")" assert_db_field_equals "$q_member_before" "status" "1" info "B注销前家庭状态校验通过(status=1)" bold "步骤3: 注销设备B账号,检查B离组" code_delete="$(get_verify_code "$EMAIL_A" 4)" del_b_resp="$(delete_account "$token_b" "$EMAIL_A" "$code_delete")" info "B注销结果: $(jq -c '.data' <<<"$del_b_resp")" q_member_after="$(db_query_json "SELECT status, role, family_id FROM user_family_member WHERE user_id = ${member_user_id} ORDER BY id DESC LIMIT 1")" assert_db_field_equals "$q_member_after" "status" "3" info "B注销后家庭状态校验通过(status=3)" bold "步骤4: 注销设备A(家庭主)账号,检查家庭解散" code_delete="$(get_verify_code "$EMAIL_A" 4)" del_a_resp="$(delete_account "$token_a" "$EMAIL_A" "$code_delete")" info "A注销结果: $(jq -c '.data' <<<"$del_a_resp")" q_owner_after="$(db_query_json "SELECT status, role, family_id FROM user_family_member WHERE user_id = ${owner_user_id} ORDER BY id DESC LIMIT 1")" assert_db_field_equals "$q_owner_after" "status" "3" info "A注销后主账号家庭状态校验通过(status=3)" q_family_after="$(db_query_json "SELECT status FROM user_family WHERE id = ${family_id} ORDER BY id DESC LIMIT 1")" assert_db_field_equals "$q_family_after" "status" "0" info "家庭状态校验通过(status=0)" bold "校验通过 ✅ 注销后家庭成员与家庭状态已正确更新" } main "$@"