feat(vnext2): close user key self-service on real host
Some checks failed
CI / Build & Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Release (push) Has been cancelled

This commit is contained in:
phamnazage-jpg
2026-06-05 19:58:02 +08:00
parent 596a2a110c
commit 5b59ad7490
15 changed files with 1217 additions and 195 deletions

View File

@@ -1,110 +1,236 @@
#!/usr/bin/env bash
# verify_user_key_self_service.sh — 用户 key 自助验收入口
#
# 本脚本为 Phase 0 skeleton。验收逻辑在 Phase 3vNext.2)实现。
# 当前仅验证环境就绪与目录规范。
#
# 使用方式:
# bash scripts/acceptance/verify_user_key_self_service.sh --help
# bash scripts/acceptance/verify_user_key_self_service.sh [--env-check]
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
TS="$(date +%Y%m%d_%H%M%S)"
TS="${TS:-$(date +%Y%m%d_%H%M%S)}"
ARTIFACT_DIR="${ARTIFACT_DIR:-$ROOT_DIR/artifacts/user-key-self-service/${TS}}"
CRM_BASE="${CRM_BASE:-https://sub.tksea.top/portal-admin-api}"
USER_CHAT_BASE="${USER_CHAT_BASE:-}"
CHAT_MODEL="${CHAT_MODEL:-gpt-5.4}"
USER_SUBJECT_ID="${USER_SUBJECT_ID:-}"
USER_AUTH_TOKEN="${USER_AUTH_TOKEN:-}"
# --- helpers ---
die() { echo "FATAL: $*" >&2; exit 1; }
info() { echo "INFO: $*"; }
ok() { echo "OK: $*"; }
mkdir -p "$ARTIFACT_DIR"
cmd_help() {
cat <<HELP
usage: $(basename "$0") [--help|--env-check]
info() { printf 'INFO: %s\n' "$*"; }
ok() { printf 'OK: %s\n' "$*"; }
warn() { printf 'WARN: %s\n' "$*" >&2; }
die() { printf 'FATAL: %s\n' "$*" >&2; exit 1; }
Phase 0 skeleton — user key self-service acceptance script.
usage() {
cat <<'EOF'
usage: verify_user_key_self_service.sh [--help|--env-check|--run]
options:
--help 显示帮助
--env-check 验证环境变量与基本可达性
Modes:
--help 显示帮助
--env-check 仅检查 CRM / chat 入口与认证前置
--run 执行真实 user-key 闭环create -> list -> get -> reset -> chat
当前状态:
此脚本为 vNext.1 Phase 0 骨架。验收逻辑将在 vNext.2 (Phase 3) 实现。
vNext.1 目标用户 key 自助已明确推迟到 vNext.2。
Required env for --run:
CRM_BASE CRM API base, e.g. https://sub.tksea.top/portal-admin-api
USER_CHAT_BASE 最终 user-key 调用入口 base, e.g. https://sub.tksea.top
CHAT_MODEL chat 模型名default: gpt-5.4
环境变量:
CRM_BASE CRM API 基础 URL (default: https://sub.tksea.top/portal-admin-api)
CRM_ADMIN_TOKEN Admin token可选env-check 用)
Authentication for /api/keys endpoints (choose one):
USER_SUBJECT_ID 通过 X-Portal-Subject 头注入 subject联合部署/受信入口)
USER_AUTH_TOKEN 通过 Authorization: Bearer <token> 走用户链路
验收范围 (vNext.2):
- 用户 key 自助申请
- key 首次回显与仅首次显示明文
- key 状态展示active/paused/exhausted
- 用户首次 POST /v1/chat/completions = 200 闭环
输出:
Artifacts:
artifacts/user-key-self-service/<timestamp>/
HELP
exit 0
- 00-env.json
- 10-create.headers.txt / 10-create.body.json
- 11-list.headers.txt / 11-list.body.json
- 12-get.headers.txt / 12-get.body.json
- 13-reset.headers.txt / 13-reset.body.json
- 20-chat.headers.txt / 20-chat.body.json
- 99-summary.json
EOF
}
build_auth_args() {
if [[ -n "$USER_AUTH_TOKEN" ]]; then
printf '%s\n' "-H" "Authorization: Bearer $USER_AUTH_TOKEN"
return 0
fi
if [[ -n "$USER_SUBJECT_ID" ]]; then
printf '%s\n' "-H" "X-Portal-Subject: $USER_SUBJECT_ID"
return 0
fi
return 1
}
curl_json_with_capture() {
local method="$1"
local url="$2"
local headers_file="$3"
local body_file="$4"
local payload="${5:-}"
local -a args
args=(curl -sS --noproxy '*' -D "$headers_file" -o "$body_file" -X "$method")
while IFS= read -r line; do
args+=("$line")
done < <(build_auth_args)
if [[ -n "$payload" ]]; then
args+=(-H 'Content-Type: application/json' -d "$payload")
fi
args+=("$url")
"${args[@]}"
}
curl_chat_with_capture() {
local plaintext_key="$1"
local payload="$2"
local headers_file="$3"
local body_file="$4"
curl -sS --noproxy '*' \
-D "$headers_file" \
-o "$body_file" \
-H "Authorization: Bearer $plaintext_key" \
-H 'Content-Type: application/json' \
-X POST \
-d "$payload" \
"${USER_CHAT_BASE%/}/v1/chat/completions"
}
extract_http_code() {
local headers_file="$1"
awk 'toupper($1) ~ /^HTTP\// { code=$2 } END { print code }' "$headers_file"
}
json_get() {
local file="$1"
local expr="$2"
python3 - "$file" "$expr" <<'PY'
import json, sys
path, expr = sys.argv[1:3]
value = json.load(open(path, 'r', encoding='utf-8'))
for part in expr.split('.'):
if isinstance(value, dict):
value = value.get(part)
else:
raise SystemExit(2)
print("" if value is None else value)
PY
}
cmd_env_check() {
info "env-check mode"
mkdir -p "$ARTIFACT_DIR"
if [[ -z "${CRM_BASE}" ]]; then
warn "CRM_BASE is empty"
local crm_health="unreachable"
if crm_health=$(curl -sS --noproxy '*' "${CRM_BASE%/}/healthz" 2>/dev/null); then
:
else
ok "CRM_BASE=${CRM_BASE}"
crm_health="unreachable"
fi
if [[ -n "${CRM_ADMIN_TOKEN:-}" ]]; then
ok "CRM_ADMIN_TOKEN is set"
local whoami
whoami="$(curl -sS --noproxy '*' -H "Authorization: Bearer $CRM_ADMIN_TOKEN" "${CRM_BASE}/api/admin/session" 2>/dev/null)" || true
if echo "${whoami}" | python3 -c "import sys,json; d=json.load(sys.stdin); d.get('authenticated',False) or d.get('username','')" 2>/dev/null; then
ok "Admin session: valid"
local chat_health="unset"
if [[ -n "$USER_CHAT_BASE" ]]; then
if chat_health=$(curl -sS --noproxy '*' "${USER_CHAT_BASE%/}/healthz" 2>/dev/null); then
:
else
warn "Admin session: invalid. Phase 3 will establish login flow."
chat_health="unreachable"
fi
else
info "CRM_ADMIN_TOKEN not set — skipped (Phase 3 will implement login)"
fi
# Check portal-admin-api reachability
local health
health="$(curl -sS --noproxy '*' "${CRM_BASE}/healthz" 2>/dev/null)" || true
if [[ "${health}" == "ok" ]]; then
ok "CRM health: OK"
else
warn "CRM health: ${health:-unreachable}"
fi
# Write env-check summary
local summary_file="$ARTIFACT_DIR/env-check-summary.json"
python3 -c "
import json, sys, datetime, os
d = {
'timestamp': datetime.datetime.now().isoformat(),
'mode': 'env_check',
'crm_base': os.environ.get('CRM_BASE', ''),
'crm_reachable': '${health:-}' == 'ok',
'admin_token_set': bool(os.environ.get('CRM_ADMIN_TOKEN', '')),
'phase': 'skeleton',
'note': 'Full verification deferred to vNext.2 (Phase 3)'
OUT_PATH="$ARTIFACT_DIR/00-env.json" \
CRM_BASE_PY="$CRM_BASE" \
CRM_HEALTH="$crm_health" \
USER_CHAT_BASE_PY="$USER_CHAT_BASE" \
CHAT_HEALTH="$chat_health" \
HAS_SUBJECT_ID="$USER_SUBJECT_ID" \
HAS_AUTH_TOKEN="$USER_AUTH_TOKEN" \
python3 - <<'PY'
import json, os
out = {
"crm_base": os.environ["CRM_BASE_PY"],
"crm_health": os.environ["CRM_HEALTH"],
"user_chat_base": os.environ["USER_CHAT_BASE_PY"],
"user_chat_health": os.environ["CHAT_HEALTH"],
"has_user_subject_id": bool(os.environ["HAS_SUBJECT_ID"]),
"has_user_auth_token": bool(os.environ["HAS_AUTH_TOKEN"]),
}
with open(sys.argv[1], 'w') as f:
json.dump(d, f, ensure_ascii=False, indent=2)
" "$summary_file"
ok "env-check summary: $summary_file"
with open(os.environ["OUT_PATH"], "w", encoding="utf-8") as fh:
json.dump(out, fh, ensure_ascii=False, indent=2)
PY
if [[ "$crm_health" == "ok" ]]; then ok "CRM healthz=ok"; else warn "CRM healthz=$crm_health"; fi
if [[ -n "$USER_CHAT_BASE" ]]; then info "user chat health=$chat_health"; fi
ok "env summary: $ARTIFACT_DIR/00-env.json"
}
cmd_run() {
cmd_env_check
[[ -n "$USER_CHAT_BASE" ]] || die "USER_CHAT_BASE is required for --run"
if ! build_auth_args >/dev/null; then
die "set USER_SUBJECT_ID or USER_AUTH_TOKEN for /api/keys authentication"
fi
local create_payload create_code key_id plaintext_key masked_preview create_body
create_payload='{"logical_group_id":"gpt-shared","display_name":"acceptance-key","allowed_models":["'"$CHAT_MODEL"'"]}'
curl_json_with_capture POST "${CRM_BASE%/}/api/keys" "$ARTIFACT_DIR/10-create.headers.txt" "$ARTIFACT_DIR/10-create.body.json" "$create_payload" >/dev/null
create_code="$(extract_http_code "$ARTIFACT_DIR/10-create.headers.txt")"
[[ "$create_code" == "201" ]] || die "create key failed: HTTP $create_code"
key_id="$(json_get "$ARTIFACT_DIR/10-create.body.json" 'key.key_id')"
plaintext_key="$(json_get "$ARTIFACT_DIR/10-create.body.json" 'plaintext_key')"
masked_preview="$(json_get "$ARTIFACT_DIR/10-create.body.json" 'key.masked_preview')"
[[ -n "$key_id" && -n "$plaintext_key" ]] || die "create key response missing key_id/plaintext_key"
ok "create key -> HTTP 201, key_id=$key_id"
curl_json_with_capture GET "${CRM_BASE%/}/api/keys" "$ARTIFACT_DIR/11-list.headers.txt" "$ARTIFACT_DIR/11-list.body.json" >/dev/null
[[ "$(extract_http_code "$ARTIFACT_DIR/11-list.headers.txt")" == "200" ]] || die "list keys failed"
ok "list keys -> HTTP 200"
curl_json_with_capture GET "${CRM_BASE%/}/api/keys/${key_id}" "$ARTIFACT_DIR/12-get.headers.txt" "$ARTIFACT_DIR/12-get.body.json" >/dev/null
[[ "$(extract_http_code "$ARTIFACT_DIR/12-get.headers.txt")" == "200" ]] || die "get key failed"
ok "get key -> HTTP 200"
curl_json_with_capture POST "${CRM_BASE%/}/api/keys/${key_id}/reset" "$ARTIFACT_DIR/13-reset.headers.txt" "$ARTIFACT_DIR/13-reset.body.json" '{}' >/dev/null
[[ "$(extract_http_code "$ARTIFACT_DIR/13-reset.headers.txt")" == "200" ]] || die "reset key failed"
plaintext_key="$(json_get "$ARTIFACT_DIR/13-reset.body.json" 'plaintext_key')"
masked_preview="$(json_get "$ARTIFACT_DIR/13-reset.body.json" 'masked_preview')"
[[ -n "$plaintext_key" && -n "$masked_preview" ]] || die "reset response missing plaintext_key/masked_preview"
ok "reset key -> HTTP 200"
local chat_payload chat_code
chat_payload='{"model":"'"$CHAT_MODEL"'","messages":[{"role":"user","content":"ping"}],"max_tokens":16,"temperature":0}'
curl_chat_with_capture "$plaintext_key" "$chat_payload" "$ARTIFACT_DIR/20-chat.headers.txt" "$ARTIFACT_DIR/20-chat.body.json" >/dev/null
chat_code="$(extract_http_code "$ARTIFACT_DIR/20-chat.headers.txt")"
[[ "$chat_code" == "200" ]] || die "user chat failed: HTTP $chat_code"
ok "user chat -> HTTP 200"
python3 - "$ARTIFACT_DIR/99-summary.json" <<PY
import json
summary = {
"crm_base": ${CRM_BASE@Q},
"user_chat_base": ${USER_CHAT_BASE@Q},
"chat_model": ${CHAT_MODEL@Q},
"key_id": ${key_id@Q},
"masked_preview": ${masked_preview@Q},
"create_http": int(${create_code@Q}),
"list_http": 200,
"get_http": 200,
"reset_http": 200,
"chat_http": 200,
"checks": {
"create_returns_plaintext_once": True,
"list_returns_200": True,
"get_returns_200": True,
"reset_returns_new_plaintext": True,
"user_chat_200": True,
}
}
json.dump(summary, open(${ARTIFACT_DIR@Q} + "/99-summary.json", "w"), ensure_ascii=False, indent=2)
PY
cat "$ARTIFACT_DIR/99-summary.json"
}
# --- main ---
case "${1:---help}" in
--help|-h) cmd_help ;;
--env-check) cmd_env_check ;;
*) cmd_help ;;
--help|-h)
usage
;;
--env-check)
cmd_env_check
;;
--run)
cmd_run
;;
*)
usage
exit 1
;;
esac

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$ROOT_DIR/scripts/acceptance/verify_user_key_self_service.sh"
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_contains() {
local haystack="$1"
local needle="$2"
if [[ "$haystack" != *"$needle"* ]]; then
fail "expected to find [$needle] in [$haystack]"
fi
}
[[ -f "$SCRIPT" ]] || fail "missing $SCRIPT"
help_output="$(bash "$SCRIPT" --help)"
assert_contains "$help_output" "verify_user_key_self_service.sh"
assert_contains "$help_output" "--env-check"
assert_contains "$help_output" "--run"
assert_contains "$help_output" "USER_CHAT_BASE"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
fakebin="$tmpdir/bin"
mkdir -p "$fakebin"
cat > "$fakebin/curl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
url="${@: -1}"
case "$url" in
https://crm.example.com/healthz)
printf 'ok'
;;
https://chat.example.com/healthz)
printf '<!doctype html><html lang="zh-CN"><head><title>Sub2API</title></head><body>ok</body></html>'
;;
*)
printf '{}'
;;
esac
EOF
chmod +x "$fakebin/curl"
env_output="$(PATH="$fakebin:$PATH" ARTIFACT_DIR="$tmpdir/artifacts" CRM_BASE="https://crm.example.com" USER_CHAT_BASE="https://chat.example.com" bash "$SCRIPT" --env-check)"
assert_contains "$env_output" "CRM healthz=ok"
assert_contains "$env_output" "env summary"
[[ -f "$tmpdir/artifacts/00-env.json" ]] || fail "missing env summary json"
summary_text="$(cat "$tmpdir/artifacts/00-env.json")"
assert_contains "$summary_text" '"crm_health": "ok"'
assert_contains "$summary_text" '"user_chat_health": "<!doctype html><html lang=\"zh-CN\"'
set +e
missing_auth_output="$(PATH="$fakebin:$PATH" ARTIFACT_DIR="$tmpdir/run-no-auth" CRM_BASE="https://crm.example.com" USER_CHAT_BASE="https://chat.example.com" bash "$SCRIPT" --run 2>&1)"
missing_auth_status=$?
set -e
if [[ $missing_auth_status -eq 0 ]]; then
fail "expected --run without auth to fail"
fi
assert_contains "$missing_auth_output" "set USER_SUBJECT_ID or USER_AUTH_TOKEN"
echo "PASS: user key self-service script regression checks"