#!/usr/bin/env bash set -euo pipefail BASE_URL="${BASE_URL:-http://127.0.0.1:8080}" EMAIL_A="${EMAIL_A:-family34_a_$(date +%s)@example.com}" DEVICE_A="${DEVICE_A:-qa34-device-a-$(date +%s)}" DEVICE_B="${DEVICE_B:-qa34-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:-}" 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" ;; *) 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" } 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 [[ -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 } get_devices() { local token="$1" local resp resp="$(api_request "GET" "/v1/public/user/devices" "" "$token")" assert_success "$resp" "获取设备列表" printf "%s" "$resp" } find_device_id() { local devices_resp="$1" local identifier="$2" jq -r --arg identifier "$identifier" '.data.list[]? | select(.identifier == $identifier) | .id' <<<"$devices_resp" | head -n1 } assert_device_exists() { local devices_resp="$1" local identifier="$2" jq -e --arg identifier "$identifier" '.data.list | any(.identifier == $identifier)' <<<"$devices_resp" >/dev/null \ || fail "设备列表中未找到 identifier=${identifier}, resp=${devices_resp}" } unbind_device() { local token="$1" local device_id="$2" local payload resp payload="$(jq -nc --arg id "$device_id" '{id:($id|tonumber)}')" resp="$(api_request "PUT" "/v1/public/user/unbind_device" "$payload" "$token")" assert_success "$resp" "解绑设备(id=${device_id})" } assert_token_invalid() { local token="$1" local who="$2" local resp code resp="$(api_request "GET" "/v1/public/user/info" "" "$token")" code="$(json_code "$resp")" if [[ "$code" == "200" ]]; then fail "${who} 预期已失效,但 token 仍可访问: ${resp}" fi info "${who} token 已失效,符合预期(code=${code}, msg=$(json_msg "$resp"))" } main() { require_cmd curl require_cmd jq bold "家庭组快速回归: 场景3/4" info "BASE_URL=${BASE_URL}" info "EMAIL_A=${EMAIL_A}" info "DEVICE_A=${DEVICE_A}" info "DEVICE_B=${DEVICE_B}" local token_a token_b token_a2 local bind_resp devices_resp local device_id_a device_id_b bold "预置: A设备登录并绑定邮箱A" token_a="$(device_login "$DEVICE_A" "qa34-device-a")" bind_resp="$(bind_email_with_verification_auto "$token_a" "$EMAIL_A" 1)" token_a="$(token_or_keep "$bind_resp" "$token_a")" bold "场景3: B绑定邮箱A后踢A" token_b="$(device_login "$DEVICE_B" "qa34-device-b")" bind_resp="$(bind_email_with_verification_auto "$token_b" "$EMAIL_A" 2)" token_b="$(token_or_keep "$bind_resp" "$token_b")" devices_resp="$(get_devices "$token_b")" assert_device_exists "$devices_resp" "$DEVICE_A" assert_device_exists "$devices_resp" "$DEVICE_B" device_id_a="$(find_device_id "$devices_resp" "$DEVICE_A")" [[ -n "$device_id_a" ]] || fail "场景3 无法找到设备A ID" unbind_device "$token_b" "$device_id_a" assert_token_invalid "$token_a" "设备A(被B踢后)" info "场景3通过" bold "场景4: A重登绑定邮箱A后踢B" token_a2="$(device_login "$DEVICE_A" "qa34-device-a-relogin")" bind_resp="$(bind_email_with_verification_auto "$token_a2" "$EMAIL_A" 2)" token_a2="$(token_or_keep "$bind_resp" "$token_a2")" devices_resp="$(get_devices "$token_a2")" assert_device_exists "$devices_resp" "$DEVICE_A" assert_device_exists "$devices_resp" "$DEVICE_B" device_id_b="$(find_device_id "$devices_resp" "$DEVICE_B")" [[ -n "$device_id_b" ]] || fail "场景4 无法找到设备B ID" unbind_device "$token_a2" "$device_id_b" assert_token_invalid "$token_b" "设备B(被A踢后)" info "场景4通过" bold "场景3/4 快速回归完成 ✅" } main "$@"