#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" 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:-}" mkdir -p "$ARTIFACT_DIR" info() { printf 'INFO: %s\n' "$*"; } ok() { printf 'OK: %s\n' "$*"; } warn() { printf 'WARN: %s\n' "$*" >&2; } die() { printf 'FATAL: %s\n' "$*" >&2; exit 1; } usage() { cat <<'EOF' usage: verify_user_key_self_service.sh [--help|--env-check|--run] Modes: --help 显示帮助 --env-check 仅检查 CRM / chat 入口与认证前置 --run 执行真实 user-key 闭环:create -> list -> get -> reset -> chat 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 Authentication for /api/keys endpoints (choose one): USER_SUBJECT_ID 通过 X-Portal-Subject 头注入 subject(联合部署/受信入口) USER_AUTH_TOKEN 通过 Authorization: Bearer 走用户链路 Artifacts: artifacts/user-key-self-service// - 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() { local crm_health="unreachable" if crm_health=$(curl -sS --noproxy '*' "${CRM_BASE%/}/healthz" 2>/dev/null); then : else crm_health="unreachable" fi 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 chat_health="unreachable" fi fi 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(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" <