634 lines
30 KiB
Bash
Executable File
634 lines
30 KiB
Bash
Executable File
#!/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 <<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 player;BASE 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 "额度池存在重复 claim:duplicate_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 一条并保留 12(claimed_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 未输出。"
|