#!/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' MODE='provision' if [ "${1:-}" = '--cleanup' ]; then MODE='cleanup' CLEANUP_MANIFEST="${2:-}" CLEANUP_RUN_ID="${3:-}" elif [ -n "${1:-}" ]; then printf 'PROVISION_FAIL 未知参数:%s\n' "$1" >&2 exit 2 fi 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:-}" DOGFOOD_PROVISION_RUN_ID="${DOGFOOD_PROVISION_RUN_ID:-}" DOGFOOD_PROVISION_CAPABILITY="${DOGFOOD_PROVISION_CAPABILITY:-}" DOGFOOD_PROVISION_CLIENT_IP="${DOGFOOD_PROVISION_CLIENT_IP:-127.0.0.1}" DOGFOOD_CLEANUP_MANIFEST_FILE="${DOGFOOD_CLEANUP_MANIFEST_FILE:-${DOGFOOD_ACCOUNTS_FILE}.runtime-cleanup.tsv}" REDIS_HOST="${REDIS_HOST:-127.0.0.1}" REDIS_PORT="${REDIS_PORT:-16379}" REDIS_DATABASE="${REDIS_DATABASE:-0}" REDIS_CLI_BIN="${REDIS_CLI_BIN:-redis-cli}" 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 } # SQL 始终经 stdin 送入客户端;密码只放 MYSQL_PWD,不出现在命令行与日志。 mysql_query() { local sql="$1" if [ -n "$MYSQL_SSH_HOST" ] && [ "$MYSQL_SSH_HOST" != '__AUTO__' ]; then # SQL(可能包含短生命周期 bearer)只经 stdin 传输,绝不进入 ssh 进程参数。 printf '%s' "$sql" | ssh -o ProxyCommand=none "$MYSQL_SSH_HOST" bash -c ' set -euo pipefail env_file="$1"; host="$2"; port="$3"; database="$4"; user="$5" [ -f "$env_file" ] || { printf "远端 MySQL 环境文件不存在\n" >&2; exit 2; } set -a; . "$env_file"; set +a : "${MYSQL_ROOT_PASSWORD:?远端环境文件缺 MYSQL_ROOT_PASSWORD}" MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql --batch --skip-column-names \ -h "$host" -P "$port" -u "$user" "$database" ' -- "$MYSQL_ENV_FILE" "$MYSQL_HOST" "$MYSQL_PORT" "$MYSQL_DATABASE" "$MYSQL_USER" 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" } cleanup_runtime_manifest() { local manifest="$1" expected_run_id="$2" refs_file sql_file db_result_file redis_scan_file keys_file local key cleanup_trap deleted=0 oauth_count=0 revoked_count=0 [ -f "$manifest" ] && [ ! -L "$manifest" ] || die '运行态清理清单必须是非 symlink 普通文件' [ "$(stat -c '%a' "$manifest" 2>/dev/null || stat -f '%Lp' "$manifest")" = 600 ] || \ die '运行态清理清单权限必须为 0600' [[ "$expected_run_id" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || die '清理 run-id 非法' [[ "$REDIS_PORT" =~ ^[0-9]+$ && "$REDIS_DATABASE" =~ ^[0-9]+$ ]] || die 'Redis 端口或 database 非法' require_env REDIS_PASSWORD command -v "$REDIS_CLI_BIN" >/dev/null || die "缺少 Redis CLI:$REDIS_CLI_BIN" refs_file="$(mktemp)" sql_file="$(mktemp)" db_result_file="$(mktemp)" redis_scan_file="$(mktemp)" keys_file="$(mktemp)" printf -v cleanup_trap 'rm -f -- %q %q %q %q %q' \ "$refs_file" "$sql_file" "$db_result_file" "$redis_scan_file" "$keys_file" trap "$cleanup_trap" EXIT python3 - "$manifest" "$expected_run_id" >"$refs_file" <<'PY' import os,re,stat,sys path,expected_run_id=sys.argv[1:] metadata=os.stat(path,follow_symlinks=False) if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600: raise SystemExit("运行态清理清单必须是 0600 普通文件") schema=None run_id=None keys=[] oauth=[] with open(path,encoding="utf-8") as handle: for raw in handle: raw=raw.rstrip("\n") if not raw: continue kind,separator,value=raw.partition(" ") if not separator: raise SystemExit("运行态清理清单行格式非法") if kind == "schema": schema=value elif kind == "run_id": run_id=value elif kind == "key": keys.append(value) elif kind == "oauth": parts=value.split() if len(parts) != 4: raise SystemExit("OAuth 清理引用格式非法") access_id,refresh_id,access_hash,refresh_hash=parts if not access_id.isdigit() or not refresh_id.isdigit() or int(access_id) <= 0 or int(refresh_id) <= 0: raise SystemExit("OAuth 清理主键非法") if not re.fullmatch(r"[0-9a-f]{64}",access_hash) or not re.fullmatch(r"[0-9a-f]{64}",refresh_hash): raise SystemExit("OAuth 清理摘要非法") oauth.append((access_id,refresh_id,access_hash,refresh_hash)) else: raise SystemExit("运行态清理清单字段非法") if schema != "dogfood-provision-cleanup/2" or run_id != expected_run_id: raise SystemExit("运行态清理清单 schema 或 run-id 不匹配") rate=re.compile( r"passport_ip:(?:register|login):(?:hour:[0-9A-Fa-f:.]+:[0-9]{10}|day:[0-9A-Fa-f:.]+:[0-9]{8})" ) unique=[] for key in keys: if not rate.fullmatch(key): raise SystemExit("运行态清理 key 越界") if key not in unique: unique.append(key) if len(set(oauth)) != len(oauth): raise SystemExit("OAuth 清理引用重复") for key in unique: print("key",key,sep="\t") for row in oauth: print("oauth",*row,sep="\t") PY oauth_count="$(awk -F '\t' '$1=="oauth" {count++} END {print count+0}' "$refs_file")" if [ "$oauth_count" -gt 0 ]; then python3 - "$refs_file" >"$sql_file" <<'PY' import sys rows=[] with open(sys.argv[1],encoding="utf-8") as handle: for raw in handle: parts=raw.rstrip("\n").split("\t") if parts[0] == "oauth": rows.append(parts[1:]) values=",".join( f"({access_id},{refresh_id},'{access_hash}','{refresh_hash}')" for access_id,refresh_id,access_hash,refresh_hash in rows ) print("START TRANSACTION;") print("CREATE TEMPORARY TABLE dogfood_oauth_revoke_refs (access_id BIGINT PRIMARY KEY, refresh_id BIGINT UNIQUE, access_hash CHAR(64), refresh_hash CHAR(64));") print(f"INSERT INTO dogfood_oauth_revoke_refs VALUES {values};") print(f"SET @dogfood_target_count={len(rows)};") print("SELECT CONCAT('TARGET\\t',@dogfood_target_count);") print("SELECT CONCAT('PAIR\\t',x.access_id,'\\t',x.refresh_id,'\\t',IF(a.id IS NULL,0,1),'\\t',IF(a.id IS NOT NULL AND x.access_hash=SHA2(a.access_token,256) AND x.refresh_hash=SHA2(a.refresh_token,256),1,0),'\\t',IF(r.id IS NULL,0,1),'\\t',IF(r.id IS NOT NULL AND x.refresh_hash=SHA2(r.refresh_token,256),1,0)) FROM dogfood_oauth_revoke_refs x LEFT JOIN system_oauth2_access_token a ON a.id=x.access_id LEFT JOIN system_oauth2_refresh_token r ON r.id=x.refresh_id ORDER BY x.access_id FOR UPDATE;") print("SELECT COUNT(*) INTO @dogfood_valid_pairs FROM dogfood_oauth_revoke_refs x LEFT JOIN system_oauth2_access_token a ON a.id=x.access_id LEFT JOIN system_oauth2_refresh_token r ON r.id=x.refresh_id WHERE (a.id IS NULL AND r.id IS NULL) OR (a.id IS NOT NULL AND r.id IS NOT NULL AND x.access_hash=SHA2(a.access_token,256) AND x.refresh_hash=SHA2(a.refresh_token,256) AND x.refresh_hash=SHA2(r.refresh_token,256)) FOR UPDATE;") print("SELECT CONCAT('VALID_PAIRS\\t',@dogfood_valid_pairs);") print(f"DELETE a FROM system_oauth2_access_token a JOIN dogfood_oauth_revoke_refs x ON x.access_id=a.id AND x.access_hash=SHA2(a.access_token,256) AND x.refresh_hash=SHA2(a.refresh_token,256) WHERE @dogfood_valid_pairs={len(rows)};") print("SET @dogfood_access_revoked=ROW_COUNT();") print(f"DELETE r FROM system_oauth2_refresh_token r JOIN dogfood_oauth_revoke_refs x ON x.refresh_id=r.id AND x.refresh_hash=SHA2(r.refresh_token,256) WHERE @dogfood_valid_pairs={len(rows)};") print("SET @dogfood_refresh_revoked=ROW_COUNT();") print("SELECT CONCAT('REVOKED\\t',@dogfood_access_revoked,'\\t',@dogfood_refresh_revoked);") print("SELECT CONCAT('REMAINING\\t',(SELECT COUNT(*) FROM system_oauth2_access_token a JOIN dogfood_oauth_revoke_refs x ON x.access_id=a.id),'\\t',(SELECT COUNT(*) FROM system_oauth2_refresh_token r JOIN dogfood_oauth_revoke_refs x ON x.refresh_id=r.id));") print("COMMIT;") PY mysql_query "$(<"$sql_file")" >"$db_result_file" || die 'OAuth MySQL 撤销事务失败' python3 - "$db_result_file" "$refs_file" "$oauth_count" >"$keys_file" <<'PY' import sys path,refs_path,expected=sys.argv[1],sys.argv[2],int(sys.argv[3]) expected_pairs=set() with open(refs_path,encoding="utf-8") as handle: for raw in handle: parts=raw.rstrip("\n").split("\t") if parts[0] == "oauth": expected_pairs.add((int(parts[1]),int(parts[2]))) target=[] valid_pairs=[] revoked=[] remaining=[] pairs={} with open(path,encoding="utf-8") as handle: for raw in handle: parts=raw.rstrip("\n").split("\t") if parts == [""]: continue kind=parts[0] if kind == "TARGET" and len(parts) == 2: target.append(parts[1:]) elif kind == "VALID_PAIRS" and len(parts) == 2: valid_pairs.append(parts[1:]) elif kind == "REVOKED" and len(parts) == 3: revoked.append(parts[1:]) elif kind == "REMAINING" and len(parts) == 3: remaining.append(parts[1:]) elif kind == "PAIR" and len(parts) == 7: if not all(part.isdigit() for part in parts[1:]): raise SystemExit("OAuth MySQL PAIR 结果格式非法") identity=(int(parts[1]),int(parts[2])) if identity in pairs: raise SystemExit("OAuth MySQL PAIR 结果重复") pairs[identity]=tuple(map(int,parts[3:])) else: raise SystemExit("OAuth MySQL 撤销结果包含未知字段") scalar_groups=(target,valid_pairs,revoked,remaining) if any(len(group) != 1 for group in scalar_groups) or any( not all(value.isdigit() for value in group[0]) for group in scalar_groups): raise SystemExit("OAuth MySQL 撤销结果格式非法") target_count=int(target[0][0]) valid_count=int(valid_pairs[0][0]) revoked_access,revoked_refresh=map(int,revoked[0]) remaining_access,remaining_refresh=map(int,remaining[0]) if target_count != expected or expected_pairs != set(pairs) or len(expected_pairs) != expected: raise SystemExit("OAuth MySQL 撤销目标不匹配") active_count=0 for identity,state in pairs.items(): if state == (1,1,1,1): active_count += 1 elif state != (0,0,0,0): raise SystemExit(f"OAuth MySQL 撤销状态非法:access_id={identity[0]} refresh_id={identity[1]}") if valid_count != expected: raise SystemExit("OAuth MySQL 撤销状态非法:valid_pairs 与目标数不一致") if (revoked_access,revoked_refresh) != (active_count,active_count): raise SystemExit("OAuth MySQL 撤销状态非法:删除数与存在对数不一致") if (remaining_access,remaining_refresh) != (0,0): raise SystemExit("OAuth MySQL 撤销状态非法:目标主键仍有残留") PY revoked_count="$oauth_count" fi # OAuth access token 的 Redis key 可由不可逆摘要在受限命名空间内重新定位,支持 DB 已撤销后的幂等重试。 REDISCLI_AUTH="$REDIS_PASSWORD" "$REDIS_CLI_BIN" -h "$REDIS_HOST" -p "$REDIS_PORT" \ -n "$REDIS_DATABASE" --scan --pattern 'oauth2_access_token:*' >"$redis_scan_file" || \ die 'OAuth Redis 缓存扫描失败' python3 - "$refs_file" "$redis_scan_file" >"$keys_file" <<'PY' import hashlib,re,sys refs_path,scan_path=sys.argv[1:] hashes=set() with open(refs_path,encoding="utf-8") as handle: for raw in handle: parts=raw.rstrip("\n").split("\t") if parts[0] == "oauth": hashes.update((parts[3],parts[4])) matched={digest:set() for digest in hashes} pattern=re.compile(r"oauth2_access_token:([A-Za-z0-9_-]{16,256})") with open(scan_path,encoding="utf-8") as handle: for raw in handle: key=raw.rstrip("\n") found=pattern.fullmatch(key) if not found: raise SystemExit("Redis 扫描返回越界 key") digest=hashlib.sha256(found.group(1).encode()).hexdigest() if digest in hashes: matched[digest].add(key) if any(len(keys) > 1 for keys in matched.values()): raise SystemExit("OAuth 摘要命中多个 Redis key") for keys in matched.values(): if keys: print(next(iter(keys))) PY awk -F '\t' '$1=="key" {print $2}' "$refs_file" >>"$keys_file" while IFS= read -r key; do [ -n "$key" ] || continue # key 经 stdin 传入,避免 OAuth token 出现在进程参数或日志。 printf '%s' "$key" | REDISCLI_AUTH="$REDIS_PASSWORD" "$REDIS_CLI_BIN" \ -h "$REDIS_HOST" -p "$REDIS_PORT" -n "$REDIS_DATABASE" -x DEL >/dev/null || \ die '定向 Redis 清理失败' deleted=$((deleted + 1)) done <"$keys_file" rm -f -- "$manifest" "$refs_file" "$sql_file" "$db_result_file" "$redis_scan_file" "$keys_file" trap - EXIT printf 'DOGFOOD_RUNTIME_CLEANUP_PASS run_id=%s oauth=%s keys=%s\n' \ "$expected_run_id" "$revoked_count" "$deleted" } if [ "$MODE" = cleanup ]; then umask 077 cleanup_runtime_manifest "$CLEANUP_MANIFEST" "$CLEANUP_RUN_ID" exit 0 fi require_env PROVISION_ACK DOGFOOD_PASSWORD_SEED DOGFOOD_PROVISION_RUN_ID DOGFOOD_PROVISION_CAPABILITY [ "$PROVISION_ACK" = "$EXPECTED_ACK" ] || die "PROVISION_ACK 不匹配;确认临时配置后必须精确设为 $EXPECTED_ACK" [[ "$DOGFOOD_PROVISION_RUN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || die 'DOGFOOD_PROVISION_RUN_ID 非法' [[ "$DOGFOOD_PROVISION_CAPABILITY" =~ ^[0-9a-f]{64}$ ]] || die 'DOGFOOD_PROVISION_CAPABILITY 必须是 256 bit 小写十六进制' [[ "$DOGFOOD_PROVISION_CLIENT_IP" =~ ^[0-9A-Fa-f:.]+$ ]] || die 'DOGFOOD_PROVISION_CLIENT_IP 非法' 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)" PROVISION_HEADER_FILE="$(mktemp)" { printf 'header = "X-Dogfood-Provisioning-Run-Id: %s"\n' "$DOGFOOD_PROVISION_RUN_ID" printf 'header = "X-Dogfood-Provisioning-Capability: %s"\n' "$DOGFOOD_PROVISION_CAPABILITY" } >"$PROVISION_HEADER_FILE" chmod 600 "$PROVISION_HEADER_FILE" cleanup() { rm -f "$BODY_FILE" "$TMP_ACCOUNTS" "$PROVISION_HEADER_FILE"; } 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") [ "$path" != '/app-api/passport/password-register' ] || args+=(--config "$PROVISION_HEADER_FILE") [ -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")" } mkdir -p "$(dirname "$DOGFOOD_CLEANUP_MANIFEST_FILE")" { printf 'schema dogfood-provision-cleanup/2\n' printf 'run_id %s\n' "$DOGFOOD_PROVISION_RUN_ID" } >"$DOGFOOD_CLEANUP_MANIFEST_FILE" chmod 600 "$DOGFOOD_CLEANUP_MANIFEST_FILE" record_rate_keys() { local scene="$1" printf 'key passport_ip:%s:hour:%s:%s\n' "$scene" "$DOGFOOD_PROVISION_CLIENT_IP" "$(date +%Y%m%d%H)" \ >>"$DOGFOOD_CLEANUP_MANIFEST_FILE" printf 'key passport_ip:%s:day:%s:%s\n' "$scene" "$DOGFOOD_PROVISION_CLIENT_IP" "$(date +%Y%m%d)" \ >>"$DOGFOOD_CLEANUP_MANIFEST_FILE" } record_oauth_tokens() { local access_token="$1" refresh_token="$2" row if [ -z "$access_token" ] && [ -z "$refresh_token" ]; then return fi [ "${#access_token}" -ge 16 ] && [ "${#access_token}" -le 256 ] \ && [[ "$access_token" =~ ^[A-Za-z0-9_-]+$ ]] \ && [ "${#refresh_token}" -ge 16 ] && [ "${#refresh_token}" -le 256 ] \ && [[ "$refresh_token" =~ ^[A-Za-z0-9_-]+$ ]] || \ die '后端返回的 OAuth token 对格式非法,拒绝生成撤销引用' row="$(mysql_query "SELECT a.id, r.id, SHA2(a.access_token,256), SHA2(r.refresh_token,256) FROM system_oauth2_access_token a JOIN system_oauth2_refresh_token r ON r.refresh_token=a.refresh_token WHERE a.access_token='$access_token' AND r.refresh_token='$refresh_token';")" || \ die '无法从 MySQL 解析 OAuth token 主键' [[ "$row" =~ ^[0-9]+$'\t'[0-9]+$'\t'[0-9a-f]{64}$'\t'[0-9a-f]{64}$ ]] || \ die 'OAuth token 主键或摘要查询结果非法' grep -Fqx "oauth ${row//$'\t'/ }" "$DOGFOOD_CLEANUP_MANIFEST_FILE" 2>/dev/null || \ printf 'oauth %s\n' "${row//$'\t'/ }" >>"$DOGFOOD_CLEANUP_MANIFEST_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 } 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")" record_rate_keys register 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 [ "$code" != '0' ] || record_oauth_tokens "$(json_value data.accessToken)" "$(json_value data.refreshToken)" # 新建与已存在统一使用派生密码登录;已存在但密码不匹配必须失败,不能冒充幂等。 record_rate_keys login 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)" record_oauth_tokens "$(json_value data.accessToken)" "$(json_value data.refreshToken)" [[ "$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 run_id=$DOGFOOD_PROVISION_RUN_ID" log "账号凭据已写入 ${DOGFOOD_ACCOUNTS_FILE}(mode=600);密码、token、new-api key 未输出。"