games-development-ai/deploy/provision-dogfood-users.sh

634 lines
30 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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.7actual=$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 <<SQL
SELECT COUNT(DISTINCT p.id),
COUNT(DISTINCT CASE WHEN p.username IN ('dogfoodc01','dogfoodc02','dogfoodc03','dogfoodc04','dogfoodc05','dogfoodc06','dogfoodc07','dogfoodc08','dogfoodc09','dogfoodc10') AND p.creator_flag=1 THEN p.id END),
COUNT(DISTINCT CASE
WHEN p.username LIKE 'dogfoodc%' AND COALESCE(p.creator_flag,0)<>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 <<SQL
SELECT p.id
FROM game_player p
LEFT JOIN newapi_quota_pool q
ON q.claimed_by_game_player_id=p.id AND q.status='CLAIMED' AND q.deleted=b'0'
WHERE p.username IN ($TARGET_SQL) AND p.deleted=b'0' AND q.id IS NULL
ORDER BY p.id;
SQL
}
# 登录返回的 40 个 userId 是本轮允许补偿的白名单;数据库返回值必须再次落在该集合内。
is_target_user_id() {
local expected="$1" candidate
for candidate in "${TARGET_USER_IDS[@]}"; do
[ "$candidate" = "$expected" ] && return 0
done
return 1
}
read_missing_claim_user_ids() {
local raw user_id candidate
raw="$(mysql_query "$(missing_claim_sql)")" || die "MySQL 缺失 claim 查询失败"
MISSING_CLAIM_USER_IDS=()
while IFS= read -r user_id; do
[ -n "$user_id" ] || continue
[[ "$user_id" =~ ^[0-9]+$ ]] || die "MySQL 缺失 claim 查询返回非法 userId$user_id"
is_target_user_id "$user_id" || die "缺失 claim 的 userId=$user_id 不属于本次 40 个目标账号,拒绝越界补偿"
for candidate in "${MISSING_CLAIM_USER_IDS[@]}"; do
[ "$candidate" != "$user_id" ] || die "MySQL 缺失 claim 查询返回重复 userId$user_id"
done
MISSING_CLAIM_USER_IDS+=("$user_id")
done <<<"$raw"
}
log "预置目标10 creator + 30 playerBASE host=$BASE_HOST runId=$DOGFOOD_PROVISION_RUN_ID"
log "运行前置dogfood=true临时 password-register/provisioning=true注册与登录 IP 时/日上限均为 60结束后恢复配置。"
# 临时开窗只能放行密码注册;广告和资金入口必须继续由 dogfood 后端守卫拒绝。
request '' GET /app-api/ad/slot/list-enabled
[ "$LAST_HTTP" = '200' ] && [ "$(json_value code)" = "$CODE_AD_DOGFOOD_DISABLED" ] \
|| die "dogfood 广告守卫未保持关闭http=$LAST_HTTP code=$(json_value code)"
read_counts
MISSING_COUNT=$((40 - ACCOUNT_COUNT))
[ "$MISSING_COUNT" -ge 0 ] || die "目标账号计数异常:$ACCOUNT_COUNT"
[ "$DUPLICATE_CLAIM_COUNT" -eq 0 ] || \
die "额度池存在重复 claimduplicate_claims=${DUPLICATE_CLAIM_COUNT},需先修复数据后再预置"
UNCLAIMED_COUNT=$((40 - CLAIMED_ACCOUNT_COUNT))
[ "$UNCLAIMED_COUNT" -ge 0 ] || die "目标账号 claim 计数异常:$CLAIMED_ACCOUNT_COUNT"
REQUIRED_FREE=$((UNCLAIMED_COUNT + 12))
if [ "$FREE_COUNT" -lt "$REQUIRED_FREE" ]; then
die "额度池 FREE=${FREE_COUNT},不足以让剩余账号各 claim 一条并保留 12claimed_accounts=${CLAIMED_ACCOUNT_COUNT} required_free=${REQUIRED_FREE});请先运行受控 new-api import 补建 neice_051-054本脚本不会创建 new-api 资源"
fi
# admin token 仅驻留内存,不输出、不写 accounts 文件。
if [ -n "$ADMIN_TOKEN_FILE" ]; then
ADMIN_TOKEN="$(read_private_token "$ADMIN_TOKEN_FILE")" || die "ADMIN_TOKEN_FILE 安全校验失败"
else
ADMIN_BODY="$(json_body "$ADMIN_USERNAME" "$ADMIN_PASSWORD")"
request '' POST /admin-api/system/auth/login "$ADMIN_BODY"
[ "$LAST_HTTP" = '200' ] && [ "$(json_value code)" = '0' ] || die "admin 登录失败"
ADMIN_TOKEN="$(json_value data.accessToken)"
[ -n "$ADMIN_TOKEN" ] || die "admin 登录响应缺 accessToken"
fi
printf 'username\trole\tuser_id\tpassword\n' >"$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 未输出。"