#!/usr/bin/env bash # 为内部狗粮预置 10 个创作者与 30 个玩家真实密码账号。 # 本脚本不修改 Nacos、不创建 new-api 资源;临时 provisioning 配置由操作者在运行前设置、运行后恢复。 set -euo pipefail readonly EXPECTED_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' readonly CODE_USERNAME_EXISTS='1002090004' readonly CODE_REGISTER_DISABLED='1002090007' readonly CODE_AD_DOGFOOD_DISABLED='1111002006' readonly CODE_TRADE_DOGFOOD_DISABLED='1106008000' BASE="${BASE:-http://100.64.0.7:48080}" TENANT="${TENANT:-1}" DOGFOOD_ACCOUNTS_FILE="${DOGFOOD_ACCOUNTS_FILE:-/root/game-staging/dogfood-accounts.tsv}" MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}" MYSQL_PORT="${MYSQL_PORT:-13306}" MYSQL_DATABASE="${MYSQL_DATABASE:-ruoyi-vue-pro}" MYSQL_USER="${MYSQL_USER:-root}" MYSQL_BIN="${MYSQL_BIN:-mysql}" MYSQL_SSH_HOST="${MYSQL_SSH_HOST-__AUTO__}" MYSQL_ENV_FILE="${MYSQL_ENV_FILE:-/root/huijing-dev.env}" VERIFY_TIMEOUT_SEC="${VERIFY_TIMEOUT_SEC:-60}" ADMIN_TOKEN_FILE="${ADMIN_TOKEN_FILE:-}" ADMIN_USERNAME="${ADMIN_USERNAME:-}" ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" log() { printf '%s\n' "$*"; } die() { printf 'PROVISION_FAIL %s\n' "$*" >&2; exit 1; } require_env() { local name for name in "$@"; do [ -n "${!name:-}" ] || die "缺少必填环境变量:$name" done } require_env PROVISION_ACK DOGFOOD_PASSWORD_SEED [ "$PROVISION_ACK" = "$EXPECTED_ACK" ] || die "PROVISION_ACK 不匹配;确认临时配置后必须精确设为 $EXPECTED_ACK" if [ -n "$ADMIN_TOKEN_FILE" ]; then [ -z "$ADMIN_USERNAME" ] && [ -z "$ADMIN_PASSWORD" ] || \ die "ADMIN_TOKEN_FILE 不能与 ADMIN_USERNAME/ADMIN_PASSWORD 混用" else require_env ADMIN_USERNAME ADMIN_PASSWORD fi # 只允许调用 mini-desktop 本机或 Tailscale 地址,防止误向其它环境批量建号。 BASE_HOST="$(python3 - "$BASE" <<'PY' import sys from urllib.parse import urlparse u=urlparse(sys.argv[1]) if u.scheme not in ("http","https") or not u.hostname or u.username or u.password or u.query or u.fragment: raise SystemExit(2) print(u.hostname) PY )" || die "BASE 必须是合法 http(s) 地址" case "$BASE_HOST" in 127.0.0.1|100.64.0.7) ;; *) die "BASE host 只允许 127.0.0.1 或 100.64.0.7,actual=$BASE_HOST" ;; esac # 从开发机访问 mini-desktop 时默认在远端执行只读查询;显式传空值可覆盖自动选择。 if [ "$MYSQL_SSH_HOST" = '__AUTO__' ]; then if [ "$BASE_HOST" = '100.64.0.7' ]; then MYSQL_SSH_HOST='root@100.64.0.7' else MYSQL_SSH_HOST='' fi fi command -v curl >/dev/null || die "缺少 curl" command -v python3 >/dev/null || die "缺少 python3" read_private_token() { python3 - "$1" <<'PY' import os,stat,sys path=sys.argv[1] flags=os.O_RDONLY|getattr(os,"O_NOFOLLOW",0) try: fd=os.open(path,flags) except OSError as error: raise SystemExit(f"无法安全打开 ADMIN_TOKEN_FILE:{error}") with os.fdopen(fd,"r",encoding="utf-8") as handle: metadata=os.fstat(handle.fileno()) if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600: raise SystemExit("ADMIN_TOKEN_FILE 必须是 0600 普通文件") token=handle.read().strip() if not token or any(character.isspace() for character in token): raise SystemExit("ADMIN_TOKEN_FILE 内容为空或包含空白字符") print(token,end="") PY } umask 077 BODY_FILE="$(mktemp)" TMP_ACCOUNTS="$(mktemp)" cleanup() { rm -f "$BODY_FILE" "$TMP_ACCOUNTS"; } trap cleanup EXIT LAST_HTTP='' LAST_BODY='' request() { local token="$1" method="$2" path="$3" data="${4:-}" local -a args=(-sS --connect-timeout 5 --max-time 30 -o "$BODY_FILE" -w '%{http_code}' -X "$method" -H "tenant-id: $TENANT") [ -z "$token" ] || args+=(-H "Authorization: Bearer $token") [ -z "$data" ] || args+=(-H 'Content-Type: application/json' --data "$data") LAST_HTTP="$(curl "${args[@]}" "$BASE$path" 2>/dev/null || printf '000')" LAST_BODY="$(<"$BODY_FILE")" } json_value() { local path="$1" printf '%s' "$LAST_BODY" | python3 -c ' import json,sys try: value=json.load(sys.stdin) for part in sys.argv[1].split("."): value=value.get(part) if isinstance(value,dict) else None if value is None: break print("" if value is None else value) except Exception: print("") ' "$path" } json_body() { DOGFOOD_JSON_USERNAME="$1" DOGFOOD_JSON_PASSWORD="$2" DOGFOOD_JSON_NICKNAME="${3:-}" \ python3 - <<'PY' import json,os body={"username":os.environ["DOGFOOD_JSON_USERNAME"],"password":os.environ["DOGFOOD_JSON_PASSWORD"]} if os.environ.get("DOGFOOD_JSON_NICKNAME"): body["nickname"]=os.environ["DOGFOOD_JSON_NICKNAME"] print(json.dumps(body,separators=(",",":"),ensure_ascii=False)) PY } derive_password() { DOGFOOD_DERIVE_USERNAME="$1" python3 - <<'PY' import hashlib,hmac,os seed=os.environ["DOGFOOD_PASSWORD_SEED"].encode() username=os.environ["DOGFOOD_DERIVE_USERNAME"].encode() # 28 位十六进制字符串满足后端 6-32 位密码约束;同 seed+username 可确定性重跑。 print(hmac.new(seed,username,hashlib.sha256).hexdigest()[:28]) PY } # MySQL 查询只返回聚合计数。密码只经 MYSQL_PWD 传给客户端,不出现在命令行与日志。 mysql_query() { local sql="$1" if [ -n "$MYSQL_SSH_HOST" ]; then local sql_base64 sql_base64="$(printf '%s' "$sql" | base64 | tr -d '\n')" ssh -o ProxyCommand=none "$MYSQL_SSH_HOST" bash -s -- \ "$MYSQL_ENV_FILE" "$MYSQL_HOST" "$MYSQL_PORT" "$MYSQL_DATABASE" "$MYSQL_USER" "$sql_base64" <<'REMOTE' set -euo pipefail env_file="$1"; host="$2"; port="$3"; database="$4"; user="$5"; sql_base64="$6" [ -f "$env_file" ] || { printf '远端 MySQL 环境文件不存在\n' >&2; exit 2; } set -a; . "$env_file"; set +a : "${MYSQL_ROOT_PASSWORD:?远端环境文件缺 MYSQL_ROOT_PASSWORD}" printf '%s' "$sql_base64" | base64 -d | MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql --batch --skip-column-names \ -h "$host" -P "$port" -u "$user" "$database" REMOTE return fi if [ -z "${MYSQL_PASSWORD:-}" ]; then [ -f "$MYSQL_ENV_FILE" ] || die "本地查询需 MYSQL_PASSWORD 或 MYSQL_ENV_FILE=$MYSQL_ENV_FILE" set -a # shellcheck disable=SC1090 . "$MYSQL_ENV_FILE" set +a MYSQL_PASSWORD="${MYSQL_ROOT_PASSWORD:-}" fi [ -n "${MYSQL_PASSWORD:-}" ] || die "无法取得 MySQL 只读查询密码" printf '%s' "$sql" | MYSQL_PWD="$MYSQL_PASSWORD" "$MYSQL_BIN" --batch --skip-column-names \ -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" "$MYSQL_DATABASE" } TARGET_NAMES=() for i in $(seq 1 10); do printf -v suffix '%02d' "$i" TARGET_NAMES+=("dogfoodc$suffix") done for i in $(seq 1 30); do printf -v suffix '%02d' "$i" TARGET_NAMES+=("dogfoodp$suffix") done TARGET_SQL="$(printf "'%s'," "${TARGET_NAMES[@]}")" TARGET_SQL="${TARGET_SQL%,}" count_sql() { cat <1 THEN p.id WHEN p.username LIKE 'dogfoodp%' AND COALESCE(p.creator_flag,0)<>0 THEN p.id END), COUNT(DISTINCT CASE WHEN q.claim_count=1 THEN p.id END), COUNT(DISTINCT CASE WHEN q.claim_count>1 THEN p.id END), (SELECT COUNT(*) FROM newapi_quota_pool WHERE status='FREE' AND deleted=b'0') FROM game_player p LEFT JOIN ( SELECT claimed_by_game_player_id, COUNT(*) AS claim_count FROM newapi_quota_pool WHERE status='CLAIMED' AND deleted=b'0' GROUP BY claimed_by_game_player_id ) q ON q.claimed_by_game_player_id=p.id WHERE p.username IN ($TARGET_SQL) AND p.deleted=b'0'; SQL } read_counts() { local raw raw="$(mysql_query "$(count_sql)")" || die "MySQL 只读计数查询失败" read -r ACCOUNT_COUNT CREATOR_COUNT ROLE_MISMATCH_COUNT CLAIMED_ACCOUNT_COUNT \ DUPLICATE_CLAIM_COUNT FREE_COUNT <<<"$raw" [[ "${ACCOUNT_COUNT:-}" =~ ^[0-9]+$ && "${CREATOR_COUNT:-}" =~ ^[0-9]+$ \ && "${ROLE_MISMATCH_COUNT:-}" =~ ^[0-9]+$ && "${CLAIMED_ACCOUNT_COUNT:-}" =~ ^[0-9]+$ \ && "${DUPLICATE_CLAIM_COUNT:-}" =~ ^[0-9]+$ && "${FREE_COUNT:-}" =~ ^[0-9]+$ ]] \ || die "MySQL 计数结果格式非法" } # 只查询本次 40 个目标账号中尚无 CLAIMED 记录的 game_player.id;补偿范围以此只读结果为起点。 missing_claim_sql() { cat <"$TMP_ACCOUNTS" TARGET_USER_IDS=() FUND_GUARD_VERIFIED=0 for index in "${!TARGET_NAMES[@]}"; do username="${TARGET_NAMES[$index]}" if [ "$index" -lt 10 ]; then role='creator'; else role='player'; fi password="$(derive_password "$username")" body="$(json_body "$username" "$password" "$username")" request '' POST /app-api/passport/password-register "$body" code="$(json_value code)" if [ "$LAST_HTTP" = '429' ] || [ "$code" = '429' ]; then die "账号 $username 注册触发 429;请确认临时注册 IP 时/日上限均为 60" elif [ "$LAST_HTTP" != '200' ]; then die "账号 $username 注册请求失败(http=$LAST_HTTP)" elif [ "$code" = "$CODE_REGISTER_DISABLED" ]; then die "password-register 仍关闭;请启用临时 provisioning 配置" elif [ "$code" != '0' ] && [ "$code" != "$CODE_USERNAME_EXISTS" ]; then die "账号 $username 注册失败(code=${code:-none});若为 429,请确认临时注册 IP 时/日上限均为 60" fi # 新建与已存在统一使用派生密码登录;已存在但密码不匹配必须失败,不能冒充幂等。 request '' POST /app-api/passport/password-login "$body" code="$(json_value code)" if [ "$LAST_HTTP" = '429' ] || [ "$code" = '429' ]; then die "账号 $username 派生密码登录触发 429;请确认临时登录 IP 时/日上限均为 60" elif [ "$LAST_HTTP" != '200' ] || [ "$code" != '0' ]; then die "账号 $username 派生密码登录验证失败(http=$LAST_HTTP code=${code:-none});若为 429,请确认临时登录 IP 时/日上限均为 60" fi user_id="$(json_value data.userId)" [[ "$user_id" =~ ^[0-9]+$ ]] || die "账号 $username 登录响应缺合法 userId" is_target_user_id "$user_id" && die "多个目标账号返回同一 userId=$user_id,拒绝继续" TARGET_USER_IDS+=("$user_id") # mock token 已关闭;只用首个真实登录账号的 token 验证一次资金后端守卫,且不落日志或证据。 if [ "$FUND_GUARD_VERIFIED" -eq 0 ]; then PLAYER_TOKEN="$(json_value data.accessToken)" [ -n "$PLAYER_TOKEN" ] || die "账号 $username 登录响应缺 accessToken,无法验证资金守卫" request "$PLAYER_TOKEN" GET /app-api/trade/charge/points-balance [ "$LAST_HTTP" = '200' ] && [ "$(json_value code)" = "$CODE_TRADE_DOGFOOD_DISABLED" ] \ || die "dogfood 充值守卫未保持关闭(http=$LAST_HTTP code=$(json_value code))" request "$PLAYER_TOKEN" GET '/app-api/trade/withdraw/page?pageNo=1&pageSize=1' [ "$LAST_HTTP" = '200' ] && [ "$(json_value code)" = "$CODE_TRADE_DOGFOOD_DISABLED" ] \ || die "dogfood 提现守卫未保持关闭(http=$LAST_HTTP code=$(json_value code))" unset PLAYER_TOKEN FUND_GUARD_VERIFIED=1 log "已用真实玩家身份验证充值/提现狗粮守卫" fi if [ "$role" = 'creator' ]; then request "$ADMIN_TOKEN" PUT /admin-api/passport/player/set-creator \ "{\"userId\":$user_id,\"creatorFlag\":1}" [ "$LAST_HTTP" = '200' ] && [ "$(json_value code)" = '0' ] || die "账号 $username creatorFlag 置位失败" fi printf '%s\t%s\t%s\t%s\n' "$username" "$role" "$user_id" "$password" >>"$TMP_ACCOUNTS" log "已验证账号 $username role=$role userId=$user_id" done mkdir -p "$(dirname "$DOGFOOD_ACCOUNTS_FILE")" install -m 600 "$TMP_ACCOUNTS" "$DOGFOOD_ACCOUNTS_FILE" # 注册消息可能已被 ACK、进入 DLQ 或尚在重试;终验前只补偿这 40 人中数据库确认缺失的目标。 read_missing_claim_user_ids for user_id in "${MISSING_CLAIM_USER_IDS[@]}"; do request "$ADMIN_TOKEN" POST "/admin-api/aigc/quota/claims/$user_id/reconcile" code="$(json_value code)" claimed="$(json_value data)" if [ "$LAST_HTTP" != '200' ] || [ "$code" != '0' ] || { [ "$claimed" != 'True' ] && [ "$claimed" != 'true' ]; }; then die "userId=$user_id 额度 claim 补偿失败(http=$LAST_HTTP code=${code:-none} claimed=${claimed:-none})" fi log "已幂等补偿额度 claim userId=$user_id" done deadline=$((SECONDS + VERIFY_TIMEOUT_SEC)) while :; do read_counts if [ "$ACCOUNT_COUNT" -eq 40 ] && [ "$CREATOR_COUNT" -eq 10 ] \ && [ "$ROLE_MISMATCH_COUNT" -eq 0 ] && [ "$CLAIMED_ACCOUNT_COUNT" -eq 40 ] \ && [ "$DUPLICATE_CLAIM_COUNT" -eq 0 ] && [ "$FREE_COUNT" -ge 12 ]; then break fi [ "$SECONDS" -lt "$deadline" ] || die "终验未收敛:accounts=$ACCOUNT_COUNT creators=$CREATOR_COUNT role_mismatches=$ROLE_MISMATCH_COUNT claimed_accounts=$CLAIMED_ACCOUNT_COUNT duplicate_claims=$DUPLICATE_CLAIM_COUNT FREE=${FREE_COUNT};额度池不足请先运行受控 new-api import 补建 neice_051-054" sleep 2 done log "PROVISION_PASS accounts=40 creators=10 role_mismatches=0 claimed_accounts=40 duplicate_claims=0 FREE=$FREE_COUNT" log "账号凭据已写入 ${DOGFOOD_ACCOUNTS_FILE}(mode=600);密码、token、new-api key 未输出。"