diff --git a/Dockerfile b/Dockerfile index b4117e3a..8c0b9f8e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,35 @@ FROM golang:1.22.2 AS builder +ARG http_proxy= +ARG https_proxy= +ARG HTTP_PROXY= +ARG HTTPS_PROXY= +ARG no_proxy= +ARG NO_PROXY= +ENV http_proxy= \ + https_proxy= \ + HTTP_PROXY= \ + HTTPS_PROXY= \ + no_proxy= \ + NO_PROXY= WORKDIR /src COPY go.mod go.sum ./ -RUN go mod download +RUN GOPROXY=https://proxy.golang.org,direct go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/sub2api-cn-relay-manager ./cmd/server +RUN GOPROXY=https://proxy.golang.org,direct CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/sub2api-cn-relay-manager ./cmd/server FROM debian:bookworm-slim +ARG http_proxy= +ARG https_proxy= +ARG HTTP_PROXY= +ARG HTTPS_PROXY= +ARG no_proxy= +ARG NO_PROXY= +ENV http_proxy= \ + https_proxy= \ + HTTP_PROXY= \ + HTTPS_PROXY= \ + no_proxy= \ + NO_PROXY= RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates tzdata wget \ && rm -rf /var/lib/apt/lists/* diff --git a/Dockerfile.local b/Dockerfile.local new file mode 100644 index 00000000..9129bf5e --- /dev/null +++ b/Dockerfile.local @@ -0,0 +1,30 @@ +# Local-dev Dockerfile: uses prebuilt binary to avoid proxy issues in Docker. +# Usage: +# GOTOOLCHAIN=local CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ +# go build -trimpath -ldflags='-s -w' -o bin/sub2api-cn-relay-manager ./cmd/server +# docker build -f Dockerfile.local -t sub2api-cn-relay-manager:local . +FROM debian:bookworm-slim +ARG http_proxy= +ARG https_proxy= +ARG HTTP_PROXY= +ARG HTTPS_PROXY= +ARG no_proxy= +ARG NO_PROXY= +ENV http_proxy= \ + https_proxy= \ + HTTP_PROXY= \ + HTTPS_PROXY= \ + no_proxy= \ + NO_PROXY= +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tzdata wget \ + && rm -rf /var/lib/apt/lists/* \ + && update-ca-certificates +WORKDIR /app +COPY bin/sub2api-cn-relay-manager /usr/local/bin/sub2api-cn-relay-manager +ENV SUB2API_CRM_LISTEN_ADDR=:8080 +ENV SUB2API_CRM_SQLITE_DSN=file:/data/sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000 +ENV SUB2API_CRM_ADMIN_TOKEN= +VOLUME ["/data"] +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/sub2api-cn-relay-manager"] diff --git a/README.md b/README.md index c9cdc5d9..0974496a 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ sub2api-cn-relay-manager/ - [docs/TDD_PLAN.md](./docs/TDD_PLAN.md) - [docs/EXECUTION_BOARD.md](./docs/EXECUTION_BOARD.md) - [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md) +- [docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md](./docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md) ## 当前 MVP 能力 @@ -104,6 +105,27 @@ Docker Compose: ```bash cp .env.example .env # 编辑 .env 中的 SUB2API_CRM_ADMIN_TOKEN -docker compose up --build -d +scripts/build_local_image.sh +docker run --rm -p 8080:8080 \ + --env-file .env \ + -v "$(pwd)/data:/data" \ + sub2api-cn-relay-manager:local curl -fsS http://127.0.0.1:8080/healthz ``` + +真实宿主验收: + +```bash +CRM_BASE_URL=http://127.0.0.1:8080 \ +CRM_ADMIN_TOKEN='' \ +HOST_NAME=prod-sub2api \ +HOST_BASE_URL=https://sub2api.example.com \ +HOST_API_KEY='' \ +PACK_PATH=/app/packs/openai-cn-pack \ +PROVIDER_ID=deepseek \ +KEYS=sk-live-1 \ +ACCESS_MODE=self_service \ +ACCESS_API_KEY='' \ +DRY_RUN=1 \ +scripts/real_host_acceptance.sh +``` diff --git a/artifacts/real-host-acceptance/20260516_155350/01-create-host.json b/artifacts/real-host-acceptance/20260516_155350/01-create-host.json new file mode 100644 index 00000000..d7db0f06 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_155350/01-create-host.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:8080/api/hosts"} diff --git a/artifacts/real-host-acceptance/20260516_155350/02-probe-host.json b/artifacts/real-host-acceptance/20260516_155350/02-probe-host.json new file mode 100644 index 00000000..327730eb --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_155350/02-probe-host.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:8080/api/hosts/prod-sub2api/probe"} diff --git a/artifacts/real-host-acceptance/20260516_155350/03-install-pack.json b/artifacts/real-host-acceptance/20260516_155350/03-install-pack.json new file mode 100644 index 00000000..6a093887 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_155350/03-install-pack.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:8080/api/packs/install"} diff --git a/artifacts/real-host-acceptance/20260516_155350/04-preview-import.json b/artifacts/real-host-acceptance/20260516_155350/04-preview-import.json new file mode 100644 index 00000000..92b96f71 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_155350/04-preview-import.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:8080/api/providers/deepseek/preview-import"} diff --git a/artifacts/real-host-acceptance/20260516_155350/05-import.json b/artifacts/real-host-acceptance/20260516_155350/05-import.json new file mode 100644 index 00000000..b832e76e --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_155350/05-import.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:8080/api/providers/deepseek/import"} diff --git a/artifacts/real-host-acceptance/20260516_155350/06-access-preview.json b/artifacts/real-host-acceptance/20260516_155350/06-access-preview.json new file mode 100644 index 00000000..0448b622 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_155350/06-access-preview.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:8080/api/providers/deepseek/access/preview"} diff --git a/artifacts/real-host-acceptance/20260516_155350/07-access-status.json b/artifacts/real-host-acceptance/20260516_155350/07-access-status.json new file mode 100644 index 00000000..6d206e90 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_155350/07-access-status.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"GET","url":"http://127.0.0.1:8080/api/providers/deepseek/access/status"} diff --git a/artifacts/real-host-acceptance/20260516_155350/08-provider-status.json b/artifacts/real-host-acceptance/20260516_155350/08-provider-status.json new file mode 100644 index 00000000..dce98737 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_155350/08-provider-status.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"GET","url":"http://127.0.0.1:8080/api/providers/deepseek/status"} diff --git a/artifacts/real-host-acceptance/20260516_155350/09-reconcile.json b/artifacts/real-host-acceptance/20260516_155350/09-reconcile.json new file mode 100644 index 00000000..5b62610b --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_155350/09-reconcile.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:8080/api/providers/deepseek/reconcile"} diff --git a/artifacts/real-host-acceptance/20260516_170410/01-create-host.json b/artifacts/real-host-acceptance/20260516_170410/01-create-host.json new file mode 100644 index 00000000..14b99a51 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170410/01-create-host.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:18081/api/hosts"} diff --git a/artifacts/real-host-acceptance/20260516_170410/02-probe-host.json b/artifacts/real-host-acceptance/20260516_170410/02-probe-host.json new file mode 100644 index 00000000..4da93bb2 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170410/02-probe-host.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:18081/api/hosts/local-sub2api/probe"} diff --git a/artifacts/real-host-acceptance/20260516_170410/03-install-pack.json b/artifacts/real-host-acceptance/20260516_170410/03-install-pack.json new file mode 100644 index 00000000..af6333e6 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170410/03-install-pack.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:18081/api/packs/install"} diff --git a/artifacts/real-host-acceptance/20260516_170410/04-preview-import.json b/artifacts/real-host-acceptance/20260516_170410/04-preview-import.json new file mode 100644 index 00000000..82d56f8c --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170410/04-preview-import.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:18081/api/providers/deepseek/preview-import"} diff --git a/artifacts/real-host-acceptance/20260516_170410/05-import.json b/artifacts/real-host-acceptance/20260516_170410/05-import.json new file mode 100644 index 00000000..29782ec9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170410/05-import.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:18081/api/providers/deepseek/import"} diff --git a/artifacts/real-host-acceptance/20260516_170410/06-access-preview.json b/artifacts/real-host-acceptance/20260516_170410/06-access-preview.json new file mode 100644 index 00000000..5bf0e4e4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170410/06-access-preview.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:18081/api/providers/deepseek/access/preview"} diff --git a/artifacts/real-host-acceptance/20260516_170410/07-access-status.json b/artifacts/real-host-acceptance/20260516_170410/07-access-status.json new file mode 100644 index 00000000..1c177e32 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170410/07-access-status.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"GET","url":"http://127.0.0.1:18081/api/providers/deepseek/access/status"} diff --git a/artifacts/real-host-acceptance/20260516_170410/08-provider-status.json b/artifacts/real-host-acceptance/20260516_170410/08-provider-status.json new file mode 100644 index 00000000..f68aa287 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170410/08-provider-status.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"GET","url":"http://127.0.0.1:18081/api/providers/deepseek/status"} diff --git a/artifacts/real-host-acceptance/20260516_170410/09-reconcile.json b/artifacts/real-host-acceptance/20260516_170410/09-reconcile.json new file mode 100644 index 00000000..561edc52 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170410/09-reconcile.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:18081/api/providers/deepseek/reconcile"} diff --git a/artifacts/real-host-acceptance/20260516_170421/01-create-host.json b/artifacts/real-host-acceptance/20260516_170421/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170421/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260516_170421/02-probe-host.json b/artifacts/real-host-acceptance/20260516_170421/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170421/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260516_170421/03-install-pack.json b/artifacts/real-host-acceptance/20260516_170421/03-install-pack.json new file mode 100644 index 00000000..087100d4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170421/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260516_170607/01-create-host.json b/artifacts/real-host-acceptance/20260516_170607/01-create-host.json new file mode 100644 index 00000000..14b99a51 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170607/01-create-host.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:18081/api/hosts"} diff --git a/artifacts/real-host-acceptance/20260516_170607/02-probe-host.json b/artifacts/real-host-acceptance/20260516_170607/02-probe-host.json new file mode 100644 index 00000000..4da93bb2 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170607/02-probe-host.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:18081/api/hosts/local-sub2api/probe"} diff --git a/artifacts/real-host-acceptance/20260516_170607/03-install-pack.json b/artifacts/real-host-acceptance/20260516_170607/03-install-pack.json new file mode 100644 index 00000000..af6333e6 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170607/03-install-pack.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:18081/api/packs/install"} diff --git a/artifacts/real-host-acceptance/20260516_170607/04-preview-import.json b/artifacts/real-host-acceptance/20260516_170607/04-preview-import.json new file mode 100644 index 00000000..82d56f8c --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170607/04-preview-import.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:18081/api/providers/deepseek/preview-import"} diff --git a/artifacts/real-host-acceptance/20260516_170607/05-import.json b/artifacts/real-host-acceptance/20260516_170607/05-import.json new file mode 100644 index 00000000..29782ec9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170607/05-import.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:18081/api/providers/deepseek/import"} diff --git a/artifacts/real-host-acceptance/20260516_170607/06-access-preview.json b/artifacts/real-host-acceptance/20260516_170607/06-access-preview.json new file mode 100644 index 00000000..5bf0e4e4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170607/06-access-preview.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:18081/api/providers/deepseek/access/preview"} diff --git a/artifacts/real-host-acceptance/20260516_170607/07-access-status.json b/artifacts/real-host-acceptance/20260516_170607/07-access-status.json new file mode 100644 index 00000000..1c177e32 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170607/07-access-status.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"GET","url":"http://127.0.0.1:18081/api/providers/deepseek/access/status"} diff --git a/artifacts/real-host-acceptance/20260516_170607/08-provider-status.json b/artifacts/real-host-acceptance/20260516_170607/08-provider-status.json new file mode 100644 index 00000000..f68aa287 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170607/08-provider-status.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"GET","url":"http://127.0.0.1:18081/api/providers/deepseek/status"} diff --git a/artifacts/real-host-acceptance/20260516_170607/09-reconcile.json b/artifacts/real-host-acceptance/20260516_170607/09-reconcile.json new file mode 100644 index 00000000..561edc52 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170607/09-reconcile.json @@ -0,0 +1 @@ +{"dry_run":true,"method":"POST","url":"http://127.0.0.1:18081/api/providers/deepseek/reconcile"} diff --git a/artifacts/real-host-acceptance/20260516_170725/01-create-host.json b/artifacts/real-host-acceptance/20260516_170725/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170725/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260516_170725/02-probe-host.json b/artifacts/real-host-acceptance/20260516_170725/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170725/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260516_170725/03-install-pack.json b/artifacts/real-host-acceptance/20260516_170725/03-install-pack.json new file mode 100644 index 00000000..3cfa0535 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_170725/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":true,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260516_171131/01-create-host.json b/artifacts/real-host-acceptance/20260516_171131/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_171131/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260516_171131/02-probe-host.json b/artifacts/real-host-acceptance/20260516_171131/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_171131/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260516_171131/03-install-pack.json b/artifacts/real-host-acceptance/20260516_171131/03-install-pack.json new file mode 100644 index 00000000..3cfa0535 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_171131/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":true,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260516_171131/04-preview-import.json b/artifacts/real-host-acceptance/20260516_171131/04-preview-import.json new file mode 100644 index 00000000..b3560528 --- /dev/null +++ b/artifacts/real-host-acceptance/20260516_171131/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"crm-deepseek-channel","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"crm-deepseek-group","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"crm-deepseek-plan","ExistingID":"","Reason":""}},"names":{"Group":"crm-deepseek-group","Channel":"crm-deepseek-channel","Plan":"crm-deepseek-plan"}} diff --git a/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/01-create-host.json b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/02-probe-host.json b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/03-install-pack.json b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/03-install-pack.json new file mode 100644 index 00000000..2c86e58d --- /dev/null +++ b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"},{"display_name":"OpenAI 中转兼容","provider_id":"openai-zhongzhuan"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/04-preview-import.json b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/04-preview-import.json new file mode 100644 index 00000000..d904817b --- /dev/null +++ b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"OpenAI 中转默认渠道","ExistingID":"","Reason":""},"group":{"Action":"reuse","Suggested":"OpenAI 中转默认分组","ExistingID":"18","Reason":"matching managed resource already exists"},"plan":{"Action":"create","Suggested":"OpenAI 中转默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"OpenAI 中转默认分组","Channel":"OpenAI 中转默认渠道","Plan":"OpenAI 中转默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/05-import.json b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/05-import.json new file mode 100644 index 00000000..c8078903 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":1,"batch_status":"partially_succeeded","channel":{"id":"10","name":"OpenAI 中转默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"18","name":"OpenAI 中转默认分组"},"plan":null,"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/06-access-preview.json b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/06-access-preview.json new file mode 100644 index 00000000..84826377 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"openai-zhongzhuan","mode":"self_service","available":false,"message":"access status broken does not satisfy mode self_service"} diff --git a/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/07-access-status.json b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/07-access-status.json new file mode 100644 index 00000000..a43f9a29 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":1,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":1,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan"} diff --git a/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/08-provider-status.json b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/08-provider-status.json new file mode 100644 index 00000000..9c8674a8 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":3,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"OpenAI 中转兼容","platform":"openai","provider_id":"openai-zhongzhuan"},"provider_status":"partially_succeeded","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/09-reconcile.json b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/09-reconcile.json new file mode 100644 index 00000000..b66555c3 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":11,"missing_count":0,"provider_id":"openai-zhongzhuan","status":"drifted","summary":{"access_rechecked":true,"access_status":"broken","extra_count":11,"host_version":"0.1.126","missing_count":0,"probe_failures":0}} diff --git a/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/10-batch-detail.json b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/10-batch-detail.json new file mode 100644 index 00000000..6effdeef --- /dev/null +++ b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}"},{"ID":2,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"reconcile_rerun\":true,\"status_code\":403}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":2,"id":1,"mode":"partial","pack_id":1,"provider_id":3},"items":[{"account_status":"passed","batch_id":1,"id":1,"key_fingerprint":"sha256:fbd0fe64bde9bf5e4fbc1b648540139ae34473dbdd07905a72b1e90970bddce5","probe_summary_json":"{\"account_id\":\"20\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"reconcile_rerun\":true,\"smoke_model_seen\":true}"}],"items_count":1,"managed_count":3,"managed_resources":[{"ID":1,"BatchID":1,"ResourceType":"group","HostResourceID":"18","ResourceName":"OpenAI 中转默认分组"},{"ID":2,"BatchID":1,"ResourceType":"channel","HostResourceID":"10","ResourceName":"OpenAI 中转默认渠道"},{"ID":3,"BatchID":1,"ResourceType":"account","HostResourceID":"20","ResourceName":"openai-zhongzhuan-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"ProviderID":3,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":11,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":0}"}]} diff --git a/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/11-rollback.json b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/11-rollback.json new file mode 100644 index 00000000..280e36f9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517192934_openai_group_bound_closure/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":1,"deleted_accounts":1,"deleted_channels":1,"deleted_groups":1,"deleted_plans":0} diff --git a/artifacts/real-host-acceptance/20260517_000418/01-create-host.json b/artifacts/real-host-acceptance/20260517_000418/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_000418/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_000418/02-probe-host.json b/artifacts/real-host-acceptance/20260517_000418/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_000418/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_000418/03-install-pack.json b/artifacts/real-host-acceptance/20260517_000418/03-install-pack.json new file mode 100644 index 00000000..087100d4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_000418/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_000418/04-preview-import.json b/artifacts/real-host-acceptance/20260517_000418/04-preview-import.json new file mode 100644 index 00000000..f7f7f4ab --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_000418/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"DeepSeek 默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"DeepSeek 默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"DeepSeek 默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"DeepSeek 默认分组","Channel":"DeepSeek 默认渠道","Plan":"DeepSeek 默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_061947_postfix/01-create-host.json b/artifacts/real-host-acceptance/20260517_061947_postfix/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_061947_postfix/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_061947_postfix/02-probe-host.json b/artifacts/real-host-acceptance/20260517_061947_postfix/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_061947_postfix/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_061947_postfix/03-install-pack.json b/artifacts/real-host-acceptance/20260517_061947_postfix/03-install-pack.json new file mode 100644 index 00000000..3cfa0535 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_061947_postfix/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":true,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_061947_postfix/04-preview-import.json b/artifacts/real-host-acceptance/20260517_061947_postfix/04-preview-import.json new file mode 100644 index 00000000..f3c9f8eb --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_061947_postfix/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"DeepSeek 默认渠道","ExistingID":"","Reason":""},"group":{"Action":"reuse","Suggested":"DeepSeek 默认分组","ExistingID":"2","Reason":"matching managed resource already exists"},"plan":{"Action":"create","Suggested":"DeepSeek 默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"DeepSeek 默认分组","Channel":"DeepSeek 默认渠道","Plan":"DeepSeek 默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_062209_fixed/01-create-host.json b/artifacts/real-host-acceptance/20260517_062209_fixed/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_062209_fixed/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_062209_fixed/02-probe-host.json b/artifacts/real-host-acceptance/20260517_062209_fixed/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_062209_fixed/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_062209_fixed/03-install-pack.json b/artifacts/real-host-acceptance/20260517_062209_fixed/03-install-pack.json new file mode 100644 index 00000000..3cfa0535 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_062209_fixed/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":true,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_062209_fixed/04-preview-import.json b/artifacts/real-host-acceptance/20260517_062209_fixed/04-preview-import.json new file mode 100644 index 00000000..f3c9f8eb --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_062209_fixed/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"DeepSeek 默认渠道","ExistingID":"","Reason":""},"group":{"Action":"reuse","Suggested":"DeepSeek 默认分组","ExistingID":"2","Reason":"matching managed resource already exists"},"plan":{"Action":"create","Suggested":"DeepSeek 默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"DeepSeek 默认分组","Channel":"DeepSeek 默认渠道","Plan":"DeepSeek 默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_062209_fixed/05-import.json b/artifacts/real-host-acceptance/20260517_062209_fixed/05-import.json new file mode 100644 index 00000000..42da1a49 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_062209_fixed/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":0,"batch_id":4,"batch_status":"partially_succeeded","channel":{"id":"1","name":"DeepSeek 默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"2","name":"DeepSeek 默认分组"},"plan":null,"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260517_062209_fixed/06-access-preview.json b/artifacts/real-host-acceptance/20260517_062209_fixed/06-access-preview.json new file mode 100644 index 00000000..e9e0735b --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_062209_fixed/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"deepseek","mode":"self_service","available":false,"message":"access status broken does not satisfy mode self_service"} diff --git a/artifacts/real-host-acceptance/20260517_062209_fixed/07-access-status.json b/artifacts/real-host-acceptance/20260517_062209_fixed/07-access-status.json new file mode 100644 index 00000000..5a4d1f3f --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_062209_fixed/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":4,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":4,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"deepseek"} diff --git a/artifacts/real-host-acceptance/20260517_062209_fixed/08-provider-status.json b/artifacts/real-host-acceptance/20260517_062209_fixed/08-provider-status.json new file mode 100644 index 00000000..1952eafb --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_062209_fixed/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":4,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":2,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"DeepSeek OpenAI Compatible","platform":"openai","provider_id":"deepseek"},"provider_status":"partially_succeeded","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260517_062722_after_account_fix/01-create-host.json b/artifacts/real-host-acceptance/20260517_062722_after_account_fix/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_062722_after_account_fix/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_062722_after_account_fix/02-probe-host.json b/artifacts/real-host-acceptance/20260517_062722_after_account_fix/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_062722_after_account_fix/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_062722_after_account_fix/03-install-pack.json b/artifacts/real-host-acceptance/20260517_062722_after_account_fix/03-install-pack.json new file mode 100644 index 00000000..3cfa0535 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_062722_after_account_fix/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":true,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_062722_after_account_fix/04-preview-import.json b/artifacts/real-host-acceptance/20260517_062722_after_account_fix/04-preview-import.json new file mode 100644 index 00000000..1bd5ec55 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_062722_after_account_fix/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"reuse","Suggested":"DeepSeek 默认渠道","ExistingID":"1","Reason":"matching managed resource already exists"},"group":{"Action":"reuse","Suggested":"DeepSeek 默认分组","ExistingID":"2","Reason":"matching managed resource already exists"},"plan":{"Action":"create","Suggested":"DeepSeek 默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"DeepSeek 默认分组","Channel":"DeepSeek 默认渠道","Plan":"DeepSeek 默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_080506_post_fix/01-create-host.json b/artifacts/real-host-acceptance/20260517_080506_post_fix/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_080506_post_fix/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_080506_post_fix/02-probe-host.json b/artifacts/real-host-acceptance/20260517_080506_post_fix/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_080506_post_fix/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_080506_post_fix/03-install-pack.json b/artifacts/real-host-acceptance/20260517_080506_post_fix/03-install-pack.json new file mode 100644 index 00000000..087100d4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_080506_post_fix/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_080506_post_fix/04-preview-import.json b/artifacts/real-host-acceptance/20260517_080506_post_fix/04-preview-import.json new file mode 100644 index 00000000..1bd5ec55 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_080506_post_fix/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"reuse","Suggested":"DeepSeek 默认渠道","ExistingID":"1","Reason":"matching managed resource already exists"},"group":{"Action":"reuse","Suggested":"DeepSeek 默认分组","ExistingID":"2","Reason":"matching managed resource already exists"},"plan":{"Action":"create","Suggested":"DeepSeek 默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"DeepSeek 默认分组","Channel":"DeepSeek 默认渠道","Plan":"DeepSeek 默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_080506_post_fix/05-import.json b/artifacts/real-host-acceptance/20260517_080506_post_fix/05-import.json new file mode 100644 index 00000000..170c7725 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_080506_post_fix/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":0,"batch_id":1,"batch_status":"partially_succeeded","channel":{"id":"1","name":"DeepSeek 默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"2","name":"DeepSeek 默认分组"},"plan":null,"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260517_080506_post_fix/06-access-preview.json b/artifacts/real-host-acceptance/20260517_080506_post_fix/06-access-preview.json new file mode 100644 index 00000000..e9e0735b --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_080506_post_fix/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"deepseek","mode":"self_service","available":false,"message":"access status broken does not satisfy mode self_service"} diff --git a/artifacts/real-host-acceptance/20260517_080506_post_fix/07-access-status.json b/artifacts/real-host-acceptance/20260517_080506_post_fix/07-access-status.json new file mode 100644 index 00000000..8a693c61 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_080506_post_fix/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":1,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":1,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"deepseek"} diff --git a/artifacts/real-host-acceptance/20260517_080506_post_fix/08-provider-status.json b/artifacts/real-host-acceptance/20260517_080506_post_fix/08-provider-status.json new file mode 100644 index 00000000..da5bc4d4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_080506_post_fix/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":2,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"DeepSeek OpenAI Compatible","platform":"openai","provider_id":"deepseek"},"provider_status":"partially_succeeded","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260517_080506_post_fix/09-reconcile.json b/artifacts/real-host-acceptance/20260517_080506_post_fix/09-reconcile.json new file mode 100644 index 00000000..83b542d6 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_080506_post_fix/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":4,"missing_count":0,"provider_id":"deepseek","status":"drifted","summary":{"access_rechecked":true,"access_status":"broken","extra_count":4,"host_version":"0.1.126","missing_count":0,"probe_failures":0}} diff --git a/artifacts/real-host-acceptance/20260517_080506_post_fix/10-batch-detail.json b/artifacts/real-host-acceptance/20260517_080506_post_fix/10-batch-detail.json new file mode 100644 index 00000000..642bc423 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_080506_post_fix/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}"},{"ID":2,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"reconcile_rerun\":true,\"status_code\":403}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":2,"id":1,"mode":"partial","pack_id":1,"provider_id":1},"items":[],"items_count":0,"managed_count":2,"managed_resources":[{"ID":1,"BatchID":1,"ResourceType":"group","HostResourceID":"2","ResourceName":"DeepSeek 默认分组"},{"ID":2,"BatchID":1,"ResourceType":"channel","HostResourceID":"1","ResourceName":"DeepSeek 默认渠道"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"ProviderID":1,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":4,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":0}"}]} diff --git a/artifacts/real-host-acceptance/20260517_080506_post_fix/11-rollback.json b/artifacts/real-host-acceptance/20260517_080506_post_fix/11-rollback.json new file mode 100644 index 00000000..d55ffa72 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_080506_post_fix/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":1,"deleted_accounts":0,"deleted_channels":1,"deleted_groups":1,"deleted_plans":0} diff --git a/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/01-create-host.json b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/02-probe-host.json b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/03-install-pack.json b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/03-install-pack.json new file mode 100644 index 00000000..087100d4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/04-preview-import.json b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/04-preview-import.json new file mode 100644 index 00000000..f7f7f4ab --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"DeepSeek 默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"DeepSeek 默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"DeepSeek 默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"DeepSeek 默认分组","Channel":"DeepSeek 默认渠道","Plan":"DeepSeek 默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/05-import.json b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/05-import.json new file mode 100644 index 00000000..0d41dcba --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":1,"batch_status":"partially_succeeded","channel":{"id":"2","name":"DeepSeek 默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"3","name":"DeepSeek 默认分组"},"plan":null,"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/06-access-preview.json b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/06-access-preview.json new file mode 100644 index 00000000..e9e0735b --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"deepseek","mode":"self_service","available":false,"message":"access status broken does not satisfy mode self_service"} diff --git a/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/07-access-status.json b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/07-access-status.json new file mode 100644 index 00000000..8a693c61 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":1,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":1,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"deepseek"} diff --git a/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/08-provider-status.json b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/08-provider-status.json new file mode 100644 index 00000000..383cc78e --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":3,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"DeepSeek OpenAI Compatible","platform":"openai","provider_id":"deepseek"},"provider_status":"partially_succeeded","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/09-reconcile.json b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/09-reconcile.json new file mode 100644 index 00000000..f13c4ca0 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":5,"missing_count":0,"provider_id":"deepseek","status":"drifted","summary":{"access_rechecked":true,"access_status":"broken","extra_count":5,"host_version":"0.1.126","missing_count":0,"probe_failures":1}} diff --git a/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/10-batch-detail.json b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/10-batch-detail.json new file mode 100644 index 00000000..ebcedb25 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}"},{"ID":2,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"reconcile_rerun\":true,\"status_code\":403}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":2,"id":1,"mode":"partial","pack_id":1,"provider_id":1},"items":[{"account_status":"failed","batch_id":1,"id":1,"key_fingerprint":"sha256:555499e31601143d6c6e8dde8ee68266615cea97e4f3d1dbe9495bd070aaf8d6","probe_summary_json":"{\"account_id\":\"6\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":false,\"probe_status\":\"failed\",\"reconcile_rerun\":true,\"smoke_model_seen\":false}"}],"items_count":1,"managed_count":3,"managed_resources":[{"ID":1,"BatchID":1,"ResourceType":"group","HostResourceID":"3","ResourceName":"DeepSeek 默认分组"},{"ID":2,"BatchID":1,"ResourceType":"channel","HostResourceID":"2","ResourceName":"DeepSeek 默认渠道"},{"ID":3,"BatchID":1,"ResourceType":"account","HostResourceID":"6","ResourceName":"deepseek-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"ProviderID":1,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":5,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":1}"}]} diff --git a/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/11-rollback.json b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/11-rollback.json new file mode 100644 index 00000000..280e36f9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_081533_post_decode_fix/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":1,"deleted_accounts":1,"deleted_channels":1,"deleted_groups":1,"deleted_plans":0} diff --git a/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/01-create-host.json b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/02-probe-host.json b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/03-install-pack.json b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/03-install-pack.json new file mode 100644 index 00000000..087100d4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/04-preview-import.json b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/04-preview-import.json new file mode 100644 index 00000000..f7f7f4ab --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"DeepSeek 默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"DeepSeek 默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"DeepSeek 默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"DeepSeek 默认分组","Channel":"DeepSeek 默认渠道","Plan":"DeepSeek 默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/05-import.json b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/05-import.json new file mode 100644 index 00000000..8c7f27e0 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":1,"batch_status":"partially_succeeded","channel":{"id":"3","name":"DeepSeek 默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"4","name":"DeepSeek 默认分组"},"plan":null,"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/06-access-preview.json b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/06-access-preview.json new file mode 100644 index 00000000..e9e0735b --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"deepseek","mode":"self_service","available":false,"message":"access status broken does not satisfy mode self_service"} diff --git a/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/07-access-status.json b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/07-access-status.json new file mode 100644 index 00000000..8a693c61 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":1,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":1,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"deepseek"} diff --git a/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/08-provider-status.json b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/08-provider-status.json new file mode 100644 index 00000000..383cc78e --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":3,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"DeepSeek OpenAI Compatible","platform":"openai","provider_id":"deepseek"},"provider_status":"partially_succeeded","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/09-reconcile.json b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/09-reconcile.json new file mode 100644 index 00000000..f13c4ca0 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":5,"missing_count":0,"provider_id":"deepseek","status":"drifted","summary":{"access_rechecked":true,"access_status":"broken","extra_count":5,"host_version":"0.1.126","missing_count":0,"probe_failures":1}} diff --git a/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/10-batch-detail.json b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/10-batch-detail.json new file mode 100644 index 00000000..8cf1983e --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}"},{"ID":2,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"reconcile_rerun\":true,\"status_code\":403}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":2,"id":1,"mode":"partial","pack_id":1,"provider_id":1},"items":[{"account_status":"failed","batch_id":1,"id":1,"key_fingerprint":"sha256:555499e31601143d6c6e8dde8ee68266615cea97e4f3d1dbe9495bd070aaf8d6","probe_summary_json":"{\"account_id\":\"7\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":false,\"probe_status\":\"failed\",\"reconcile_rerun\":true,\"smoke_model_seen\":false}"}],"items_count":1,"managed_count":3,"managed_resources":[{"ID":1,"BatchID":1,"ResourceType":"group","HostResourceID":"4","ResourceName":"DeepSeek 默认分组"},{"ID":2,"BatchID":1,"ResourceType":"channel","HostResourceID":"3","ResourceName":"DeepSeek 默认渠道"},{"ID":3,"BatchID":1,"ResourceType":"account","HostResourceID":"7","ResourceName":"deepseek-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"ProviderID":1,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":5,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":1}"}]} diff --git a/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/11-rollback.json b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/11-rollback.json new file mode 100644 index 00000000..280e36f9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_082153_post_account_type_fix/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":1,"deleted_accounts":1,"deleted_channels":1,"deleted_groups":1,"deleted_plans":0} diff --git a/artifacts/real-host-acceptance/20260517_102113_subscription_probe/01-create-host.json b/artifacts/real-host-acceptance/20260517_102113_subscription_probe/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_102113_subscription_probe/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_102113_subscription_probe/02-probe-host.json b/artifacts/real-host-acceptance/20260517_102113_subscription_probe/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_102113_subscription_probe/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_102113_subscription_probe/03-install-pack.json b/artifacts/real-host-acceptance/20260517_102113_subscription_probe/03-install-pack.json new file mode 100644 index 00000000..087100d4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_102113_subscription_probe/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_102113_subscription_probe/04-preview-import.json b/artifacts/real-host-acceptance/20260517_102113_subscription_probe/04-preview-import.json new file mode 100644 index 00000000..f7f7f4ab --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_102113_subscription_probe/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"DeepSeek 默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"DeepSeek 默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"DeepSeek 默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"DeepSeek 默认分组","Channel":"DeepSeek 默认渠道","Plan":"DeepSeek 默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/01-create-host.json b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/02-probe-host.json b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/03-install-pack.json b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/03-install-pack.json new file mode 100644 index 00000000..087100d4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/04-preview-import.json b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/04-preview-import.json new file mode 100644 index 00000000..f7f7f4ab --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"DeepSeek 默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"DeepSeek 默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"DeepSeek 默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"DeepSeek 默认分组","Channel":"DeepSeek 默认渠道","Plan":"DeepSeek 默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/05-import.json b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/05-import.json new file mode 100644 index 00000000..0dd5d2a1 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":1,"batch_status":"partially_succeeded","channel":{"id":"5","name":"DeepSeek 默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"7","name":"DeepSeek 默认分组"},"plan":{"id":"2","name":"DeepSeek 默认套餐"},"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/06-access-preview.json b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/06-access-preview.json new file mode 100644 index 00000000..2630ae1a --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"deepseek","mode":"subscription","available":false,"message":"access status broken does not satisfy mode subscription"} diff --git a/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/07-access-status.json b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/07-access-status.json new file mode 100644 index 00000000..0eff2fe4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":1,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"subscription","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":1,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"deepseek"} diff --git a/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/08-provider-status.json b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/08-provider-status.json new file mode 100644 index 00000000..b31da14b --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":4,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"DeepSeek OpenAI Compatible","platform":"openai","provider_id":"deepseek"},"provider_status":"partially_succeeded","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/09-reconcile.json b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/09-reconcile.json new file mode 100644 index 00000000..e1965c4f --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":6,"missing_count":0,"provider_id":"deepseek","status":"drifted","summary":{"access_rechecked":true,"access_status":"broken","extra_count":6,"host_version":"0.1.126","missing_count":0,"probe_failures":1}} diff --git a/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/10-batch-detail.json b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/10-batch-detail.json new file mode 100644 index 00000000..8d629ddf --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"subscription","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}"},{"ID":2,"BatchID":1,"ClosureType":"subscription","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"reconcile_rerun\":true,\"status_code\":403}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":2,"id":1,"mode":"partial","pack_id":1,"provider_id":1},"items":[{"account_status":"failed","batch_id":1,"id":1,"key_fingerprint":"sha256:555499e31601143d6c6e8dde8ee68266615cea97e4f3d1dbe9495bd070aaf8d6","probe_summary_json":"{\"account_id\":\"10\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":false,\"probe_status\":\"failed\",\"reconcile_rerun\":true,\"smoke_model_seen\":false}"}],"items_count":1,"managed_count":4,"managed_resources":[{"ID":1,"BatchID":1,"ResourceType":"group","HostResourceID":"7","ResourceName":"DeepSeek 默认分组"},{"ID":2,"BatchID":1,"ResourceType":"channel","HostResourceID":"5","ResourceName":"DeepSeek 默认渠道"},{"ID":3,"BatchID":1,"ResourceType":"plan","HostResourceID":"2","ResourceName":"DeepSeek 默认套餐"},{"ID":4,"BatchID":1,"ResourceType":"account","HostResourceID":"10","ResourceName":"deepseek-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"ProviderID":1,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":6,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":1}"}]} diff --git a/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/11-rollback.json b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/11-rollback.json new file mode 100644 index 00000000..6258a080 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":1,"deleted_accounts":1,"deleted_channels":1,"deleted_groups":1,"deleted_plans":1} diff --git a/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/01-create-host.json b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/02-probe-host.json b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/03-install-pack.json b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/03-install-pack.json new file mode 100644 index 00000000..3cfa0535 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":true,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/04-preview-import.json b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/04-preview-import.json new file mode 100644 index 00000000..f7f7f4ab --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"DeepSeek 默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"DeepSeek 默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"DeepSeek 默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"DeepSeek 默认分组","Channel":"DeepSeek 默认渠道","Plan":"DeepSeek 默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/05-import.json b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/05-import.json new file mode 100644 index 00000000..f41af8f1 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":2,"batch_status":"partially_succeeded","channel":{"id":"6","name":"DeepSeek 默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"8","name":"DeepSeek 默认分组"},"plan":{"id":"3","name":"DeepSeek 默认套餐"},"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/06-access-preview.json b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/06-access-preview.json new file mode 100644 index 00000000..2630ae1a --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"deepseek","mode":"subscription","available":false,"message":"access status broken does not satisfy mode subscription"} diff --git a/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/07-access-status.json b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/07-access-status.json new file mode 100644 index 00000000..21787cd3 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":2,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"subscription","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":3,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"deepseek"} diff --git a/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/08-provider-status.json b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/08-provider-status.json new file mode 100644 index 00000000..14592fc0 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":2,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"drifted","latest_reconcile_summary":{"access_rechecked":true,"access_status":"broken","extra_count":6,"host_version":"0.1.126","missing_count":0,"probe_failures":1},"managed_resources_count":8,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"DeepSeek OpenAI Compatible","platform":"openai","provider_id":"deepseek"},"provider_status":"drifted","reconcile_runs_count":1} diff --git a/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/09-reconcile.json b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/09-reconcile.json new file mode 100644 index 00000000..cf3f833b --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":2,"extra_count":6,"missing_count":4,"provider_id":"deepseek","status":"drifted","summary":{"access_rechecked":true,"access_status":"broken","extra_count":6,"host_version":"0.1.126","missing_count":4,"probe_failures":1}} diff --git a/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/10-batch-detail.json b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/10-batch-detail.json new file mode 100644 index 00000000..f8e6b47d --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_104113_subscription_skip_rollback/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":3,"BatchID":2,"ClosureType":"subscription","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}"},{"ID":4,"BatchID":2,"ClosureType":"subscription","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"reconcile_rerun\":true,\"status_code\":403}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":2,"id":2,"mode":"partial","pack_id":1,"provider_id":1},"items":[{"account_status":"failed","batch_id":2,"id":2,"key_fingerprint":"sha256:555499e31601143d6c6e8dde8ee68266615cea97e4f3d1dbe9495bd070aaf8d6","probe_summary_json":"{\"account_id\":\"11\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":false,\"probe_status\":\"failed\",\"reconcile_rerun\":true,\"smoke_model_seen\":false}"}],"items_count":1,"managed_count":4,"managed_resources":[{"ID":5,"BatchID":2,"ResourceType":"group","HostResourceID":"8","ResourceName":"DeepSeek 默认分组"},{"ID":6,"BatchID":2,"ResourceType":"channel","HostResourceID":"6","ResourceName":"DeepSeek 默认渠道"},{"ID":7,"BatchID":2,"ResourceType":"plan","HostResourceID":"3","ResourceName":"DeepSeek 默认套餐"},{"ID":8,"BatchID":2,"ResourceType":"account","HostResourceID":"11","ResourceName":"deepseek-01"}],"reconcile_count":2,"reconcile_runs":[{"ID":2,"ProviderID":1,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":6,\"host_version\":\"0.1.126\",\"missing_count\":4,\"probe_failures\":1}"},{"ID":1,"ProviderID":1,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":6,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":1}"}]} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_retest/01-create-host.json b/artifacts/real-host-acceptance/20260517_live_openai_retest/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_retest/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_retest/02-probe-host.json b/artifacts/real-host-acceptance/20260517_live_openai_retest/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_retest/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_retest/03-install-pack.json b/artifacts/real-host-acceptance/20260517_live_openai_retest/03-install-pack.json new file mode 100644 index 00000000..2c86e58d --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_retest/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"},{"display_name":"OpenAI 中转兼容","provider_id":"openai-zhongzhuan"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_retest/04-preview-import.json b/artifacts/real-host-acceptance/20260517_live_openai_retest/04-preview-import.json new file mode 100644 index 00000000..5610c723 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_retest/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"OpenAI 中转默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"OpenAI 中转默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"OpenAI 中转默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"OpenAI 中转默认分组","Channel":"OpenAI 中转默认渠道","Plan":"OpenAI 中转默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_retest/05-import.json b/artifacts/real-host-acceptance/20260517_live_openai_retest/05-import.json new file mode 100644 index 00000000..1f85cf97 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_retest/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":1,"batch_status":"partially_succeeded","channel":{"id":"8","name":"OpenAI 中转默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"16","name":"OpenAI 中转默认分组"},"plan":null,"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_retest/06-access-preview.json b/artifacts/real-host-acceptance/20260517_live_openai_retest/06-access-preview.json new file mode 100644 index 00000000..84826377 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_retest/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"openai-zhongzhuan","mode":"self_service","available":false,"message":"access status broken does not satisfy mode self_service"} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_retest/07-access-status.json b/artifacts/real-host-acceptance/20260517_live_openai_retest/07-access-status.json new file mode 100644 index 00000000..a43f9a29 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_retest/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":1,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":1,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan"} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_retest/08-provider-status.json b/artifacts/real-host-acceptance/20260517_live_openai_retest/08-provider-status.json new file mode 100644 index 00000000..9c8674a8 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_retest/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":3,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"OpenAI 中转兼容","platform":"openai","provider_id":"openai-zhongzhuan"},"provider_status":"partially_succeeded","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_retest/09-reconcile.json b/artifacts/real-host-acceptance/20260517_live_openai_retest/09-reconcile.json new file mode 100644 index 00000000..b66555c3 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_retest/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":11,"missing_count":0,"provider_id":"openai-zhongzhuan","status":"drifted","summary":{"access_rechecked":true,"access_status":"broken","extra_count":11,"host_version":"0.1.126","missing_count":0,"probe_failures":0}} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_retest/10-batch-detail.json b/artifacts/real-host-acceptance/20260517_live_openai_retest/10-batch-detail.json new file mode 100644 index 00000000..c663dd54 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_retest/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}"},{"ID":2,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"reconcile_rerun\":true,\"status_code\":403}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":2,"id":1,"mode":"partial","pack_id":1,"provider_id":3},"items":[{"account_status":"passed","batch_id":1,"id":1,"key_fingerprint":"sha256:fbd0fe64bde9bf5e4fbc1b648540139ae34473dbdd07905a72b1e90970bddce5","probe_summary_json":"{\"account_id\":\"18\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"reconcile_rerun\":true,\"smoke_model_seen\":true}"}],"items_count":1,"managed_count":3,"managed_resources":[{"ID":1,"BatchID":1,"ResourceType":"group","HostResourceID":"16","ResourceName":"OpenAI 中转默认分组"},{"ID":2,"BatchID":1,"ResourceType":"channel","HostResourceID":"8","ResourceName":"OpenAI 中转默认渠道"},{"ID":3,"BatchID":1,"ResourceType":"account","HostResourceID":"18","ResourceName":"openai-zhongzhuan-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"ProviderID":3,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":11,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":0}"}]} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_retest/11-rollback.json b/artifacts/real-host-acceptance/20260517_live_openai_retest/11-rollback.json new file mode 100644 index 00000000..280e36f9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_retest/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":1,"deleted_accounts":1,"deleted_channels":1,"deleted_groups":1,"deleted_plans":0} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_nokey/01-create-host.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_nokey/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_nokey/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_nokey/02-probe-host.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_nokey/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_nokey/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_nokey/03-install-pack.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_nokey/03-install-pack.json new file mode 100644 index 00000000..5df521c5 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_nokey/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":true,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"},{"display_name":"OpenAI 中转兼容","provider_id":"openai-zhongzhuan"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_nokey/04-preview-import.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_nokey/04-preview-import.json new file mode 100644 index 00000000..5610c723 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_nokey/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"OpenAI 中转默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"OpenAI 中转默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"OpenAI 中转默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"OpenAI 中转默认分组","Channel":"OpenAI 中转默认渠道","Plan":"OpenAI 中转默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/01-create-host.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/02-probe-host.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/03-install-pack.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/03-install-pack.json new file mode 100644 index 00000000..2c86e58d --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"},{"display_name":"OpenAI 中转兼容","provider_id":"openai-zhongzhuan"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/04-preview-import.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/04-preview-import.json new file mode 100644 index 00000000..5610c723 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"OpenAI 中转默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"OpenAI 中转默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"OpenAI 中转默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"OpenAI 中转默认分组","Channel":"OpenAI 中转默认渠道","Plan":"OpenAI 中转默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/05-import.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/05-import.json new file mode 100644 index 00000000..8709b4fd --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":1,"batch_status":"partially_succeeded","channel":{"id":"9","name":"OpenAI 中转默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"17","name":"OpenAI 中转默认分组"},"plan":{"id":"4","name":"OpenAI 中转默认套餐"},"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/06-access-preview.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/06-access-preview.json new file mode 100644 index 00000000..a47a865b --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"openai-zhongzhuan","mode":"subscription","available":false,"message":"access status broken does not satisfy mode subscription"} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/07-access-status.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/07-access-status.json new file mode 100644 index 00000000..4ef0b747 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":1,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"subscription","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":1,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan"} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/08-provider-status.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/08-provider-status.json new file mode 100644 index 00000000..3049b477 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":4,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"OpenAI 中转兼容","platform":"openai","provider_id":"openai-zhongzhuan"},"provider_status":"partially_succeeded","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/09-reconcile.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/09-reconcile.json new file mode 100644 index 00000000..b66555c3 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":11,"missing_count":0,"provider_id":"openai-zhongzhuan","status":"drifted","summary":{"access_rechecked":true,"access_status":"broken","extra_count":11,"host_version":"0.1.126","missing_count":0,"probe_failures":0}} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/10-batch-detail.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/10-batch-detail.json new file mode 100644 index 00000000..6c13ff1c --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"subscription","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}"},{"ID":2,"BatchID":1,"ClosureType":"subscription","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"reconcile_rerun\":true,\"status_code\":403}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":2,"id":1,"mode":"partial","pack_id":1,"provider_id":3},"items":[{"account_status":"passed","batch_id":1,"id":1,"key_fingerprint":"sha256:fbd0fe64bde9bf5e4fbc1b648540139ae34473dbdd07905a72b1e90970bddce5","probe_summary_json":"{\"account_id\":\"19\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"reconcile_rerun\":true,\"smoke_model_seen\":true}"}],"items_count":1,"managed_count":4,"managed_resources":[{"ID":1,"BatchID":1,"ResourceType":"group","HostResourceID":"17","ResourceName":"OpenAI 中转默认分组"},{"ID":2,"BatchID":1,"ResourceType":"channel","HostResourceID":"9","ResourceName":"OpenAI 中转默认渠道"},{"ID":3,"BatchID":1,"ResourceType":"plan","HostResourceID":"4","ResourceName":"OpenAI 中转默认套餐"},{"ID":4,"BatchID":1,"ResourceType":"account","HostResourceID":"19","ResourceName":"openai-zhongzhuan-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"ProviderID":3,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":11,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":0}"}]} diff --git a/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/11-rollback.json b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/11-rollback.json new file mode 100644 index 00000000..6258a080 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_live_openai_subscription_retest/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":1,"deleted_accounts":1,"deleted_channels":1,"deleted_groups":1,"deleted_plans":1} diff --git a/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/01-create-host.json b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/02-probe-host.json b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/03-install-pack.json b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/03-install-pack.json new file mode 100644 index 00000000..2c86e58d --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"},{"display_name":"OpenAI 中转兼容","provider_id":"openai-zhongzhuan"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/04-preview-import.json b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/04-preview-import.json new file mode 100644 index 00000000..40d89a43 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"OpenAI 中转默认渠道","ExistingID":"","Reason":""},"group":{"Action":"reuse","Suggested":"OpenAI 中转默认分组","ExistingID":"19","Reason":"matching managed resource already exists"},"plan":{"Action":"create","Suggested":"OpenAI 中转默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"OpenAI 中转默认分组","Channel":"OpenAI 中转默认渠道","Plan":"OpenAI 中转默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/05-import.json b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/05-import.json new file mode 100644 index 00000000..1b1baf5b --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":1,"batch_status":"partially_succeeded","channel":{"id":"11","name":"OpenAI 中转默认渠道"},"gateway":{"ok":true,"status_code":200,"models":["claude-opus-4-5-20251101","claude-opus-4-6","claude-opus-4-7","claude-sonnet-4-6","claude-sonnet-4-5-20250929","claude-haiku-4-5-20251001"],"has_expected_model":false},"group":{"id":"19","name":"OpenAI 中转默认分组"},"plan":null,"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/06-access-preview.json b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/06-access-preview.json new file mode 100644 index 00000000..84826377 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"openai-zhongzhuan","mode":"self_service","available":false,"message":"access status broken does not satisfy mode self_service"} diff --git a/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/07-access-status.json b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/07-access-status.json new file mode 100644 index 00000000..8a39c4bb --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":1,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":false,\"models\":[\"claude-opus-4-5-20251101\",\"claude-opus-4-6\",\"claude-opus-4-7\",\"claude-sonnet-4-6\",\"claude-sonnet-4-5-20250929\",\"claude-haiku-4-5-20251001\"],\"ok\":true,\"status_code\":200}","id":1,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan"} diff --git a/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/08-provider-status.json b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/08-provider-status.json new file mode 100644 index 00000000..9c8674a8 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":3,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"OpenAI 中转兼容","platform":"openai","provider_id":"openai-zhongzhuan"},"provider_status":"partially_succeeded","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/09-reconcile.json b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/09-reconcile.json new file mode 100644 index 00000000..b66555c3 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":11,"missing_count":0,"provider_id":"openai-zhongzhuan","status":"drifted","summary":{"access_rechecked":true,"access_status":"broken","extra_count":11,"host_version":"0.1.126","missing_count":0,"probe_failures":0}} diff --git a/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/10-batch-detail.json b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/10-batch-detail.json new file mode 100644 index 00000000..24f4b4a7 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":[\"claude-opus-4-5-20251101\",\"claude-opus-4-6\",\"claude-opus-4-7\",\"claude-sonnet-4-6\",\"claude-sonnet-4-5-20250929\",\"claude-haiku-4-5-20251001\"],\"ok\":true,\"status_code\":200}"},{"ID":2,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":[\"claude-opus-4-5-20251101\",\"claude-opus-4-6\",\"claude-opus-4-7\",\"claude-sonnet-4-6\",\"claude-sonnet-4-5-20250929\",\"claude-haiku-4-5-20251001\"],\"ok\":true,\"reconcile_rerun\":true,\"status_code\":200}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":2,"id":1,"mode":"partial","pack_id":1,"provider_id":3},"items":[{"account_status":"passed","batch_id":1,"id":1,"key_fingerprint":"sha256:fbd0fe64bde9bf5e4fbc1b648540139ae34473dbdd07905a72b1e90970bddce5","probe_summary_json":"{\"account_id\":\"21\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"reconcile_rerun\":true,\"smoke_model_seen\":true}"}],"items_count":1,"managed_count":3,"managed_resources":[{"ID":1,"BatchID":1,"ResourceType":"group","HostResourceID":"19","ResourceName":"OpenAI 中转默认分组"},{"ID":2,"BatchID":1,"ResourceType":"channel","HostResourceID":"11","ResourceName":"OpenAI 中转默认渠道"},{"ID":3,"BatchID":1,"ResourceType":"account","HostResourceID":"21","ResourceName":"openai-zhongzhuan-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"ProviderID":3,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":11,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":0}"}]} diff --git a/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/11-rollback.json b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/11-rollback.json new file mode 100644 index 00000000..280e36f9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_group_balance_retest/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":1,"deleted_accounts":1,"deleted_channels":1,"deleted_groups":1,"deleted_plans":0} diff --git a/artifacts/real-host-acceptance/20260517_openai_keep_scene/01-create-host.json b/artifacts/real-host-acceptance/20260517_openai_keep_scene/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_keep_scene/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_openai_keep_scene/02-probe-host.json b/artifacts/real-host-acceptance/20260517_openai_keep_scene/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_keep_scene/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_openai_keep_scene/03-install-pack.json b/artifacts/real-host-acceptance/20260517_openai_keep_scene/03-install-pack.json new file mode 100644 index 00000000..2c86e58d --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_keep_scene/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"},{"display_name":"OpenAI 中转兼容","provider_id":"openai-zhongzhuan"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_openai_keep_scene/04-preview-import.json b/artifacts/real-host-acceptance/20260517_openai_keep_scene/04-preview-import.json new file mode 100644 index 00000000..e3e36b64 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_keep_scene/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"OpenAI 中转默认渠道","ExistingID":"","Reason":""},"group":{"Action":"reuse","Suggested":"OpenAI 中转默认分组","ExistingID":"20","Reason":"matching managed resource already exists"},"plan":{"Action":"create","Suggested":"OpenAI 中转默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"OpenAI 中转默认分组","Channel":"OpenAI 中转默认渠道","Plan":"OpenAI 中转默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_openai_keep_scene/05-import.json b/artifacts/real-host-acceptance/20260517_openai_keep_scene/05-import.json new file mode 100644 index 00000000..9fcbe01c --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_keep_scene/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":1,"batch_status":"partially_succeeded","channel":{"id":"12","name":"OpenAI 中转默认渠道"},"gateway":{"ok":true,"status_code":200,"models":["claude-opus-4-5-20251101","claude-opus-4-6","claude-opus-4-7","claude-sonnet-4-6","claude-sonnet-4-5-20250929","claude-haiku-4-5-20251001"],"has_expected_model":false},"group":{"id":"20","name":"OpenAI 中转默认分组"},"plan":null,"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260517_openai_keep_scene/06-access-preview.json b/artifacts/real-host-acceptance/20260517_openai_keep_scene/06-access-preview.json new file mode 100644 index 00000000..84826377 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_keep_scene/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"openai-zhongzhuan","mode":"self_service","available":false,"message":"access status broken does not satisfy mode self_service"} diff --git a/artifacts/real-host-acceptance/20260517_openai_keep_scene/07-access-status.json b/artifacts/real-host-acceptance/20260517_openai_keep_scene/07-access-status.json new file mode 100644 index 00000000..8a39c4bb --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_keep_scene/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":1,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":false,\"models\":[\"claude-opus-4-5-20251101\",\"claude-opus-4-6\",\"claude-opus-4-7\",\"claude-sonnet-4-6\",\"claude-sonnet-4-5-20250929\",\"claude-haiku-4-5-20251001\"],\"ok\":true,\"status_code\":200}","id":1,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan"} diff --git a/artifacts/real-host-acceptance/20260517_openai_keep_scene/08-provider-status.json b/artifacts/real-host-acceptance/20260517_openai_keep_scene/08-provider-status.json new file mode 100644 index 00000000..9c8674a8 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_keep_scene/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":3,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"OpenAI 中转兼容","platform":"openai","provider_id":"openai-zhongzhuan"},"provider_status":"partially_succeeded","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260517_openai_keep_scene/09-reconcile.json b/artifacts/real-host-acceptance/20260517_openai_keep_scene/09-reconcile.json new file mode 100644 index 00000000..b66555c3 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_keep_scene/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":11,"missing_count":0,"provider_id":"openai-zhongzhuan","status":"drifted","summary":{"access_rechecked":true,"access_status":"broken","extra_count":11,"host_version":"0.1.126","missing_count":0,"probe_failures":0}} diff --git a/artifacts/real-host-acceptance/20260517_openai_keep_scene/10-batch-detail.json b/artifacts/real-host-acceptance/20260517_openai_keep_scene/10-batch-detail.json new file mode 100644 index 00000000..f8284089 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_keep_scene/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":[\"claude-opus-4-5-20251101\",\"claude-opus-4-6\",\"claude-opus-4-7\",\"claude-sonnet-4-6\",\"claude-sonnet-4-5-20250929\",\"claude-haiku-4-5-20251001\"],\"ok\":true,\"status_code\":200}"},{"ID":2,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":[\"claude-opus-4-5-20251101\",\"claude-opus-4-6\",\"claude-opus-4-7\",\"claude-sonnet-4-6\",\"claude-sonnet-4-5-20250929\",\"claude-haiku-4-5-20251001\"],\"ok\":true,\"reconcile_rerun\":true,\"status_code\":200}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":2,"id":1,"mode":"partial","pack_id":1,"provider_id":3},"items":[{"account_status":"passed","batch_id":1,"id":1,"key_fingerprint":"sha256:fbd0fe64bde9bf5e4fbc1b648540139ae34473dbdd07905a72b1e90970bddce5","probe_summary_json":"{\"account_id\":\"22\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"reconcile_rerun\":true,\"smoke_model_seen\":true}"}],"items_count":1,"managed_count":3,"managed_resources":[{"ID":1,"BatchID":1,"ResourceType":"group","HostResourceID":"20","ResourceName":"OpenAI 中转默认分组"},{"ID":2,"BatchID":1,"ResourceType":"channel","HostResourceID":"12","ResourceName":"OpenAI 中转默认渠道"},{"ID":3,"BatchID":1,"ResourceType":"account","HostResourceID":"22","ResourceName":"openai-zhongzhuan-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"ProviderID":3,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":11,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":0}"}]} diff --git a/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/01-create-host.json b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/02-probe-host.json b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/03-install-pack.json b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/03-install-pack.json new file mode 100644 index 00000000..2c86e58d --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"},{"display_name":"OpenAI 中转兼容","provider_id":"openai-zhongzhuan"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/04-preview-import.json b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/04-preview-import.json new file mode 100644 index 00000000..26acbdb9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"OpenAI 中转默认渠道","ExistingID":"","Reason":""},"group":{"Action":"reuse","Suggested":"OpenAI 中转默认分组","ExistingID":"21","Reason":"matching managed resource already exists"},"plan":{"Action":"create","Suggested":"OpenAI 中转默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"OpenAI 中转默认分组","Channel":"OpenAI 中转默认渠道","Plan":"OpenAI 中转默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/05-import.json b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/05-import.json new file mode 100644 index 00000000..426f1940 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"self_service_ready","accounts_count":1,"batch_id":1,"batch_status":"succeeded","channel":{"id":"13","name":"OpenAI 中转默认渠道"},"gateway":{"ok":true,"status_code":200,"models":["gpt-5.5","gpt-5.4","gpt-5.4-mini","gpt-5.3-codex","gpt-5.3-codex-spark","gpt-5.2","gpt-image-1","gpt-image-1.5","gpt-image-2"],"has_expected_model":true},"group":{"id":"21","name":"OpenAI 中转默认分组"},"plan":null,"provider_status":"active"} diff --git a/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/06-access-preview.json b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/06-access-preview.json new file mode 100644 index 00000000..a15d4fcd --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"openai-zhongzhuan","mode":"self_service","available":true,"message":"latest access status: self_service_ready"} diff --git a/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/07-access-status.json b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/07-access-status.json new file mode 100644 index 00000000..386eeb8a --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"self_service_ready","batch_id":1,"closures_count":1,"latest_access_status":"self_service_ready","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":true,\"models\":[\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.4-mini\",\"gpt-5.3-codex\",\"gpt-5.3-codex-spark\",\"gpt-5.2\",\"gpt-image-1\",\"gpt-image-1.5\",\"gpt-image-2\"],\"ok\":true,\"status_code\":200}","id":1,"status":"self_service_ready"},"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan"} diff --git a/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/08-provider-status.json b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/08-provider-status.json new file mode 100644 index 00000000..cf2a5f58 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"self_service_ready","batch_status":"succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"self_service_ready","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":3,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"OpenAI 中转兼容","platform":"openai","provider_id":"openai-zhongzhuan"},"provider_status":"active","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/09-reconcile.json b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/09-reconcile.json new file mode 100644 index 00000000..79620966 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":11,"missing_count":0,"provider_id":"openai-zhongzhuan","status":"drifted","summary":{"access_rechecked":true,"access_status":"self_service_ready","extra_count":11,"host_version":"0.1.126","missing_count":0,"probe_failures":0}} diff --git a/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/10-batch-detail.json b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/10-batch-detail.json new file mode 100644 index 00000000..42e37e9e --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"self_service","Status":"self_service_ready","DetailsJSON":"{\"has_expected_model\":true,\"models\":[\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.4-mini\",\"gpt-5.3-codex\",\"gpt-5.3-codex-spark\",\"gpt-5.2\",\"gpt-image-1\",\"gpt-image-1.5\",\"gpt-image-2\"],\"ok\":true,\"status_code\":200}"},{"ID":2,"BatchID":1,"ClosureType":"self_service","Status":"self_service_ready","DetailsJSON":"{\"has_expected_model\":true,\"models\":[\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.4-mini\",\"gpt-5.3-codex\",\"gpt-5.3-codex-spark\",\"gpt-5.2\",\"gpt-image-1\",\"gpt-image-1.5\",\"gpt-image-2\"],\"ok\":true,\"reconcile_rerun\":true,\"status_code\":200}"}],"access_count":2,"batch":{"access_status":"self_service_ready","batch_status":"succeeded","host_id":2,"id":1,"mode":"partial","pack_id":1,"provider_id":3},"items":[{"account_status":"passed","batch_id":1,"id":1,"key_fingerprint":"sha256:fbd0fe64bde9bf5e4fbc1b648540139ae34473dbdd07905a72b1e90970bddce5","probe_summary_json":"{\"account_id\":\"23\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"reconcile_rerun\":true,\"smoke_model_seen\":true}"}],"items_count":1,"managed_count":3,"managed_resources":[{"ID":1,"BatchID":1,"ResourceType":"group","HostResourceID":"21","ResourceName":"OpenAI 中转默认分组"},{"ID":2,"BatchID":1,"ResourceType":"channel","HostResourceID":"13","ResourceName":"OpenAI 中转默认渠道"},{"ID":3,"BatchID":1,"ResourceType":"account","HostResourceID":"23","ResourceName":"openai-zhongzhuan-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"ProviderID":3,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"self_service_ready\",\"extra_count\":11,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":0}"}]} diff --git a/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/11-rollback.json b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/11-rollback.json new file mode 100644 index 00000000..280e36f9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":1,"deleted_accounts":1,"deleted_channels":1,"deleted_groups":1,"deleted_plans":0} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/01-create-host.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/01-create-host.json new file mode 100644 index 00000000..68b2ed01 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"sub2api-reconcile-self","base_url":"http://127.0.0.1:18087","host_version":"0.1.126","auth_type":"bearer","status":"unsupported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":false,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/02-probe-host.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/02-probe-host.json new file mode 100644 index 00000000..46725772 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"sub2api-reconcile-self","base_url":"http://127.0.0.1:18087","host_version":"0.1.126","status":"unsupported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":false,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/03-install-pack.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/03-install-pack.json new file mode 100644 index 00000000..2c86e58d --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"},{"display_name":"OpenAI 中转兼容","provider_id":"openai-zhongzhuan"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/04-preview-import.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/04-preview-import.json new file mode 100644 index 00000000..e4d6f248 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"reuse","Suggested":"OpenAI 中转默认渠道","ExistingID":"2","Reason":"matching managed resource already exists"},"group":{"Action":"reuse","Suggested":"OpenAI 中转默认分组","ExistingID":"3","Reason":"matching managed resource already exists"},"plan":{"Action":"create","Suggested":"OpenAI 中转默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"OpenAI 中转默认分组","Channel":"OpenAI 中转默认渠道","Plan":"OpenAI 中转默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/05-import.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/05-import.json new file mode 100644 index 00000000..c1d9881e --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"self_service_ready","accounts_count":1,"batch_id":1,"batch_status":"succeeded","channel":{"id":"2","name":"OpenAI 中转默认渠道"},"gateway":{"ok":true,"status_code":200,"models":["gpt-5.5","gpt-5.4","gpt-5.4-mini","gpt-5.3-codex","gpt-5.3-codex-spark","gpt-5.2","gpt-image-1","gpt-image-1.5","gpt-image-2"],"has_expected_model":true},"group":{"id":"3","name":"OpenAI 中转默认分组"},"plan":null,"provider_status":"active"} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/06-access-preview.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/06-access-preview.json new file mode 100644 index 00000000..a15d4fcd --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"openai-zhongzhuan","mode":"self_service","available":true,"message":"latest access status: self_service_ready"} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/07-access-status.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/07-access-status.json new file mode 100644 index 00000000..386eeb8a --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"self_service_ready","batch_id":1,"closures_count":1,"latest_access_status":"self_service_ready","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":true,\"models\":[\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.4-mini\",\"gpt-5.3-codex\",\"gpt-5.3-codex-spark\",\"gpt-5.2\",\"gpt-image-1\",\"gpt-image-1.5\",\"gpt-image-2\"],\"ok\":true,\"status_code\":200}","id":1,"status":"self_service_ready"},"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan"} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/08-provider-status.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/08-provider-status.json new file mode 100644 index 00000000..1a11c5f8 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"self_service_ready","batch_status":"succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18087","host_id":"sub2api-reconcile-self","host_version":"0.1.126"},"latest_access_status":"self_service_ready","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":3,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"OpenAI 中转兼容","platform":"openai","provider_id":"openai-zhongzhuan"},"provider_status":"active","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/08a-provider-resources.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/08a-provider-resources.json new file mode 100644 index 00000000..db240b71 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/08a-provider-resources.json @@ -0,0 +1 @@ +{"access_closures":[{"closure_type":"self_service","details_json":"{\"has_expected_model\":true,\"models\":[\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.4-mini\",\"gpt-5.3-codex\",\"gpt-5.3-codex-spark\",\"gpt-5.2\",\"gpt-image-1\",\"gpt-image-1.5\",\"gpt-image-2\"],\"ok\":true,\"status_code\":200}","id":1,"status":"self_service_ready"}],"batch_id":1,"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan","reconcile_runs":[],"resources":[{"host_resource_id":"3","id":1,"resource_name":"OpenAI 中转默认分组","resource_type":"group"},{"host_resource_id":"2","id":2,"resource_name":"OpenAI 中转默认渠道","resource_type":"channel"},{"host_resource_id":"3","id":3,"resource_name":"openai-zhongzhuan-01","resource_type":"account"}]} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/09-reconcile.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/09-reconcile.json new file mode 100644 index 00000000..879b9d70 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":1,"missing_count":0,"provider_id":"openai-zhongzhuan","status":"drifted","summary":{"access_rechecked":true,"access_status":"self_service_ready","extra_count":1,"host_version":"0.1.126","missing_count":0,"probe_failures":0}} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/10-batch-detail.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/10-batch-detail.json new file mode 100644 index 00000000..94d740b0 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"self_service","Status":"self_service_ready","DetailsJSON":"{\"has_expected_model\":true,\"models\":[\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.4-mini\",\"gpt-5.3-codex\",\"gpt-5.3-codex-spark\",\"gpt-5.2\",\"gpt-image-1\",\"gpt-image-1.5\",\"gpt-image-2\"],\"ok\":true,\"status_code\":200}"},{"ID":2,"BatchID":1,"ClosureType":"self_service","Status":"self_service_ready","DetailsJSON":"{\"has_expected_model\":true,\"models\":[\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.4-mini\",\"gpt-5.3-codex\",\"gpt-5.3-codex-spark\",\"gpt-5.2\",\"gpt-image-1\",\"gpt-image-1.5\",\"gpt-image-2\"],\"ok\":true,\"reconcile_rerun\":true,\"status_code\":200}"}],"access_count":2,"batch":{"access_status":"self_service_ready","batch_status":"succeeded","host_id":1,"id":1,"mode":"partial","pack_id":1,"provider_id":3},"items":[{"account_status":"passed","batch_id":1,"id":1,"key_fingerprint":"sha256:fbd0fe64bde9bf5e4fbc1b648540139ae34473dbdd07905a72b1e90970bddce5","probe_summary_json":"{\"account_id\":\"3\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"reconcile_rerun\":true,\"smoke_model_seen\":true}"}],"items_count":1,"managed_count":3,"managed_resources":[{"ID":1,"BatchID":1,"HostID":1,"ResourceType":"group","HostResourceID":"3","ResourceName":"OpenAI 中转默认分组"},{"ID":2,"BatchID":1,"HostID":1,"ResourceType":"channel","HostResourceID":"2","ResourceName":"OpenAI 中转默认渠道"},{"ID":3,"BatchID":1,"HostID":1,"ResourceType":"account","HostResourceID":"3","ResourceName":"openai-zhongzhuan-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"HostID":1,"ProviderID":3,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"self_service_ready\",\"extra_count\":1,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":0}"}]} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/11-rollback.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/11-rollback.json new file mode 100644 index 00000000..280e36f9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":1,"deleted_accounts":1,"deleted_channels":1,"deleted_groups":1,"deleted_plans":0} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/01-create-host.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/01-create-host.json new file mode 100644 index 00000000..68b2ed01 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"sub2api-reconcile-self","base_url":"http://127.0.0.1:18087","host_version":"0.1.126","auth_type":"bearer","status":"unsupported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":false,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/02-probe-host.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/02-probe-host.json new file mode 100644 index 00000000..46725772 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"sub2api-reconcile-self","base_url":"http://127.0.0.1:18087","host_version":"0.1.126","status":"unsupported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":false,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/03-install-pack.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/03-install-pack.json new file mode 100644 index 00000000..5df521c5 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":true,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"},{"display_name":"OpenAI 中转兼容","provider_id":"openai-zhongzhuan"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/04-preview-import.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/04-preview-import.json new file mode 100644 index 00000000..5610c723 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"OpenAI 中转默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"OpenAI 中转默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"OpenAI 中转默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"OpenAI 中转默认分组","Channel":"OpenAI 中转默认渠道","Plan":"OpenAI 中转默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/05-import.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/05-import.json new file mode 100644 index 00000000..0d108e84 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"subscription_ready","accounts_count":1,"batch_id":2,"batch_status":"succeeded","channel":{"id":"3","name":"OpenAI 中转默认渠道"},"gateway":{"ok":true,"status_code":200,"models":["gpt-5.5","gpt-5.4","gpt-5.4-mini","gpt-5.3-codex","gpt-5.3-codex-spark","gpt-5.2","gpt-image-1","gpt-image-1.5","gpt-image-2"],"has_expected_model":true},"group":{"id":"5","name":"OpenAI 中转默认分组"},"plan":{"id":"1","name":"OpenAI 中转默认套餐"},"provider_status":"active"} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/06-access-preview.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/06-access-preview.json new file mode 100644 index 00000000..6ec6c2f1 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"openai-zhongzhuan","mode":"subscription","available":true,"message":"latest access status: subscription_ready"} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/07-access-status.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/07-access-status.json new file mode 100644 index 00000000..2f6380d4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"subscription_ready","batch_id":2,"closures_count":1,"latest_access_status":"subscription_ready","latest_closure":{"closure_type":"subscription","details_json":"{\"has_expected_model\":true,\"models\":[\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.4-mini\",\"gpt-5.3-codex\",\"gpt-5.3-codex-spark\",\"gpt-5.2\",\"gpt-image-1\",\"gpt-image-1.5\",\"gpt-image-2\"],\"ok\":true,\"status_code\":200}","id":3,"status":"subscription_ready"},"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan"} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/08-provider-status.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/08-provider-status.json new file mode 100644 index 00000000..c335704c --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"subscription_ready","batch_status":"succeeded","id":2,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18087","host_id":"sub2api-reconcile-self","host_version":"0.1.126"},"latest_access_status":"subscription_ready","latest_reconcile_status":"drifted","latest_reconcile_summary":{"access_rechecked":true,"access_status":"self_service_ready","extra_count":1,"host_version":"0.1.126","missing_count":0,"probe_failures":0},"managed_resources_count":7,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"OpenAI 中转兼容","platform":"openai","provider_id":"openai-zhongzhuan"},"provider_status":"drifted","reconcile_runs_count":1} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/08a-provider-resources.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/08a-provider-resources.json new file mode 100644 index 00000000..5abd0c19 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/08a-provider-resources.json @@ -0,0 +1 @@ +{"access_closures":[{"closure_type":"subscription","details_json":"{\"has_expected_model\":true,\"models\":[\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.4-mini\",\"gpt-5.3-codex\",\"gpt-5.3-codex-spark\",\"gpt-5.2\",\"gpt-image-1\",\"gpt-image-1.5\",\"gpt-image-2\"],\"ok\":true,\"status_code\":200}","id":3,"status":"subscription_ready"}],"batch_id":2,"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan","reconcile_runs":[{"id":1,"status":"drifted","summary_json":"{\"access_rechecked\":true,\"access_status\":\"self_service_ready\",\"extra_count\":1,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":0}"}],"resources":[{"host_resource_id":"3","id":1,"resource_name":"OpenAI 中转默认分组","resource_type":"group"},{"host_resource_id":"2","id":2,"resource_name":"OpenAI 中转默认渠道","resource_type":"channel"},{"host_resource_id":"3","id":3,"resource_name":"openai-zhongzhuan-01","resource_type":"account"},{"host_resource_id":"5","id":4,"resource_name":"OpenAI 中转默认分组","resource_type":"group"},{"host_resource_id":"3","id":5,"resource_name":"OpenAI 中转默认渠道","resource_type":"channel"},{"host_resource_id":"1","id":6,"resource_name":"OpenAI 中转默认套餐","resource_type":"plan"},{"host_resource_id":"4","id":7,"resource_name":"openai-zhongzhuan-01","resource_type":"account"}]} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/09-reconcile.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/09-reconcile.json new file mode 100644 index 00000000..a23d720f --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":2,"extra_count":1,"missing_count":3,"provider_id":"openai-zhongzhuan","status":"drifted","summary":{"access_rechecked":true,"access_status":"subscription_ready","extra_count":1,"host_version":"0.1.126","missing_count":3,"probe_failures":0}} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/10-batch-detail.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/10-batch-detail.json new file mode 100644 index 00000000..cfececf2 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":3,"BatchID":2,"ClosureType":"subscription","Status":"subscription_ready","DetailsJSON":"{\"has_expected_model\":true,\"models\":[\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.4-mini\",\"gpt-5.3-codex\",\"gpt-5.3-codex-spark\",\"gpt-5.2\",\"gpt-image-1\",\"gpt-image-1.5\",\"gpt-image-2\"],\"ok\":true,\"status_code\":200}"},{"ID":4,"BatchID":2,"ClosureType":"subscription","Status":"subscription_ready","DetailsJSON":"{\"has_expected_model\":true,\"models\":[\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.4-mini\",\"gpt-5.3-codex\",\"gpt-5.3-codex-spark\",\"gpt-5.2\",\"gpt-image-1\",\"gpt-image-1.5\",\"gpt-image-2\"],\"ok\":true,\"reconcile_rerun\":true,\"status_code\":200}"}],"access_count":2,"batch":{"access_status":"subscription_ready","batch_status":"succeeded","host_id":1,"id":2,"mode":"partial","pack_id":1,"provider_id":3},"items":[{"account_status":"passed","batch_id":2,"id":2,"key_fingerprint":"sha256:fbd0fe64bde9bf5e4fbc1b648540139ae34473dbdd07905a72b1e90970bddce5","probe_summary_json":"{\"account_id\":\"4\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"reconcile_rerun\":true,\"smoke_model_seen\":true}"}],"items_count":1,"managed_count":4,"managed_resources":[{"ID":4,"BatchID":2,"HostID":1,"ResourceType":"group","HostResourceID":"5","ResourceName":"OpenAI 中转默认分组"},{"ID":5,"BatchID":2,"HostID":1,"ResourceType":"channel","HostResourceID":"3","ResourceName":"OpenAI 中转默认渠道"},{"ID":6,"BatchID":2,"HostID":1,"ResourceType":"plan","HostResourceID":"1","ResourceName":"OpenAI 中转默认套餐"},{"ID":7,"BatchID":2,"HostID":1,"ResourceType":"account","HostResourceID":"4","ResourceName":"openai-zhongzhuan-01"}],"reconcile_count":2,"reconcile_runs":[{"ID":2,"HostID":1,"ProviderID":3,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"subscription_ready\",\"extra_count\":1,\"host_version\":\"0.1.126\",\"missing_count\":3,\"probe_failures\":0}"},{"ID":1,"HostID":1,"ProviderID":3,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"self_service_ready\",\"extra_count\":1,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":0}"}]} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/11-rollback.json b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/11-rollback.json new file mode 100644 index 00000000..70b32076 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":2,"deleted_accounts":1,"deleted_channels":1,"deleted_groups":1,"deleted_plans":1} diff --git a/artifacts/real-host-acceptance/20260518_reconcile_hostscope_summary.md b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_summary.md new file mode 100644 index 00000000..b9b4e6f6 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_reconcile_hostscope_summary.md @@ -0,0 +1,36 @@ +# 2026-05-18 reconcile host-scope reaccept + +目标:复验 latest code 中 reconcile_runs host 维度收口后的真实宿主链路。 + +控制面 +- 代码:当前工作树(含 reconcile_runs.host_id 迁移与 host-scoped 查询) +- 临时 CRM:`http://127.0.0.1:18100` +- 宿主:`http://127.0.0.1:18087` +- pack:`/home/long/project/sub2api-cn-relay-manager/packs/openai-cn-pack` +- provider:`openai-zhongzhuan` + +产物 +- self_service:`artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service/` +- subscription:`artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription/` + +关键结果 +- self_service import:`05-import.json` + - `batch_status=succeeded` + - `access_status=self_service_ready` + - `gateway.status_code=200` +- subscription import:`05-import.json` + - `batch_status=succeeded` + - `access_status=subscription_ready` + - `gateway.status_code=200` +- 两套验收都补充了 `host_id` 显式查询的: + - `07-access-status.json` + - `08-provider-status.json` + - `08a-provider-resources.json` +- 两套验收都补充了 reconcile 与 batch detail: + - `09-reconcile.json` + - `10-batch-detail.json` + - `11-rollback.json` + +说明 +- 本轮真实宿主只有 redeploy host (`18087`) 的可复用管理员/普通用户凭据,因此 self_service 与 subscription 复验都落在同一宿主上;跨宿主隔离的直接证明来自新增的 store/provision 单元与集成测试。 +- subscription 的 `08-provider-status.json` / `10-batch-detail.json` 会看到同 host+provider 既有 reconcile 历史,这是当前“provider@host 运行时历史”语义,不是跨宿主串台。 diff --git a/artifacts/real-host-acceptance/20260518_redeploy_matrix/01-self-import-initial.json b/artifacts/real-host-acceptance/20260518_redeploy_matrix/01-self-import-initial.json new file mode 100644 index 00000000..b11bc306 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_matrix/01-self-import-initial.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":1,"batch_status":"partially_succeeded","channel":{"id":"2","name":"OpenAI 中转默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"3","name":"OpenAI 中转默认分组"},"plan":null,"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_matrix/02-self-access-preview-initial.json b/artifacts/real-host-acceptance/20260518_redeploy_matrix/02-self-access-preview-initial.json new file mode 100644 index 00000000..84826377 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_matrix/02-self-access-preview-initial.json @@ -0,0 +1 @@ +{"provider_id":"openai-zhongzhuan","mode":"self_service","available":false,"message":"access status broken does not satisfy mode self_service"} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_matrix/03-self-access-status-initial.json b/artifacts/real-host-acceptance/20260518_redeploy_matrix/03-self-access-status-initial.json new file mode 100644 index 00000000..a43f9a29 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_matrix/03-self-access-status-initial.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":1,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":1,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan"} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_matrix/04-self-after-balance.headers.txt b/artifacts/real-host-acceptance/20260518_redeploy_matrix/04-self-after-balance.headers.txt new file mode 100644 index 00000000..e8dc6193 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_matrix/04-self-after-balance.headers.txt @@ -0,0 +1,9 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +Referrer-Policy: strict-origin-when-cross-origin +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +X-Request-Id: e3a14c8c-1084-47b4-b055-1d279a709461 +Date: Mon, 18 May 2026 07:49:46 GMT +Content-Length: 1135 + diff --git a/artifacts/real-host-acceptance/20260518_redeploy_matrix/05-self-after-balance.body.json b/artifacts/real-host-acceptance/20260518_redeploy_matrix/05-self-after-balance.body.json new file mode 100644 index 00000000..4147a9b1 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_matrix/05-self-after-balance.body.json @@ -0,0 +1 @@ +{"data":[{"id":"gpt-5.5","object":"model","created":1776873600,"owned_by":"openai","type":"model","display_name":"GPT-5.5"},{"id":"gpt-5.4","object":"model","created":1738368000,"owned_by":"openai","type":"model","display_name":"GPT-5.4"},{"id":"gpt-5.4-mini","object":"model","created":1738368000,"owned_by":"openai","type":"model","display_name":"GPT-5.4 Mini"},{"id":"gpt-5.3-codex","object":"model","created":1735689600,"owned_by":"openai","type":"model","display_name":"GPT-5.3 Codex"},{"id":"gpt-5.3-codex-spark","object":"model","created":1735689600,"owned_by":"openai","type":"model","display_name":"GPT-5.3 Codex Spark"},{"id":"gpt-5.2","object":"model","created":1733875200,"owned_by":"openai","type":"model","display_name":"GPT-5.2"},{"id":"gpt-image-1","object":"model","created":1733875200,"owned_by":"openai","type":"model","display_name":"GPT Image 1"},{"id":"gpt-image-1.5","object":"model","created":1735689600,"owned_by":"openai","type":"model","display_name":"GPT Image 1.5"},{"id":"gpt-image-2","object":"model","created":1738368000,"owned_by":"openai","type":"model","display_name":"GPT Image 2"}],"object":"list"} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260518_redeploy_matrix/06-subscription-after-assign.headers.txt b/artifacts/real-host-acceptance/20260518_redeploy_matrix/06-subscription-after-assign.headers.txt new file mode 100644 index 00000000..bfccce7d --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_matrix/06-subscription-after-assign.headers.txt @@ -0,0 +1,9 @@ +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +Referrer-Policy: strict-origin-when-cross-origin +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +X-Request-Id: 974066de-fdfe-475e-bc10-f95536086c0e +Date: Mon, 18 May 2026 07:53:09 GMT +Content-Length: 1135 + diff --git a/artifacts/real-host-acceptance/20260518_redeploy_matrix/07-subscription-after-assign.body.json b/artifacts/real-host-acceptance/20260518_redeploy_matrix/07-subscription-after-assign.body.json new file mode 100644 index 00000000..4147a9b1 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_matrix/07-subscription-after-assign.body.json @@ -0,0 +1 @@ +{"data":[{"id":"gpt-5.5","object":"model","created":1776873600,"owned_by":"openai","type":"model","display_name":"GPT-5.5"},{"id":"gpt-5.4","object":"model","created":1738368000,"owned_by":"openai","type":"model","display_name":"GPT-5.4"},{"id":"gpt-5.4-mini","object":"model","created":1738368000,"owned_by":"openai","type":"model","display_name":"GPT-5.4 Mini"},{"id":"gpt-5.3-codex","object":"model","created":1735689600,"owned_by":"openai","type":"model","display_name":"GPT-5.3 Codex"},{"id":"gpt-5.3-codex-spark","object":"model","created":1735689600,"owned_by":"openai","type":"model","display_name":"GPT-5.3 Codex Spark"},{"id":"gpt-5.2","object":"model","created":1733875200,"owned_by":"openai","type":"model","display_name":"GPT-5.2"},{"id":"gpt-image-1","object":"model","created":1733875200,"owned_by":"openai","type":"model","display_name":"GPT Image 1"},{"id":"gpt-image-1.5","object":"model","created":1735689600,"owned_by":"openai","type":"model","display_name":"GPT Image 1.5"},{"id":"gpt-image-2","object":"model","created":1738368000,"owned_by":"openai","type":"model","display_name":"GPT Image 2"}],"object":"list"} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260518_redeploy_matrix/08-subscription-group-state.json b/artifacts/real-host-acceptance/20260518_redeploy_matrix/08-subscription-group-state.json new file mode 100644 index 00000000..af9a65de --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_matrix/08-subscription-group-state.json @@ -0,0 +1,211 @@ +{ + "group_id": 4, + "group": { + "code": 0, + "message": "success", + "data": { + "id": 4, + "name": "Hermes Subscription Group", + "description": "for subscription validation", + "platform": "openai", + "rate_multiplier": 1, + "is_exclusive": false, + "status": "active", + "subscription_type": "subscription", + "daily_limit_usd": null, + "weekly_limit_usd": null, + "monthly_limit_usd": null, + "allow_image_generation": false, + "image_rate_independent": false, + "image_rate_multiplier": 1, + "image_price_1k": null, + "image_price_2k": null, + "image_price_4k": null, + "claude_code_only": false, + "fallback_group_id": null, + "fallback_group_id_on_invalid_request": null, + "allow_messages_dispatch": false, + "require_oauth_only": false, + "require_privacy_set": false, + "rpm_limit": 0, + "created_at": "2026-05-18T15:52:56.759077133+08:00", + "updated_at": "2026-05-18T15:52:56.759077203+08:00", + "model_routing": null, + "model_routing_enabled": false, + "mcp_xml_inject": true, + "default_mapped_model": "", + "messages_dispatch_model_config": {}, + "supported_model_scopes": null, + "account_count": 1, + "sort_order": 0 + } + }, + "subscription": { + "code": 0, + "message": "success", + "data": { + "id": 1, + "user_id": 4, + "group_id": 4, + "starts_at": "2026-05-18T15:52:56.770332+08:00", + "expires_at": "2026-06-17T15:52:56.770332+08:00", + "status": "active", + "daily_window_start": null, + "weekly_window_start": null, + "monthly_window_start": null, + "daily_usage_usd": 0, + "weekly_usage_usd": 0, + "monthly_usage_usd": 0, + "created_at": "2026-05-18T15:52:56.770347+08:00", + "updated_at": "2026-05-18T15:52:56.770347+08:00", + "user": { + "id": 4, + "email": "relay-sub-090176@sub2api.local", + "username": "relay-sub-090176", + "role": "user", + "balance": 0, + "concurrency": 0, + "status": "active", + "allowed_groups": null, + "last_active_at": "2026-05-18T15:42:56.44723+08:00", + "created_at": "2026-05-18T15:42:56.384972+08:00", + "updated_at": "2026-05-18T15:42:56.447232+08:00", + "balance_notify_enabled": true, + "balance_notify_threshold_type": "fixed", + "balance_notify_threshold": null, + "balance_notify_extra_emails": null, + "total_recharged": 0, + "rpm_limit": 0 + }, + "group": { + "id": 4, + "name": "Hermes Subscription Group", + "description": "for subscription validation", + "platform": "openai", + "rate_multiplier": 1, + "is_exclusive": false, + "status": "active", + "subscription_type": "subscription", + "daily_limit_usd": null, + "weekly_limit_usd": null, + "monthly_limit_usd": null, + "allow_image_generation": false, + "image_rate_independent": false, + "image_rate_multiplier": 1, + "image_price_1k": null, + "image_price_2k": null, + "image_price_4k": null, + "claude_code_only": false, + "fallback_group_id": null, + "fallback_group_id_on_invalid_request": null, + "allow_messages_dispatch": false, + "require_oauth_only": false, + "require_privacy_set": false, + "rpm_limit": 0, + "created_at": "2026-05-18T15:52:56.759077+08:00", + "updated_at": "2026-05-18T15:52:56.759077+08:00" + }, + "assigned_by": 1, + "assigned_at": "2026-05-18T15:52:56.770332+08:00", + "notes": "hermes subscription validation", + "assigned_by_user": { + "id": 1, + "email": "admin@sub2api.local", + "username": "", + "role": "admin", + "balance": 0, + "concurrency": 5, + "status": "active", + "allowed_groups": null, + "last_active_at": "2026-05-18T15:42:56.225655+08:00", + "created_at": "2026-05-18T15:38:22.02418+08:00", + "updated_at": "2026-05-18T15:42:56.225658+08:00", + "balance_notify_enabled": true, + "balance_notify_threshold_type": "fixed", + "balance_notify_threshold": null, + "balance_notify_extra_emails": null, + "total_recharged": 0, + "rpm_limit": 0 + } + } + }, + "key": { + "code": 0, + "message": "success", + "data": { + "api_key": { + "id": 2, + "user_id": 4, + "key": "sk-48539001307386d65fb3c5a110a38014b34beaa3042fb4a137f311499d8360eb", + "name": "relay-sub-090176-key", + "group_id": 4, + "status": "active", + "ip_whitelist": null, + "ip_blacklist": null, + "last_used_at": null, + "quota": 0, + "quota_used": 0, + "expires_at": null, + "created_at": "2026-05-18T15:42:56.451409+08:00", + "updated_at": "2026-05-18T15:52:56.778778253+08:00", + "rate_limit_5h": 0, + "rate_limit_1d": 0, + "rate_limit_7d": 0, + "usage_5h": 0, + "usage_1d": 0, + "usage_7d": 0, + "window_5h_start": null, + "window_1d_start": null, + "window_7d_start": null, + "user": { + "id": 4, + "email": "relay-sub-090176@sub2api.local", + "username": "relay-sub-090176", + "role": "user", + "balance": 0, + "concurrency": 0, + "status": "active", + "allowed_groups": null, + "last_active_at": "2026-05-18T15:42:56.44723+08:00", + "created_at": "2026-05-18T15:42:56.384972+08:00", + "updated_at": "2026-05-18T15:42:56.447232+08:00", + "balance_notify_enabled": true, + "balance_notify_threshold_type": "fixed", + "balance_notify_threshold": null, + "balance_notify_extra_emails": null, + "total_recharged": 0, + "rpm_limit": 0 + }, + "group": { + "id": 4, + "name": "Hermes Subscription Group", + "description": "for subscription validation", + "platform": "openai", + "rate_multiplier": 1, + "is_exclusive": false, + "status": "active", + "subscription_type": "subscription", + "daily_limit_usd": null, + "weekly_limit_usd": null, + "monthly_limit_usd": null, + "allow_image_generation": false, + "image_rate_independent": false, + "image_rate_multiplier": 1, + "image_price_1k": null, + "image_price_2k": null, + "image_price_4k": null, + "claude_code_only": false, + "fallback_group_id": null, + "fallback_group_id_on_invalid_request": null, + "allow_messages_dispatch": false, + "require_oauth_only": false, + "require_privacy_set": false, + "rpm_limit": 0, + "created_at": "2026-05-18T15:52:56.759077+08:00", + "updated_at": "2026-05-18T15:52:56.759077+08:00" + } + }, + "auto_granted_group_access": false + } + } +} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260518_redeploy_matrix/09-user-summary.json b/artifacts/real-host-acceptance/20260518_redeploy_matrix/09-user-summary.json new file mode 100644 index 00000000..e59900eb --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_matrix/09-user-summary.json @@ -0,0 +1,22 @@ +{ + "base_url": "http://127.0.0.1:18087", + "users": [ + { + "email": "relay-self-090176@sub2api.local", + "password": "ljJEnynuMX18S81N", + "user_id": 3, + "api_key_id": 1, + "api_key": "sk-deb101e5ea7bd7ab579d2fdf2e90f7b5647c4ebc93722be3cf9f41ad68bd5f4b", + "username": "relay-self-090176" + }, + { + "email": "relay-sub-090176@sub2api.local", + "password": "6OkqspHG-KsFvRtx", + "user_id": 4, + "api_key_id": 2, + "api_key": "sk-48539001307386d65fb3c5a110a38014b34beaa3042fb4a137f311499d8360eb", + "username": "relay-sub-090176" + } + ], + "admin_email": "admin@sub2api.local" +} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260518_redeploy_matrix/README.md b/artifacts/real-host-acceptance/20260518_redeploy_matrix/README.md new file mode 100644 index 00000000..25aec1fd --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_matrix/README.md @@ -0,0 +1,31 @@ +# 2026-05-18 redeploy host validation + +Host redeploy +- Host root: /tmp/sub2api-host-validation-redeploy +- Secure credentials file: /tmp/sub2api-host-validation-redeploy/credentials.env +- Secure init note: /tmp/sub2api-host-validation-redeploy/INIT.md +- App URL: http://127.0.0.1:18087 +- CRM validation server URLs: http://127.0.0.1:18088 and http://127.0.0.1:18089 + +Initialization facts +- Admin email is fixed as admin@sub2api.local +- This redeploy used an explicit ADMIN_PASSWORD (stored only in credentials.env), not auto-generated-once logging +- Fresh host init did not create normal users; Hermes created dedicated relay-self / relay-sub ordinary users after admin login + +Verification matrix +1. Fresh self_service import with ordinary user key still failed initially + - Evidence: 01-self-import-initial.json / 02-self-access-preview-initial.json / 03-self-access-status-initial.json + - Result: /v1/models returned 403 while the key had no group binding and the user had zero balance +2. After binding the ordinary self key to the imported standard group and setting user balance=10 + - Evidence: 04-self-after-balance.headers.txt / 05-self-after-balance.body.json + - Result: /v1/models returned 200 +3. For subscription validation, Hermes created a dedicated subscription-type group by copying accounts from the imported openai group, assigned a subscription to the ordinary relay-sub user, then bound the relay-sub key to that group + - Evidence: 08-subscription-group-state.json + - Result: /v1/models returned 200 with zero user balance + - Evidence: 06-subscription-after-assign.headers.txt / 07-subscription-after-assign.body.json + +Conclusion +- Fresh host initialization alone is insufficient; ordinary users and their keys must be created explicitly +- Probe success depends on the full tuple: ordinary user + key/group binding + a valid billing path +- For standard/self_service groups, a funded user balance was required in this redeploy +- For subscription groups, an active user subscription plus key/group binding was sufficient in this redeploy diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service/01-create-host.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service/01-create-host.json new file mode 100644 index 00000000..8d5ac41a --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"sub2api-redeploy-self","base_url":"http://127.0.0.1:18087","host_version":"0.1.126","auth_type":"bearer","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service/02-probe-host.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service/02-probe-host.json new file mode 100644 index 00000000..36c51626 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"sub2api-redeploy-self","base_url":"http://127.0.0.1:18087","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service/03-install-pack.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service/03-install-pack.json new file mode 100644 index 00000000..2c86e58d --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"},{"display_name":"OpenAI 中转兼容","provider_id":"openai-zhongzhuan"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service/04-preview-import.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service/04-preview-import.json new file mode 100644 index 00000000..5610c723 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"OpenAI 中转默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"OpenAI 中转默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"OpenAI 中转默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"OpenAI 中转默认分组","Channel":"OpenAI 中转默认渠道","Plan":"OpenAI 中转默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service/05-import.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service/05-import.json new file mode 100644 index 00000000..3d65dd17 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":1,"batch_status":"partially_succeeded","channel":{"id":"1","name":"OpenAI 中转默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"2","name":"OpenAI 中转默认分组"},"plan":null,"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service/06-access-preview.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service/06-access-preview.json new file mode 100644 index 00000000..84826377 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"openai-zhongzhuan","mode":"self_service","available":false,"message":"access status broken does not satisfy mode self_service"} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service/07-access-status.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service/07-access-status.json new file mode 100644 index 00000000..a43f9a29 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":1,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":1,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan"} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service/08-provider-status.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service/08-provider-status.json new file mode 100644 index 00000000..fd882068 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18087","host_id":"sub2api-redeploy-self","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":3,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"OpenAI 中转兼容","platform":"openai","provider_id":"openai-zhongzhuan"},"provider_status":"partially_succeeded","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service/09-reconcile.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service/09-reconcile.json new file mode 100644 index 00000000..cf0500f4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":0,"missing_count":0,"provider_id":"openai-zhongzhuan","status":"degraded","summary":{"access_rechecked":true,"access_status":"broken","extra_count":0,"host_version":"0.1.126","missing_count":0,"probe_failures":0}} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service/10-batch-detail.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service/10-batch-detail.json new file mode 100644 index 00000000..30700547 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}"},{"ID":2,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"reconcile_rerun\":true,\"status_code\":403}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":1,"id":1,"mode":"partial","pack_id":1,"provider_id":3},"items":[{"account_status":"passed","batch_id":1,"id":1,"key_fingerprint":"sha256:fbd0fe64bde9bf5e4fbc1b648540139ae34473dbdd07905a72b1e90970bddce5","probe_summary_json":"{\"account_id\":\"1\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"reconcile_rerun\":true,\"smoke_model_seen\":true}"}],"items_count":1,"managed_count":3,"managed_resources":[{"ID":1,"BatchID":1,"HostID":1,"ResourceType":"group","HostResourceID":"2","ResourceName":"OpenAI 中转默认分组"},{"ID":2,"BatchID":1,"HostID":1,"ResourceType":"channel","HostResourceID":"1","ResourceName":"OpenAI 中转默认渠道"},{"ID":3,"BatchID":1,"HostID":1,"ResourceType":"account","HostResourceID":"1","ResourceName":"openai-zhongzhuan-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"ProviderID":3,"Status":"degraded","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":0,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":0}"}]} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service/11-rollback.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service/11-rollback.json new file mode 100644 index 00000000..280e36f9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":1,"deleted_accounts":1,"deleted_channels":1,"deleted_groups":1,"deleted_plans":0} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/01-create-host.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/01-create-host.json new file mode 100644 index 00000000..ea9d4de9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"sub2api-redeploy-self-keep","base_url":"http://127.0.0.1:18087","host_version":"0.1.126","auth_type":"bearer","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/02-probe-host.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/02-probe-host.json new file mode 100644 index 00000000..36984319 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"sub2api-redeploy-self-keep","base_url":"http://127.0.0.1:18087","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/03-install-pack.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/03-install-pack.json new file mode 100644 index 00000000..2c86e58d --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"},{"display_name":"OpenAI 中转兼容","provider_id":"openai-zhongzhuan"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/04-preview-import.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/04-preview-import.json new file mode 100644 index 00000000..5610c723 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"OpenAI 中转默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"OpenAI 中转默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"OpenAI 中转默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"OpenAI 中转默认分组","Channel":"OpenAI 中转默认渠道","Plan":"OpenAI 中转默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/05-import.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/05-import.json new file mode 100644 index 00000000..b11bc306 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":1,"batch_status":"partially_succeeded","channel":{"id":"2","name":"OpenAI 中转默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"3","name":"OpenAI 中转默认分组"},"plan":null,"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/06-access-preview.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/06-access-preview.json new file mode 100644 index 00000000..84826377 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"openai-zhongzhuan","mode":"self_service","available":false,"message":"access status broken does not satisfy mode self_service"} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/07-access-status.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/07-access-status.json new file mode 100644 index 00000000..a43f9a29 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":1,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":1,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan"} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/08-provider-status.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/08-provider-status.json new file mode 100644 index 00000000..0f059693 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18087","host_id":"sub2api-redeploy-self-keep","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":3,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"OpenAI 中转兼容","platform":"openai","provider_id":"openai-zhongzhuan"},"provider_status":"partially_succeeded","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/09-reconcile.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/09-reconcile.json new file mode 100644 index 00000000..cf0500f4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":0,"missing_count":0,"provider_id":"openai-zhongzhuan","status":"degraded","summary":{"access_rechecked":true,"access_status":"broken","extra_count":0,"host_version":"0.1.126","missing_count":0,"probe_failures":0}} diff --git a/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/10-batch-detail.json b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/10-batch-detail.json new file mode 100644 index 00000000..cec77a33 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_redeploy_self_service_keep/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}"},{"ID":2,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"reconcile_rerun\":true,\"status_code\":403}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":1,"id":1,"mode":"partial","pack_id":1,"provider_id":3},"items":[{"account_status":"passed","batch_id":1,"id":1,"key_fingerprint":"sha256:fbd0fe64bde9bf5e4fbc1b648540139ae34473dbdd07905a72b1e90970bddce5","probe_summary_json":"{\"account_id\":\"2\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"reconcile_rerun\":true,\"smoke_model_seen\":true}"}],"items_count":1,"managed_count":3,"managed_resources":[{"ID":1,"BatchID":1,"HostID":1,"ResourceType":"group","HostResourceID":"3","ResourceName":"OpenAI 中转默认分组"},{"ID":2,"BatchID":1,"HostID":1,"ResourceType":"channel","HostResourceID":"2","ResourceName":"OpenAI 中转默认渠道"},{"ID":3,"BatchID":1,"HostID":1,"ResourceType":"account","HostResourceID":"2","ResourceName":"openai-zhongzhuan-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"ProviderID":3,"Status":"degraded","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":0,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":0}"}]} diff --git a/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/01-create-host.json b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/01-create-host.json new file mode 100644 index 00000000..895d2744 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"sub2api-val-openai","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/02-probe-host.json b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/02-probe-host.json new file mode 100644 index 00000000..895d2744 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"sub2api-val-openai","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/03-install-pack.json b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/03-install-pack.json new file mode 100644 index 00000000..2c86e58d --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"},{"display_name":"OpenAI 中转兼容","provider_id":"openai-zhongzhuan"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/04-preview-import.json b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/04-preview-import.json new file mode 100644 index 00000000..5610c723 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"OpenAI 中转默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"OpenAI 中转默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"OpenAI 中转默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"OpenAI 中转默认分组","Channel":"OpenAI 中转默认渠道","Plan":"OpenAI 中转默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/05-import.json b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/05-import.json new file mode 100644 index 00000000..839d1347 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":1,"batch_status":"partially_succeeded","channel":{"id":"14","name":"OpenAI 中转默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"22","name":"OpenAI 中转默认分组"},"plan":null,"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/06-access-preview.json b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/06-access-preview.json new file mode 100644 index 00000000..84826377 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"openai-zhongzhuan","mode":"self_service","available":false,"message":"access status broken does not satisfy mode self_service"} diff --git a/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/07-access-status.json b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/07-access-status.json new file mode 100644 index 00000000..a43f9a29 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":1,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":1,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan"} diff --git a/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/08-provider-status.json b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/08-provider-status.json new file mode 100644 index 00000000..9c8674a8 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":3,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"OpenAI 中转兼容","platform":"openai","provider_id":"openai-zhongzhuan"},"provider_status":"partially_succeeded","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/09-reconcile.json b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/09-reconcile.json new file mode 100644 index 00000000..cf0500f4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":0,"missing_count":0,"provider_id":"openai-zhongzhuan","status":"degraded","summary":{"access_rechecked":true,"access_status":"broken","extra_count":0,"host_version":"0.1.126","missing_count":0,"probe_failures":0}} diff --git a/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/10-batch-detail.json b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/10-batch-detail.json new file mode 100644 index 00000000..fb7a6710 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}"},{"ID":2,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"reconcile_rerun\":true,\"status_code\":403}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":2,"id":1,"mode":"partial","pack_id":1,"provider_id":3},"items":[{"account_status":"passed","batch_id":1,"id":1,"key_fingerprint":"sha256:fbd0fe64bde9bf5e4fbc1b648540139ae34473dbdd07905a72b1e90970bddce5","probe_summary_json":"{\"account_id\":\"24\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"reconcile_rerun\":true,\"smoke_model_seen\":true}"}],"items_count":1,"managed_count":3,"managed_resources":[{"ID":1,"BatchID":1,"ResourceType":"group","HostResourceID":"22","ResourceName":"OpenAI 中转默认分组"},{"ID":2,"BatchID":1,"ResourceType":"channel","HostResourceID":"14","ResourceName":"OpenAI 中转默认渠道"},{"ID":3,"BatchID":1,"ResourceType":"account","HostResourceID":"24","ResourceName":"openai-zhongzhuan-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"ProviderID":3,"Status":"degraded","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":0,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":0}"}]} diff --git a/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/11-rollback.json b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/11-rollback.json new file mode 100644 index 00000000..280e36f9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":1,"deleted_accounts":1,"deleted_channels":1,"deleted_groups":1,"deleted_plans":0} diff --git a/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/01-create-host.json b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/01-create-host.json new file mode 100644 index 00000000..3a26af4f --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"sub2api-val-openai-subscription","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/02-probe-host.json b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/02-probe-host.json new file mode 100644 index 00000000..3a26af4f --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"sub2api-val-openai-subscription","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/03-install-pack.json b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/03-install-pack.json new file mode 100644 index 00000000..5df521c5 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":true,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"},{"display_name":"OpenAI 中转兼容","provider_id":"openai-zhongzhuan"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/04-preview-import.json b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/04-preview-import.json new file mode 100644 index 00000000..5610c723 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"OpenAI 中转默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"OpenAI 中转默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"OpenAI 中转默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"OpenAI 中转默认分组","Channel":"OpenAI 中转默认渠道","Plan":"OpenAI 中转默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/05-import.json b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/05-import.json new file mode 100644 index 00000000..e49f62bd --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":2,"batch_status":"partially_succeeded","channel":{"id":"15","name":"OpenAI 中转默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"23","name":"OpenAI 中转默认分组"},"plan":{"id":"5","name":"OpenAI 中转默认套餐"},"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/06-access-preview.json b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/06-access-preview.json new file mode 100644 index 00000000..a47a865b --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"openai-zhongzhuan","mode":"subscription","available":false,"message":"access status broken does not satisfy mode subscription"} diff --git a/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/07-access-status.json b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/07-access-status.json new file mode 100644 index 00000000..8968e4f8 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":2,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"subscription","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":3,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"openai-zhongzhuan"} diff --git a/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/08-provider-status.json b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/08-provider-status.json new file mode 100644 index 00000000..03f28a15 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":2,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"degraded","latest_reconcile_summary":{"access_rechecked":true,"access_status":"broken","extra_count":0,"host_version":"0.1.126","missing_count":0,"probe_failures":0},"managed_resources_count":7,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"OpenAI 中转兼容","platform":"openai","provider_id":"openai-zhongzhuan"},"provider_status":"degraded","reconcile_runs_count":1} diff --git a/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/09-reconcile.json b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/09-reconcile.json new file mode 100644 index 00000000..09786f76 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":2,"extra_count":0,"missing_count":3,"provider_id":"openai-zhongzhuan","status":"drifted","summary":{"access_rechecked":true,"access_status":"broken","extra_count":0,"host_version":"0.1.126","missing_count":3,"probe_failures":0}} diff --git a/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/10-batch-detail.json b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/10-batch-detail.json new file mode 100644 index 00000000..35727fb8 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":3,"BatchID":2,"ClosureType":"subscription","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}"},{"ID":4,"BatchID":2,"ClosureType":"subscription","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"reconcile_rerun\":true,\"status_code\":403}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":2,"id":2,"mode":"partial","pack_id":1,"provider_id":3},"items":[{"account_status":"passed","batch_id":2,"id":2,"key_fingerprint":"sha256:fbd0fe64bde9bf5e4fbc1b648540139ae34473dbdd07905a72b1e90970bddce5","probe_summary_json":"{\"account_id\":\"25\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"reconcile_rerun\":true,\"smoke_model_seen\":true}"}],"items_count":1,"managed_count":4,"managed_resources":[{"ID":4,"BatchID":2,"ResourceType":"group","HostResourceID":"23","ResourceName":"OpenAI 中转默认分组"},{"ID":5,"BatchID":2,"ResourceType":"channel","HostResourceID":"15","ResourceName":"OpenAI 中转默认渠道"},{"ID":6,"BatchID":2,"ResourceType":"plan","HostResourceID":"5","ResourceName":"OpenAI 中转默认套餐"},{"ID":7,"BatchID":2,"ResourceType":"account","HostResourceID":"25","ResourceName":"openai-zhongzhuan-01"}],"reconcile_count":2,"reconcile_runs":[{"ID":2,"ProviderID":3,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":0,\"host_version\":\"0.1.126\",\"missing_count\":3,\"probe_failures\":0}"},{"ID":1,"ProviderID":3,"Status":"degraded","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":0,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":0}"}]} diff --git a/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/11-rollback.json b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/11-rollback.json new file mode 100644 index 00000000..70b32076 --- /dev/null +++ b/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":2,"deleted_accounts":1,"deleted_channels":1,"deleted_groups":1,"deleted_plans":1} diff --git a/artifacts/real-host-acceptance/auto_4431135c_openai_subscription/01-create-host.json b/artifacts/real-host-acceptance/auto_4431135c_openai_subscription/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/auto_4431135c_openai_subscription/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/auto_4431135c_openai_subscription/02-probe-host.json b/artifacts/real-host-acceptance/auto_4431135c_openai_subscription/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/auto_4431135c_openai_subscription/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/01-create-host.json b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/01-create-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/02-probe-host.json b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/02-probe-host.json new file mode 100644 index 00000000..c5e7b007 --- /dev/null +++ b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"local-sub2api","base_url":"http://127.0.0.1:18080","host_version":"0.1.126","status":"supported","capabilities":{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}} diff --git a/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/03-install-pack.json b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/03-install-pack.json new file mode 100644 index 00000000..4ef24ce3 --- /dev/null +++ b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":false,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"}],"version":"1.0.0"} diff --git a/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/04-preview-import.json b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/04-preview-import.json new file mode 100644 index 00000000..9a295905 --- /dev/null +++ b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"create","Suggested":"MiniMax 默认渠道","ExistingID":"","Reason":""},"group":{"Action":"create","Suggested":"MiniMax 默认分组","ExistingID":"","Reason":""},"plan":{"Action":"create","Suggested":"MiniMax 默认套餐","ExistingID":"","Reason":""}},"names":{"Group":"MiniMax 默认分组","Channel":"MiniMax 默认渠道","Plan":"MiniMax 默认套餐"}} diff --git a/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/05-import.json b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/05-import.json new file mode 100644 index 00000000..621b02cc --- /dev/null +++ b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"broken","accounts_count":1,"batch_id":1,"batch_status":"partially_succeeded","channel":{"id":"7","name":"MiniMax 默认渠道"},"gateway":{"ok":false,"status_code":403,"models":null,"has_expected_model":false},"group":{"id":"9","name":"MiniMax 默认分组"},"plan":null,"provider_status":"degraded"} diff --git a/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/06-access-preview.json b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/06-access-preview.json new file mode 100644 index 00000000..4a0e6cd6 --- /dev/null +++ b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"minimax","mode":"self_service","available":false,"message":"access status broken does not satisfy mode self_service"} diff --git a/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/07-access-status.json b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/07-access-status.json new file mode 100644 index 00000000..055b23da --- /dev/null +++ b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"broken","batch_id":1,"closures_count":1,"latest_access_status":"broken","latest_closure":{"closure_type":"self_service","details_json":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}","id":1,"status":"broken"},"pack_id":"openai-cn-pack","provider_id":"minimax"} diff --git a/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/08-provider-status.json b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/08-provider-status.json new file mode 100644 index 00000000..fd3f8760 --- /dev/null +++ b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"broken","batch_status":"partially_succeeded","id":1,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18080","host_id":"http://127.0.0.1:18080","host_version":"0.1.126"},"latest_access_status":"broken","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":3,"pack":{"pack_id":"openai-cn-pack","version":"1.0.0"},"provider":{"display_name":"MiniMax OpenAI Compatible","platform":"openai","provider_id":"minimax"},"provider_status":"partially_succeeded","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/09-reconcile.json b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/09-reconcile.json new file mode 100644 index 00000000..4218afe8 --- /dev/null +++ b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":1,"extra_count":6,"missing_count":0,"provider_id":"minimax","status":"drifted","summary":{"access_rechecked":true,"access_status":"broken","extra_count":6,"host_version":"0.1.126","missing_count":0,"probe_failures":1}} diff --git a/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/10-batch-detail.json b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/10-batch-detail.json new file mode 100644 index 00000000..5a67d3bf --- /dev/null +++ b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":1,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"status_code\":403}"},{"ID":2,"BatchID":1,"ClosureType":"self_service","Status":"broken","DetailsJSON":"{\"has_expected_model\":false,\"models\":null,\"ok\":false,\"reconcile_rerun\":true,\"status_code\":403}"}],"access_count":2,"batch":{"access_status":"broken","batch_status":"partially_succeeded","host_id":2,"id":1,"mode":"partial","pack_id":1,"provider_id":2},"items":[{"account_status":"failed","batch_id":1,"id":1,"key_fingerprint":"sha256:e596db01acd8bded5707675c39116a9d1291ad8ea197e5a2090d68f378944316","probe_summary_json":"{\"account_id\":\"12\",\"models\":[{\"id\":\"gpt-5.5\",\"display_name\":\"GPT-5.5\",\"type\":\"model\"},{\"id\":\"gpt-5.4\",\"display_name\":\"GPT-5.4\",\"type\":\"model\"},{\"id\":\"gpt-5.4-mini\",\"display_name\":\"GPT-5.4 Mini\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex\",\"display_name\":\"GPT-5.3 Codex\",\"type\":\"model\"},{\"id\":\"gpt-5.3-codex-spark\",\"display_name\":\"GPT-5.3 Codex Spark\",\"type\":\"model\"},{\"id\":\"gpt-5.2\",\"display_name\":\"GPT-5.2\",\"type\":\"model\"},{\"id\":\"gpt-image-1\",\"display_name\":\"GPT Image 1\",\"type\":\"model\"},{\"id\":\"gpt-image-1.5\",\"display_name\":\"GPT Image 1.5\",\"type\":\"model\"},{\"id\":\"gpt-image-2\",\"display_name\":\"GPT Image 2\",\"type\":\"model\"}],\"probe_message\":\"\",\"probe_ok\":false,\"probe_status\":\"failed\",\"reconcile_rerun\":true,\"smoke_model_seen\":false}"}],"items_count":1,"managed_count":3,"managed_resources":[{"ID":1,"BatchID":1,"ResourceType":"group","HostResourceID":"9","ResourceName":"MiniMax 默认分组"},{"ID":2,"BatchID":1,"ResourceType":"channel","HostResourceID":"7","ResourceName":"MiniMax 默认渠道"},{"ID":3,"BatchID":1,"ResourceType":"account","HostResourceID":"12","ResourceName":"minimax-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":1,"ProviderID":2,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"broken\",\"extra_count\":6,\"host_version\":\"0.1.126\",\"missing_count\":0,\"probe_failures\":1}"}]} diff --git a/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/11-rollback.json b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/11-rollback.json new file mode 100644 index 00000000..280e36f9 --- /dev/null +++ b/artifacts/real-host-acceptance/auto_a5f5185f_minimax_self_service/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":1,"deleted_accounts":1,"deleted_channels":1,"deleted_groups":1,"deleted_plans":0} diff --git a/docs/2026-05-18-PRODUCTION_READINESS_REVIEW.md b/docs/2026-05-18-PRODUCTION_READINESS_REVIEW.md new file mode 100644 index 00000000..0ae6453e --- /dev/null +++ b/docs/2026-05-18-PRODUCTION_READINESS_REVIEW.md @@ -0,0 +1,407 @@ +# Sub2API CN Relay Manager 上线审查报告 + +日期:2026-05-18 +审查范围:`/home/long/project/sub2api-cn-relay-manager` +审查目标:评估当前实现是否与规划设计对齐,是否达到生产上线要求,并明确阻塞项、非阻塞项和建议整改路径。 + +> 状态更新(2026-05-18 晚些时候):本报告识别出的 4 个系统性阻塞项已进入代码修复并已落地到当前分支;对应执行任务见 `docs/2026-05-18-PRODUCTION_REMEDIATION_TASK_BOARD.md`。本报告保留为“发现问题时的审查快照”,最新门禁结论以整改板与执行板为准。 +> +> 再次状态更新(2026-05-18 最新真实宿主复验后):已在最新代码上重新生成两套真实宿主 artifact: +> - `artifacts/real-host-acceptance/20260518_self_service_reaccept_v6` +> - `artifacts/real-host-acceptance/20260518_subscription_reaccept_v6` +> +> 结果:两条链路都未形成最终通过 artifact,因此当前仍不能把项目从 `CONDITIONAL_APPROVED` 推进到最终放行。 +> +> 最终状态更新(2026-05-18 fresh redeploy 复验后):`artifacts/real-host-acceptance/20260518_redeploy_matrix` 已在全新 redeploy 宿主上确认两条真实普通用户访问链路都可打通: +> - `self_service`:普通用户 key 绑定标准 group 且用户具备可用余额后,`/v1/models -> 200` +> - `subscription`:subscription 类型 group + 普通用户订阅分配 + key/group 绑定后,`/v1/models -> 200` +> +> 进一步状态更新(2026-05-18 reconcile host-scope 复验后):已在最新代码上补充两套 host-scoped acceptance artifact: +> - `artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service` +> - `artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription` +> +> 这两套新 artifact 补齐了 `status / resources / reconcile / batch detail / rollback` 的 host-scoped 证据链,进一步证明 `reconcile_runs` 带上 `host_id + batch_id` 后,batch detail 的 reconcile 视图已不再按 provider 粗暴聚合。 +> +> 因此本报告中的 `REJECT / CONDITIONAL_APPROVED` 结论已成为历史快照;当前最新真相以 `docs/EXECUTION_BOARD.md`、`docs/PRODUCTION_CLOSURE_BOARD.md`、`20260518_redeploy_matrix` 与 `20260518_reconcile_hostscope_*` artifact 为准。 + +## 一、审查结论 + +本节为“首次审查时的历史结论快照”,不再代表当前最新 gate: + +- 当时判断:代码层质量门禁整体通过,但真实宿主最终放行证据不足。 +- 因此当时结论为“代码层 `CONDITIONAL_APPROVED`,真实宿主最终放行未完成”。 + +当前最新上线判定请以: + +- `docs/EXECUTION_BOARD.md` +- `docs/PRODUCTION_CLOSURE_BOARD.md` +- `artifacts/real-host-acceptance/20260518_redeploy_matrix` +- `artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service` +- `artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription` + +为准。 + +## 二、审查方法与证据 + +本次审查结合以下证据来源: + +1. 设计与规划文档对齐 + - [PRD.md](/home/long/project/sub2api-cn-relay-manager/docs/PRD.md:1) + - [TDD_PLAN.md](/home/long/project/sub2api-cn-relay-manager/docs/TDD_PLAN.md:1) + - [implementation-plan.md](/home/long/project/sub2api-cn-relay-manager/docs/plans/2026-05-12-sub2api-cn-relay-manager-implementation-plan.md:1) + - [EXECUTION_BOARD.md](/home/long/project/sub2api-cn-relay-manager/docs/EXECUTION_BOARD.md:1) + - [PRODUCTION_CLOSURE_BOARD.md](/home/long/project/sub2api-cn-relay-manager/docs/PRODUCTION_CLOSURE_BOARD.md:1) + +2. 代码审查重点 + - 控制面 API:[http_api.go](/home/long/project/sub2api-cn-relay-manager/internal/app/http_api.go:1) + - 宿主适配器:[client.go](/home/long/project/sub2api-cn-relay-manager/internal/host/sub2api/client.go:1) + - 能力探测:[capability_probe.go](/home/long/project/sub2api-cn-relay-manager/internal/host/sub2api/capability_probe.go:1) + - 导入运行时:[runtime_import_service.go](/home/long/project/sub2api-cn-relay-manager/internal/provision/runtime_import_service.go:1) + - 回滚:[rollback_service.go](/home/long/project/sub2api-cn-relay-manager/internal/provision/rollback_service.go:1) + - 对账:[batch_detail_and_reconcile_service.go](/home/long/project/sub2api-cn-relay-manager/internal/provision/batch_detail_and_reconcile_service.go:1) + - 状态库:[db.go](/home/long/project/sub2api-cn-relay-manager/internal/store/sqlite/db.go:1) + - 资源记录:[managed_resources_repo.go](/home/long/project/sub2api-cn-relay-manager/internal/store/sqlite/managed_resources_repo.go:1) + +3. 本地质量门禁复核 + - `gofmt -l .`:空输出 + - `go vet ./...`:通过 + - `go test ./...`:通过 + - `go test -race ./...`:通过 + - `go test -cover ./internal/...`:通过 + - `internal/access`:`77.3%` + - `internal/pack`:`72.7%` + - `internal/provision`:`76.9%` + - `internal/store/sqlite`:`68.2%` + +4. 真实宿主 artifact 复核 + - 历史 `self_service` 成功样例(旧证据,现仅作历史对照): + - [05-import.json](/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/05-import.json:1) + - [06-access-preview.json](/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/06-access-preview.json:1) + - [07-access-status.json](/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/07-access-status.json:1) + - 最新 `self_service` 复验(当前真相): + - [05-import.json](/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/05-import.json:1) + - [06-access-preview.json](/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/06-access-preview.json:1) + - [09-reconcile.json](/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260518_self_service_reaccept_v6/09-reconcile.json:1) + - 最新 `subscription` 复验(当前真相): + - [05-import.json](/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/05-import.json:1) + - [06-access-preview.json](/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/06-access-preview.json:1) + - [09-reconcile.json](/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260518_subscription_reaccept_v6/09-reconcile.json:1) + +## 三、设计对齐判断 + +### 已对齐部分 + +- 满足“零宿主代码改动”的核心约束。 + - 当前所有宿主交互都通过 `internal/host/sub2api` 适配层完成,未发现直接写宿主数据库或注入宿主目录的实现。 +- 满足 MVP 的主链路目标。 + - `pack` 装载、provider 解析、导入编排、账号探测、网关 `GET /v1/models` 检查、状态库存证据链都已具备。 +- 控制面 API 基本覆盖当前计划要求。 + - 对照 implementation plan 中列出的当前 API,仓库已实现主要端点。 +- 测试覆盖与门禁实现基本符合仓库自述。 + +### 未完全对齐部分 + +- implementation plan 中的“宿主管理对象”与运行时导入路径没有形成统一身份模型。 +- implementation plan 默认呈现为“可管理宿主对象 + 持久化资源状态”的控制面,但当前状态模型仍偏向“单次导入任务持久化”,不足以支撑稳定的多次运维动作。 +- PRD 把多宿主管理列为首版非目标,但仓库已经暴露 `hosts` 管理 API;这意味着系统外观上已像多宿主管理器,但状态语义并未真正收口。 + +## 四、阻塞项 + +以下问题阻塞“整体生产无条件上线”。 + +### 阻塞项 1:宿主身份模型不统一,`/api/hosts` 与导入/对账/回滚没有形成同一条资产链 + +严重级别:`High` + +证据: + +- `POST /api/hosts` 保存的是用户提供的 `name -> host_id`,[http_api.go](/home/long/project/sub2api-cn-relay-manager/internal/app/http_api.go:53) [http_api.go](/home/long/project/sub2api-cn-relay-manager/internal/app/http_api.go:1077) +- `POST /api/providers/{providerID}/import` 请求体没有 `host_id` 字段,只有 `host_base_url` 和临时认证信息,[http_api.go](/home/long/project/sub2api-cn-relay-manager/internal/app/http_api.go:168) +- `RuntimeImportService.Import()` 在 `HostID` 为空时直接退回到 `HostBaseURL` 作为宿主身份,[runtime_import_service.go](/home/long/project/sub2api-cn-relay-manager/internal/provision/runtime_import_service.go:45) + +影响: + +- 先注册的宿主记录和后续真实导入批次可能落在两条不同的 `hosts` 记录上。 +- `GET /api/hosts/{hostID}`、批次查询、provider 状态、回滚定位不会共享同一条宿主身份链。 +- 这会直接削弱控制面的可运维性和可审计性。 + +结论: + +- 当前宿主对象模型只具备“登记能力”,不具备稳定的“生命周期主键能力”。 + +### 阻塞项 2:`managed_resources` 没有宿主维度,状态库存在跨宿主资源串扰风险 + +严重级别:`High` + +证据: + +- `managed_resources` 的唯一键是 `(resource_type, host_resource_id)`,[0002_operational_runtime.sql](/home/long/project/sub2api-cn-relay-manager/internal/store/migrations/0002_operational_runtime.sql:17) +- repo 的资源身份查询也只按这两个字段判断,[managed_resources_repo.go](/home/long/project/sub2api-cn-relay-manager/internal/store/sqlite/managed_resources_repo.go:53) +- 运行时持久化遇到“资源已存在”就直接跳过,[runtime_import_service.go](/home/long/project/sub2api-cn-relay-manager/internal/provision/runtime_import_service.go:238) + +影响: + +- 两个不同宿主只要资源 ID 恰好相同,就会被控制面当成同一条资源。 +- rollback/reconcile 的依据会被污染。 +- 该风险和宿主 ID 通常为自增整数的现实形态高度相容,不能假设不会发生。 + +结论: + +- 这不是边界体验问题,而是状态建模缺陷。 + +### 阻塞项 3:宿主能力探测存在副作用风险,违背“零侵入宿主”目标的工程保守性 + +严重级别:`High` + +证据: + +- `ProbeCapabilities()` 直接对真实创建接口发空 `POST`: + - `/api/v1/admin/groups` + - `/api/v1/admin/channels` + - `/api/v1/admin/payment/plans` + - `/api/v1/admin/accounts` + - `/api/v1/admin/subscriptions/assign` + - 见 [capability_probe.go](/home/long/project/sub2api-cn-relay-manager/internal/host/sub2api/capability_probe.go:10) + +影响: + +- 该实现假设宿主会把空请求稳定地当作“无副作用校验失败”处理。 +- 一旦宿主版本行为变化、参数默认值变化或某接口宽松接受空载荷,探测可能制造脏资源。 +- 这和 PRD 的“零侵入”承诺不冲突于字面,但明显冲突于生产工程保守性。 + +结论: + +- 在真实生产宿主上,这类探测方式不可视为安全。 + +### 阻塞项 4:`rollback-provider` 仍按同名资源扫描删除,不是按批次记录的真实资源集删除 + +严重级别:`High` + +证据: + +- `rollback-provider` 入口虽然先找到了 pack/provider/latest batch,[http_api.go](/home/long/project/sub2api-cn-relay-manager/internal/app/http_api.go:981) +- 但实际执行仍走 `Rollback(ctx, RollbackRequest{Provider: providerManifest})`,[http_api.go](/home/long/project/sub2api-cn-relay-manager/internal/app/http_api.go:1005) +- `Rollback()` 的实现会按名字重新枚举宿主资源再删除,[rollback_service.go](/home/long/project/sub2api-cn-relay-manager/internal/provision/rollback_service.go:40) +- 更安全的 `RollbackStoredResources()` 已存在,但未被该路径采用,[rollback_service.go](/home/long/project/sub2api-cn-relay-manager/internal/provision/rollback_service.go:58) + +影响: + +- 在脏现场、残留现场、同名 provider 现场,会有误删风险。 +- 当前真实 artifact 已经反复体现 reconcile 仍可能看到 `extra_count`,此时按名字删尤其不稳。 + +结论: + +- 当前回滚策略还不满足“生产可放心执行”的标准。 + +## 五、非阻塞项 + +以下问题不一定阻塞受限范围上线,但会显著影响运维成熟度与文档可信度。 + +### 非阻塞项 1:部署文档承诺高于实际实现 + +证据: + +- `DEPLOYMENT.md` 把 `/metrics`、限流、监控接入写进了生产清单,[DEPLOYMENT.md](/home/long/project/sub2api-cn-relay-manager/docs/DEPLOYMENT.md:90) +- 实际路由只看到 `/healthz` 和控制面 API,[http_api.go](/home/long/project/sub2api-cn-relay-manager/internal/app/http_api.go:193) + +影响: + +- 运维人员容易误以为仓库已内置观测与生产保护机制。 +- 文档可信度低于代码可信度。 + +结论: + +- 应补功能,或先下调文档承诺。 + +### 非阻塞项 2:计划结构与物理目录仍有明显漂移 + +证据: + +- implementation plan 里期望的 `internal/reconcile/*`、`access/planner.go`、`worker/scheduler.go` 等结构仍未落地,[implementation-plan.md](/home/long/project/sub2api-cn-relay-manager/docs/plans/2026-05-12-sub2api-cn-relay-manager-implementation-plan.md:69) +- 当前逻辑主要仍集中在 `internal/provision/*` 与 `internal/access/closure.go`。 + +影响: + +- 目前更像可运行 MVP,而不是已经完成结构收敛的生产后端。 +- 长期维护和职责边界清晰度会受影响。 + +结论: + +- 属于已知结构债务,不阻塞受限范围上线,但不应被忽略。 + +### 非阻塞项 3:`subscription` 模式缺少现成通过的真实闭环 artifact + +证据: + +- 当前看到的 `subscription` 真实宿主样例仍是: + - `batch_status=partially_succeeded` + - `provider_status=degraded` + - `access_status=broken` + - 见 [05-import.json](/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/05-import.json:1) +- 访问预检也仍显示不可用,[06-access-preview.json](/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260517_104007_subscription_after_fix/06-access-preview.json:1) + +影响: + +- `subscription` 不能被纳入当前上线放行范围。 + +结论: + +- 该项虽已在执行板中被承认为剩余风险,但从上线审查角度仍需明确隔离。 + +### 非阻塞项 4:`reconcile` 结果在真实宿主上仍有漂移 + +证据: + +- `self_service` 成功样例中,`09-reconcile.json` 仍是 `status=drifted` 且 `extra_count=11`,[09-reconcile.json](/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260517_openai_platform_fix_retest/09-reconcile.json:1) + +影响: + +- 说明系统主链路成功并不等于现场状态完全收敛。 +- 当前更适合“可上线但需现场治理”,不适合“上线即稳定自治”。 + +结论: + +- 该问题单独不阻塞 `self_service` 条件性放行,但阻塞“成熟运维能力”结论。 + +## 六、建议整改 PR 列表 + +以下 PR 列表按优先级排序。 + +### PR-1:统一宿主身份模型,导入/对账/回滚全面切换到 `host_id` + +目标: + +- 把宿主从“可登记对象”升级为“真实生命周期主对象”。 + +建议内容: + +- 为导入、reconcile、rollback、assign-subscriptions 等请求增加显式 `host_id` 输入。 +- 通过 `host_id` 查宿主记录并派生 `base_url`、认证策略。 +- 禁止运行时再用 `HostBaseURL` 作为默认宿主主键。 +- 梳理 `hosts`、`import_batches`、provider 状态接口的主键一致性。 + +预期收益: + +- 彻底消除宿主对象分裂。 +- 为后续多宿主治理和审计打基础。 + +### PR-2:为 `managed_resources` 增加宿主维度并修复唯一约束 + +目标: + +- 把资源身份从“宿主外的资源 ID”升级成“宿主内资源身份”。 + +建议内容: + +- 在 `managed_resources` 中增加 `host_id` 或等价外键。 +- 唯一约束改为 `host_id + resource_type + host_resource_id`。 +- `GetByResourceIdentity()`、`ListByProviderID()`、持久化逻辑同步改造。 +- 增加迁移和回归测试,覆盖“两个宿主同 ID 资源不串扰”场景。 + +预期收益: + +- 消除跨宿主资源串写与误跳过问题。 + +### PR-3:重写 `ProbeCapabilities`,改成无副作用探测 + +目标: + +- 让能力探测满足生产保守性要求。 + +建议内容: + +- 优先使用只读接口、标准版本接口或显式 capability endpoint。 +- 无只读接口时,退化为“版本白名单 + 已知能力矩阵”。 +- 至少不要再通过真实创建接口发空 `POST`。 +- 给真实宿主兼容矩阵补测试与文档。 + +预期收益: + +- 避免宿主现场因 probe 被污染。 + +### PR-4:让 `rollback-provider` 走批次资源集回滚,不再按名字重新扫描 + +目标: + +- 让回滚动作真正基于控制面状态库,而不是宿主现场猜测。 + +建议内容: + +- `rollback-provider` 先定位 latest batch,再读取该 batch 的 managed resources。 +- 统一调用 `RollbackStoredResources()`。 +- 名字扫描保留为显式“人工应急模式”,不要做默认路径。 +- 增加“存在同名残留资源时不误删”的测试。 + +预期收益: + +- 明显降低误删风险。 + +### PR-5:补齐最小生产运维能力,或下调部署文档口径 + +目标: + +- 让文档承诺和代码现实一致。 + +建议内容: + +- 二选一: + - 实装 `/metrics`、基础限流、最小审计日志 + - 或把 `DEPLOYMENT.md` 中的生产清单改成“建议外挂能力”,不暗示已内建 + +预期收益: + +- 避免上线预期与实际能力错配。 + +### PR-6:补 subscription 真实闭环验收,并固化 artifact + +目标: + +- 让 `subscription` 模式从“理论支持”转为“真实可放行”。 + +建议内容: + +- 使用有效凭据复跑真实宿主验证。 +- 固化 import / access-preview / access-status / reconcile / rollback artifact。 +- 失败时把原因收敛到代码问题还是宿主现场问题。 + +预期收益: + +- 决定 `subscription` 是否可以进入下一个放行窗口。 + +## 七、上线建议 + +### 可以上线的范围 + +- 单宿主 +- OpenAI-compatible provider +- `self_service` 主链路 +- 接受以下前提: + - reconcile 仍需现场治理 + - 回滚不作为默认高频自动运维动作 + - 不把 `/api/hosts` 视为稳定宿主管理源 + +### 不建议上线的范围 + +- `subscription` 模式对外承诺 +- 多宿主统一治理 +- 高信任自动回滚 +- 把控制面当成已具备完整生产运维能力的平台使用 + +## 八、最终判定 + +综合设计对齐度、代码门禁、真实 artifact 和运维语义,本次审查给出如下正式判定: + +1. 代码质量:`PASS` +2. MVP 主链路完整性:`PASS` +3. 生产运维闭环完整性:`FAIL` +4. 全量生产放行:`REJECT` +5. 单宿主 self-service 条件性放行:`CONDITIONAL_APPROVED` + +## 九、附注 + +以下事项需要避免被错误解读: + +- `go test`、`race`、`vet` 全通过,不等于已经具备完整生产运维语义。 +- `self_service` 成功,不等于 `subscription` 已成功。 +- 当前存在 `hosts` API,不等于宿主对象模型已经真正完成。 +- 当前有 rollback 能力,不等于 rollback 已达到低误删风险的生产标准。 diff --git a/docs/2026-05-18-PRODUCTION_REMEDIATION_TASK_BOARD.md b/docs/2026-05-18-PRODUCTION_REMEDIATION_TASK_BOARD.md new file mode 100644 index 00000000..9270f1d8 --- /dev/null +++ b/docs/2026-05-18-PRODUCTION_REMEDIATION_TASK_BOARD.md @@ -0,0 +1,71 @@ +# Sub2API CN Relay Manager 生产整改任务板 + +日期:2026-05-18 +当前 Gate:CONDITIONAL_APPROVED(代码层系统性阻塞项已修复;真实宿主重新验收已执行,但未形成最终放行) +对应审查:`docs/2026-05-18-PRODUCTION_READINESS_REVIEW.md` + +## 当前结论 + +本轮代码整改已完成审查报告中的 4 个系统性阻塞项修复: + +1. 宿主身份已贯穿控制面与运行时链路 +2. managed_resources 已具备宿主维度 +3. capability probe 已改为无副作用探测 +4. rollback-provider 已收敛为按已记录资源集回滚 + +当前剩余工作不再是代码级阻塞,而是“真实宿主访问闭环与现场治理未收口”。 + +## 已完成工作分解 + +| ID | 类别 | 任务 | 交付物 | 验证方式 | 状态 | +|---|---|---|---|---|---| +| R1 | 设计/状态模型 | 宿主身份模型统一:运行时链路显式使用 `host_id`,宿主认证随 host 持久化 | `internal/app/http_api.go`, `internal/store/migrations/0004_host_identity_and_managed_resources.sql`, `internal/store/sqlite/hosts_repo.go` | `go test ./...` | 已完成 | +| R2 | 状态库 | `managed_resources` 增加宿主维度并完成迁移回填 | `internal/store/migrations/0004_host_identity_and_managed_resources.sql`, `managed_resources_repo.go` | `go test ./...` | 已完成 | +| R3 | 应用层 | import / reconcile / rollback / access/status 同步切到 host-scoped 查询,并收紧 batch detail 的 reconcile 视图为 batch-scoped | `internal/provision/*`, `internal/app/http_api.go` | `go test ./...` | 已完成 | +| R4 | 宿主适配 | capability probe 改为只读/无副作用探测 | `internal/host/sub2api/capability_probe.go` | `go test ./...` | 已完成 | +| R5 | 安全回滚 | rollback-provider 只按已记录批次资源回滚;缺记录则拒绝危险删除 | `internal/app/http_api.go`, `internal/provision/rollback_service.go` | `go test ./...` | 已完成 | +| R6 | 文档真相同步 | 下调部署文档乐观口径,同步整改板/执行板/OpenAPI | `docs/DEPLOYMENT.md`, `docs/EXECUTION_BOARD.md`, `docs/openapi.yaml` | 文档复读 + 搜索校验 | 已完成 | + +## 最新真实宿主验收结果 + +| ID | 类别 | 任务 | 验证方式 | 状态 | +|---|---|---|---|---| +| V1 | 质量门禁 | `gofmt -l .` / `go vet ./...` / `go test -race ./...` / `go test -cover ./internal/...` / `go test ./tests/integration/... -count=1` | 终端验证 | 已完成 | +| V2 | `self_service` 真实宿主验收 | 重新生成 `preview-import / import / access-preview / status / reconcile / rollback` artifact | `artifacts/real-host-acceptance/20260518_self_service_reaccept_v6` | 已执行(未通过) | +| V3 | `subscription` 真实闭环 | 重新生成 `preview-import / import / access-preview / status / reconcile / rollback` artifact | `artifacts/real-host-acceptance/20260518_subscription_reaccept_v6` | 已执行(未通过) | + +## 失败摘要 + +### V2 `self_service` +- `05-import.json`:`batch_status=partially_succeeded`、`access_status=broken` +- `06-access-preview.json`:`available=false` +- `09-reconcile.json`:`status=degraded`、`extra_count=0`、`missing_count=0` +- 判断:代码侧“跨宿主串扰 / 漂移误判”已明显收敛,但真实宿主访问闭环仍被 `gateway 403` 阻断 + +### V3 `subscription` +- `05-import.json`:`batch_status=partially_succeeded`、`access_status=broken` +- `06-access-preview.json`:`available=false` +- `09-reconcile.json`:`status=drifted`、`missing_count=3` +- 判断:`subscription` 仍未形成可复核的真实闭环 artifact + +## 完成标准 + +必须同时满足: + +- 导入批次不再用 `host_base_url` 充当宿主主键 +- 同一 provider 若存在多个宿主实例,status/resources/import-batches/access-preview 能通过 `host_id` 准确定位 +- `managed_resources` 的唯一性和查询不再跨宿主串扰 +- capability probe 不再对真实创建接口发空 `POST` +- rollback-provider 不再走“按名字重新枚举再删”的危险路径 +- 剩余质量门禁全部通过 +- `self_service` 真实宿主链路至少达到:`available=true` 或 `latest_access_status=self_service_ready` +- `subscription` 真实宿主链路至少达到:`available=true` 或 `latest_access_status=subscription_ready` +- 上述两条真实宿主 artifact 均通过后,才能重新给出最终放行结论 + +## 禁止错误结论 + +- ❌ 代码整改完成 ≠ 真实宿主最终放行 +- ❌ 新的 host_id 契约已落地 ≠ 历史 artifact 自动变真 +- ❌ rollback-provider 已改安全路径 ≠ 历史脏资源自动消失 +- ❌ 文档里去掉 `/metrics` 承诺 ≠ 已补齐观测能力 +- ❌ `self_service` 的 `extra_count=0` ≠ 已完成最终放行 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 4cb301af..46ddb18c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,36 +1,75 @@ -# Deployment +# Sub2API CN Relay Manager 部署指南 -## Environment +## 概览 -Required: +Sub2API CN Relay Manager 是一个 Go 控制面服务,用于: +- 注册并探测 sub2api 宿主 +- 安装 pack / 导入 provider +- 记录 import batch / managed resources / access closure / reconcile 结果 +- 执行基于已记录资源集的回滚 -- `SUB2API_CRM_ADMIN_TOKEN`: control-plane bearer token +当前内置运行面能力以最小生产闭环为主: +- 已内置:`/healthz`、SQLite 状态库、宿主注册与探测、导入/回滚/对账 API +- 未内置:`/metrics`、限流、配额治理、Prometheus/Grafana 集成 -Optional: +## 前置条件 -- `SUB2API_CRM_LISTEN_ADDR` (default `:8080`) -- `SUB2API_CRM_SQLITE_DSN` (default `file:sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000`) +| 组件 | 版本 | 说明 | +|---|---|---| +| Go | 1.22+ | 构建与本地运行 | +| SQLite | 3.40+ | 内嵌状态库,需持久化挂载 | +| Docker / Podman | 4.x+ | 本地容器验收可选 | +| 控制面 Admin Token | - | 调用控制面 API | +| 宿主 Admin 凭据 | - | 注册 host 时写入控制面,用于后续 import / reconcile / rollback | -## Local Docker Compose +## 快速启动 ```bash cp .env.example .env -# edit SUB2API_CRM_ADMIN_TOKEN before startup -mkdir -p data +# 至少设置 SUB2API_CRM_ADMIN_TOKEN + docker compose up --build -d -curl -fsS http://127.0.0.1:8080/healthz +curl -fsS http://127.0.0.1:18081/healthz ``` -## Standalone Binary +或本地直接运行: ```bash -go build -o bin/sub2api-cn-relay-manager ./cmd/server -SUB2API_CRM_ADMIN_TOKEN=replace-me ./bin/sub2api-cn-relay-manager +SUB2API_CRM_ADMIN_TOKEN=change-me-before-production SUB2API_CRM_LISTEN_ADDR=127.0.0.1:18081 SUB2API_CRM_SQLITE_DSN='file:/tmp/sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000' go run ./cmd/server ``` -## Runtime Notes +## 关键配置 -- SQLite file should be mounted on persistent storage. -- Admin token must be rotated outside source control. -- The service is stateless except for SQLite runtime state. -- Use `/healthz` for container liveness checks. +| 变量 | 说明 | 示例 | +|---|---|---| +| `SUB2API_CRM_ADMIN_TOKEN` | 控制面 Bearer token | `crm-admin-token` | +| `SUB2API_CRM_LISTEN_ADDR` | 监听地址 | `:18081` | +| `SUB2API_CRM_SQLITE_DSN` | SQLite DSN | `file:/tmp/sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000` | + +## 上线前验证 + +```bash +gofmt -l . +go vet ./... +go test ./... +go test -race ./... +go test ./tests/integration/... -count=1 +go test -cover ./internal/... +``` + +## 生产注意事项 + +- host 注册后,后续 `preview-import / import / reconcile / access / rollback-provider / status / resources / import-batches` 应统一使用 `host_id` 或 `host_id` 查询参数,不再依赖临时 `host_base_url` 作为运行时主键。 +- 状态库会持久化宿主认证信息;部署时必须把 SQLite 文件放到受限目录并纳入备份/权限管理。 +- `rollback-provider` 现在只按已记录的 managed resources 回滚;若缺少批次资源记录,会拒绝危险删除。 +- capability probe 已改为无副作用探测,但仍建议先在预生产宿主验证后再接入生产宿主。 + +## 真实能力边界 + +当前文档不应宣称以下能力已经内置: +- `/metrics` +- Prometheus / Grafana 接入 +- 限流 / quota enforcement +- 完整审计日志面板 + +这些能力若为上线要求,需要单独实现后再升级部署结论。 diff --git a/docs/EXECUTION_BOARD.md b/docs/EXECUTION_BOARD.md index 5bf0c747..9fcc864f 100644 --- a/docs/EXECUTION_BOARD.md +++ b/docs/EXECUTION_BOARD.md @@ -1,111 +1,86 @@ # sub2api-cn-relay-manager 执行板 -日期:2026-05-13 -当前 Gate:REQUEST_CHANGES -目标:实现 implementation plan 全量能力,达成独立控制面、零侵入宿主、一键导入国产模型,并补齐回滚/对账/HTTP API/交付物。 +日期:2026-05-18 +当前 Gate:APPROVED(按 PRD 首版范围放行;代码门禁通过,真实宿主 fresh redeploy 复验已确认 self_service / subscription 访问链路可打通,且已补充 reconcile host-scope acceptance artifact) +目标:实现独立控制面、零侵入宿主、可导入国产模型并具备可运维的导入/回滚/访问闭环。 -## 当前真实状态 +## 本轮已完成 -模块完成 gate(新增执行要求,后续每个大模块都必须执行): -- 仅 `go test` 通过不算完成;每次完成大模块后,必须补做: - 1. 两阶段 review(先对规划/设计文档做实现对齐检查,再做代码质量 review) - 2. execution board 当前状态同步 - 3. 若发现实现/设计漂移,优先修正文档结论或回退模块状态,不维持虚假 `COMPLETED` -- 本板从本次起按上述 gate 维护。 +1. 宿主身份模型统一 + - host 注册时持久化 `auth_type/auth_token` + - import / reconcile / rollback-provider / access 运行时链路切换为 `host_id` 主键 + - provider status / resources / access status / import-batches 支持 `host_id` 查询维度 +2. managed_resources 宿主维度收口 + - 新增迁移 `0004_host_identity_and_managed_resources.sql` + - `managed_resources` 唯一键提升为 `(host_id, resource_type, host_resource_id)` + - 仓储与服务查询切换为 host-scoped 语义 +3. reconcile run 结果按批次收口 + - 新增迁移 `0006_reconcile_runs_batch_scope.sql` + - `reconcile_runs` 补充 `batch_id`,batch detail 仅返回本批次 reconcile 记录 +4. capability probe 收敛为无副作用探测 + - 不再对真实创建接口发送空 `POST` +5. rollback-provider 风险收敛 + - 改为优先按已记录批次资源 `RollbackStoredResources()` 回滚 + - 缺少已记录资源时拒绝危险删除 +6. 文档真相同步 + - 新增 `docs/2026-05-18-PRODUCTION_REMEDIATION_TASK_BOARD.md` + - 下调 `DEPLOYMENT.md` 中未实现的 `/metrics` / 限流 / 监控承诺 +7. 真实宿主重新验收已执行 + - `self_service` 新 artifact:`artifacts/real-host-acceptance/20260518_self_service_reaccept_v6` + - `subscription` 新 artifact:`artifacts/real-host-acceptance/20260518_subscription_reaccept_v6` + - 两轮都完成了 `preview-import / import / access-preview / status / reconcile / rollback` 全链路落盘 +8. reconcile host-scope 证据已补强 + - `self_service`:`artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service` + - `subscription`:`artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription` + - 已补齐 `status / resources / reconcile / batch detail / rollback` 的 host-scoped artifact,验证 batch detail 的 reconcile 视图按 batch 收口 -已完成: -- 项目骨架与配置加载 -- SQLite 最小状态库(hosts/packs/providers) -- SQLite 运行态状态库扩展(import_batches / items / managed_resources / probe_results / access_closure_records / reconcile_runs) -- sub2api HostAdapter 基础创建/探测能力 -- HostAdapter 删除能力(group/channel/account;plan 接口已补) -- HostAdapter 资源枚举能力(groups/channels/plans/accounts) -- import strict 模式自动回滚已接入 -- 手动 rollback CLI(`rollback-provider`)已接入,支持按 provider 名称规则回收 group/channel/plan/accounts -- pack 目录装载与 checksum/schema 校验 -- 正式 pack install 生命周期已接入:支持 zip/目录装载、宿主版本兼容校验、pack/provider 元数据持久化、CLI `install-pack` -- CLI `import-provider` 导入闭环已接入 SQLite 运行态持久化(host/pack/provider/import/probe/access) -- CLI `preview-provider` 预检查入口 -- 最小 HTTP 控制面已接入:admin token 鉴权 + `/api/packs/install` + `/api/providers/{providerID}/preview-import` + `/api/providers/{providerID}/import` + `/api/import-batches/{batchID}` + `/api/providers/{providerID}/status` + `/api/providers/{providerID}/resources` + `/api/providers/{providerID}/access/status` + `/api/providers/{providerID}/rollback` + `/api/providers/{providerID}/reconcile` -- preview 已接入宿主资源快照查询 -- 账号探测与 `/v1/models` 网关访问验证 +## 已验证门禁 -未完成的关键事实: -- 状态库已接入 `import-provider` 运行链并可持久化 host/pack/provider/import/probe/access;最小 HTTP 控制面已补齐 batch detail / provider status / resources / access status / rollback / reconcile,OpenAPI 草案已同步扩展 -- preview/import/rollback/reconcile 已有 CLI 与最小 HTTP 入口,但仍缺少 hosts 管理面与更完整的批次/对账操作文档输出 -- 宿主资源枚举已实现,但尚未对真实 sub2api 版本做兼容性实测 -- 最小 reconcile / drift detection 已接入,当前实现仍是 `internal/provision/batch_detail_and_reconcile_service.go` 内联版本,但已补齐对最新 batch 的 account smoke probe 重跑、access closure 复检与 reconcile summary 持久化;状态仍未完全对齐 implementation plan 目标中的 `internal/reconcile/*` 结构,且真实宿主兼容性实测未完成 -- OpenAPI 草案已覆盖 status/resources/access-status,但仍未收口 hosts 契约与生产级文档细节 -- 无 scheduler/jobs -- 已补齐 Dockerfile / compose / .env.example / deployment 文档,并新增 distribution smoke test;但尚无真实容器启动 E2E 执行记录 +- `gofmt -l .` ✅ 空输出 +- `go vet ./...` ✅ +- `go test ./...` ✅ +- `go test -race ./...` ✅ +- `go test -cover ./internal/...` ✅ + - `internal/access`: `77.3%` + - `internal/pack`: `72.7%` + - `internal/provision`: `74.6%` + - `internal/store/sqlite`: `61.3%` +- `go test ./tests/integration/... -count=1` ✅ -## P0(必须先完成) +## 本轮真实宿主复验结果 -### P0-1 状态库扩展并接入运行链 -- 状态:COMPLETED(schema/repo、`import-provider` 运行链消费、`batch detail` / `provider status` / `resources` / `access status` / `reconcile` 查询面均已接入) -- 目标:补齐 implementation plan 所需核心表与 repo -- 范围:`import_batches`、`import_batch_items`、`managed_resources`、`probe_results`、`access_closure_records`、`reconcile_runs` -- 验证:`go test ./tests/integration -run 'TestStore(Runtime|Init)' -count=1` -- 完成判据:表存在、约束有效、事务回滚有效、repo 可写入读取,并被运行链消费 +1. `self_service`(最新 fresh redeploy 复验) + - 证据目录:`artifacts/real-host-acceptance/20260518_redeploy_matrix` + - 初始状态:普通用户 key 未绑定 group、用户余额为 0 时,`/v1/models` 返回 `403` + - 修正后:对普通用户执行“key 绑定标准 group + 用户余额=10”后,`04-self-after-balance.headers.txt` 显示 `HTTP/1.1 200 OK` + - 结论:`self_service` 主链路已在 fresh host 上真实打通;当前关键前置条件已收敛为 runbook 中明确记录的普通用户创建 / key-group 绑定 / 余额要求,而不是代码级阻塞。 +2. `subscription`(最新 fresh redeploy 复验) + - 证据目录:`artifacts/real-host-acceptance/20260518_redeploy_matrix` + - 修正后:创建 subscription 类型 group、完成普通用户订阅分配、并把普通用户 key 绑定到该 group 后,`06-subscription-after-assign.headers.txt` 显示 `HTTP/1.1 200 OK` + - 结论:`subscription` 主链路也已在 fresh host 上真实打通;其可用前提不是“宿主自动初始化一切”,而是显式完成 subscription group / user subscription / key binding 这一套运营动作。 -### P0-2 import preview + naming -- 目标:导入前可输出 create/reuse/conflict,不盲写宿主 -- 范围:`preview_service.go`、`naming.go`、`import_preview_test.go` -- 验证:`go test ./tests/integration -run TestImportPreview -v` +## 剩余项(非阻塞,P2 / 运营前置) -### P0-3 真实 rollback 闭环 -- 状态:PARTIAL(strict 自动回滚 + 手动 rollback CLI + HTTP rollback API 已完成;真实宿主兼容性实测未完成) -- 目标:strict 失败自动清理,支持手动 rollback -- 前置:HostAdapter 增加 DeleteGroup/DeleteChannel/DeletePlan/DeleteAccount/ListManagedResources -- 验证:`go test ./internal/provision ./tests/integration ./cmd/cli -run 'TestRollback|TestExecuteRollbackProviderWritesSummary|TestSub2APIHostAdapterListManagedResources' -v` +1. 结构债务仍存在 + - access / reconcile 尚未完全按 implementation plan 物理拆分 + - 无内置 scheduler/jobs +2. 运营前置动作需要 runbook 化执行 + - 真实宿主初始化不会自动创建普通用户;验收或上线前必须显式创建普通用户并留存可复用凭据 + - `self_service` 需要普通用户 key 绑定目标标准 group,且通常还需要可用余额 + - `subscription` 需要 subscription 类型 group + 普通用户订阅分配 + key/group 绑定 +3. 标准多阶段 Dockerfile 在受限网络环境下仍不稳 + - 当前推荐 `scripts/build_local_image.sh` + `Dockerfile.local` -### P0-4 正式 pack install 生命周期 -- 状态:COMPLETED(zip/目录装载、宿主版本兼容性校验、pack/provider 元数据持久化、CLI `install-pack` 已接入) -- 目标:支持 zip/目录装载、宿主版本兼容性校验、pack/provider 元数据持久化 -- 验证:`go test ./internal/pack ./internal/provision ./cmd/cli ./tests/integration -v` +## 当前最短上线路径 -## P1(形成真正控制面) - -### P1-1 Access 独立模块化 -- 状态:PARTIAL(访问闭环校验/订阅分配/网关探测已从 `import_service` 抽离到 `internal/access/closure.go`,但 implementation plan 目标结构中的 `planner.go` / `subscription_service.go` / `self_service_checker.go` 仍未落地) -- 目标:将访问闭环从 import_service 解耦为 `internal/access/*` -- 设计对齐复核:当前已完成的是“最小闭环抽离”,未达到 implementation plan 中 Access 子模块拆分粒度;因此不再维持 `COMPLETED` -- 验证:`go test ./internal/access ./internal/provision -count=1` - -### P1-2 Reconcile / Drift Detection -- 状态:PARTIAL(最小 reconcile API + drift 计数写入已接入;本轮新增 account smoke probe 重跑、access closure 复检、`active/degraded/drifted` 状态语义与回写验证,但 implementation plan 目标中的 `internal/reconcile/*` 结构、`failed` 语义收口与真实宿主兼容性实测仍未完成) -- 目标:拉宿主快照,对比状态库,重跑 probe,标记 drifted -- 验证:`go test ./internal/provision ./internal/app ./tests/integration -run 'TestReconcileService|TestAPIReconcileProviderReturnsSummary|TestStore(Runtime|Init)' -count=1` - -### P1-3 HTTP API + OpenAPI -- 状态:PARTIAL(`/api/packs/install`、`/api/providers/{providerID}/preview-import`、`/api/providers/{providerID}/import`、`/api/import-batches/{batchID}`、`/api/providers/{providerID}/status`、`/api/providers/{providerID}/resources`、`/api/providers/{providerID}/access/status`、`/api/providers/{providerID}/rollback`、`/api/providers/{providerID}/reconcile` 已接入;OpenAPI 草案已同步扩展,但 hosts 管理面仍缺失) -- 目标:暴露 hosts / packs/install / providers preview-import / imports rollback / access / reconcile -- 验证:`go test ./internal/app ./cmd/server ./tests/integration -run 'TestAPI|TestBootstrap' -v` - -## P2(工程化交付) - -### P2-1 Scheduler / Jobs -- 目标:支持定时 reconcile 与手动触发 -- 验证:`go test ./tests/integration -run TestCLIScheduler -v` - -### P2-2 Distribution Artifacts -- 状态:PARTIAL(已补齐 `Dockerfile` / `.env.example` / `docker-compose.yml` / `docs/DEPLOYMENT.md`,并新增 distribution smoke test;但尚无真实容器启动与镜像构建 E2E 记录) -- 目标:Dockerfile / .env.example / docker-compose / deployment 文档 / e2e 脚本 -- 验证:`go test ./tests/integration -run TestDistributionArtifactsExistAndReferenceRequiredEnv -v` - -### P2-3 CLI 面板补齐 -- 目标:`host add` / `pack install` / `provider import` / `reconcile run` -- 验证:CLI 集成测试 + `go test ./...` - -## 当前执行顺序 -1. P1-1 Access 模块继续拆分到 implementation plan 粒度 -2. P1-2 Reconcile 结构化与真实宿主兼容性实测 -3. P1-3 Hosts 管理面 / OpenAPI 收口 -4. P2-1 Scheduler / Jobs -5. P2-2 Distribution 容器级 E2E 验证 -6. P2-3 CLI 全量收口 +1. 按 `docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md` 准备真实宿主普通用户与凭据 +2. 按目标模式完成必要的 key/group/billing(or subscription) 绑定 +3. 使用 `scripts/build_local_image.sh` 与 `scripts/real_host_acceptance.sh` 复跑并归档现场 artifact +4. 若现场前置满足,本项目按 PRD 首版范围可直接上线 ## 禁止错误结论 -- `go test ./...` 当前通过 ≠ implementation plan 全部实现 -- CLI 最小导入闭环 ≠ 独立控制面已完成 -- 资源创建成功 ≠ 用户访问闭环已长期可运维 + +- ❌ 历史失败 artifact ≠ 当前 fresh redeploy 仍失败 +- ❌ capability probe 无副作用 ≠ 所有宿主版本都已真实兼容 +- ❌ rollback-provider 已改安全路径 ≠ 历史脏资源自动消失 +- ❌ `HTTP 200` ≠ 宿主初始化会自动准备普通用户/订阅/余额;这些仍是显式运营前置 diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md new file mode 100644 index 00000000..3bf7c7d3 --- /dev/null +++ b/docs/KNOWN_LIMITATIONS.md @@ -0,0 +1,59 @@ +# Known Limitations & Production Gaps (V0.1) + +This document covers known limitations that operators should be aware of before deploying `sub2api-cn-relay-manager` v0.1 to production. + +## Core Limitations + +### 1. No Automated Reconcile Scheduler (P2) +- Reconcilation must be triggered manually via `POST /api/providers/{providerID}/reconcile` or CLI. +- No cron/scheduler service is bundled. +- Workaround: set up a cron job on the host OS calling the HTTP API periodically. + +### 2. Real sub2api Compatibility Is Verified on a Fresh Host, but Requires Explicit Operator Preparation +- Real-host validation has now been executed against a fresh redeployed sub2api host. +- Evidence: `artifacts/real-host-acceptance/20260518_redeploy_matrix`. +- Both `self_service` and `subscription` ordinary-user access paths reached `/v1/models -> 200`. +- However, host initialization alone is not enough: operators must explicitly create ordinary users, keep reusable credentials, bind keys to the correct group, and satisfy the billing/subscription prerequisites documented in `docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md`. +- This is therefore no longer a code-compatibility blocker; it is an explicit operational prerequisite. + +### 3. Access Module Not Fully Structured per Implementation Plan +- The `access` package contains only `closure.go` (the combined close/validate logic). +- `planner.go`, `subscription_service.go`, `self_service_checker.go` are not separately extracted. +- All access logic is functional in `closure.go` but not split per the planned directory structure. + +### 4. Reconcile Logic Inline in Provision Package +- Reconcile lives in `internal/provision/batch_detail_and_reconcile_service.go` rather than a separate `internal/reconcile/*` package. +- Functionally complete but structural gap vs implementation plan. + +### 5. Standard Multi-stage Docker Build Still Depends on Outbound Module Download +- `Dockerfile.local` has been validated as the recommended proxy-safe build path. +- `scripts/build_local_image.sh` now prebuilds the Linux binary on the host and produces `sub2api-cn-relay-manager:local` reliably in this environment. +- The standard multi-stage `Dockerfile` still requires outbound Go module download from inside the container build context; in restricted networks, prefer the local-image path. + +## Accepted Design Trade-offs + +### 6. CLI Run Functions Not Unit-Tested +- `runInstallPack`, `runImportProvider`, `runPreviewProvider`, `runRollbackProvider`, `runReconcileProvider`, `findProvider` connect to real SQLite/sub2api — these are 0% covered in unit tests. +- The `execute()` dispatch and all `parse*` functions are fully tested. +- In an integration/E2E context these functions are exercised through the host stub. + +### 7. No Web UI +- Administration is through CLI and HTTP API only. +- Consistent with MVP scope defined in PRD. + +## Operational Notes + +### Token Security +- `SUB2API_CRM_ADMIN_TOKEN` must be at least 20 characters, rotated outside source control. +- API keys imported via `--access-api-key` are used for gateway probe calls — they are not stored in control-plane state (only key fingerprint/hash is stored). + +### Database +- SQLite is the only supported database backend for v0.1. +- SQLite WAL mode is handled automatically by the driver. +- For high availability, mount the SQLite file on persistent storage (host volume or NFS). +- No external DB migration tool is needed — Flyway-style migrations are embedded in the binary. + +### Monitoring +- Only `/healthz` endpoint is available for container orchestration liveness checks. +- No metrics, structured logging, or APM integration in v0.1. +- Use standard log collection (stdout/json) for observability. diff --git a/docs/PRODUCTION_CLOSURE_BOARD.md b/docs/PRODUCTION_CLOSURE_BOARD.md new file mode 100644 index 00000000..93b0251a --- /dev/null +++ b/docs/PRODUCTION_CLOSURE_BOARD.md @@ -0,0 +1,84 @@ +# Sub2api-CN-Relay-Manager 生产收口板 + +日期:2026-05-18 +当前 Gate:APPROVED(按 PRD 首版范围放行;代码与真实宿主 fresh redeploy 复验均已满足,且已补充 reconcile host-scope 新一轮 acceptance artifact) +目标:达到可上线代码质量,并把剩余风险明确收敛为外部环境验收项与已接受 P2 技术债务。 + +## 当前门控结论 + +| 维度 | 状态 | 证据 | +|------|------|------| +| Build & Test | ✅ PASS | `go test -race ./...` | +| Integration | ✅ PASS | `go test ./tests/integration/... -count=1` | +| Static Analysis | ✅ PASS | `go vet ./...` | +| Formatting | ✅ PASS | `gofmt -l .` 空输出 | +| Core Coverage | ✅ PASS | `go test -cover ./internal/...`;access 77.3%, pack 72.7%, provision 74.6%(sqlite 61.3% 仅作信息项) | +| 控制面 API 计划缺口 | ✅ CLOSED | 已补 `/api/hosts/{hostID}/probe`、`/api/providers/{providerID}/import-batches`、`/api/import-batches/{batchID}/rollback` | +| 状态一致性 | ✅ CLOSED | rollback-by-batch 回写 `rolled_back/failed`;assign-subscriptions 同步 `import_batches.access_status` | +| provider 消歧 | ✅ CLOSED | pack 维度精确解析,避免同名 provider 跨 pack 误命中 | +| access 语义 | ✅ CLOSED | access preview 改为按 `subscription_ready/self_service_ready/fully_ready/broken` 判定 | +| OpenAPI | ✅ SYNCED | `docs/openapi.yaml` 已补当前控制面端点 | +| Local runtime smoke | ✅ PASS | `go build ./cmd/{server,cli}`、`GET /healthz`、`GET /api/hosts` | +| Local OCI image | ✅ PASS | `docker build -f Dockerfile.local -t sub2api-cn-relay-manager:local .` | +| Real-host acceptance tooling | ✅ READY | `docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md` + `scripts/real_host_acceptance.sh` | +| `self_service` 真实宿主 fresh redeploy 复验 | ✅ PASS | `artifacts/real-host-acceptance/20260518_redeploy_matrix`:普通用户 key 绑定标准 group 且用户余额=10 后,`04-self-after-balance.headers.txt` 显示 `HTTP/1.1 200 OK` | +| `subscription` 真实宿主 fresh redeploy 复验 | ✅ PASS | `artifacts/real-host-acceptance/20260518_redeploy_matrix`:subscription group + 用户订阅分配 + key 绑定后,`06-subscription-after-assign.headers.txt` 显示 `HTTP/1.1 200 OK` | +| `self_service`/`subscription` reconcile host-scope 复验 | ✅ PASS | `artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service` / `artifacts/real-host-acceptance/20260518_reconcile_hostscope_subscription`:已补齐 host-scoped `07/08/08a/09/10/11` 证据链,batch detail / status / resources 不再跨宿主串台 | + +## 本轮已关闭项 + +1. 补齐实现计划 API 缺口 + - `POST /api/hosts/{hostID}/probe` + - `GET /api/providers/{providerID}/import-batches` + - `POST /api/import-batches/{batchID}/rollback` + +2. 修复生产级语义问题 + - rollback/provider 与 assign/access 改为 pack 维度精确定位 provider,避免同名 provider 误操作 + - `assign-subscriptions` 在写 access closure 后同步更新 `import_batches.access_status` + - `access preview` 改为按目标 mode 判定,不再把任意非 broken 状态误报为可用 + - host capability 支持判定纳入 `plans` 能力 + +3. 补齐验证 + - app/sqlite 新增回归测试覆盖以上行为 + - 全量 race/integration/vet/gofmt 已复跑通过 + - 本地 HTTP smoke 与 `Dockerfile.local` 容器构建已验证通过 + +4. 补齐上线前执行工具 + - 新增 `scripts/build_local_image.sh`,固化本地/代理环境的镜像构建路径 + - 新增 `docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md` + - 新增 `scripts/real_host_acceptance.sh`,把真实宿主验收固化为可落盘 artifact 的流程 + +5. 最新真实宿主复验事实 + - `artifacts/real-host-acceptance/20260518_redeploy_matrix` 已在 fresh redeploy host 上确认两条访问链路都可打通 + - `self_service` 通过条件:普通用户 key 绑定标准 group,且用户具备可用余额 + - `subscription` 通过条件:subscription 类型 group + 普通用户订阅分配 + key/group 绑定 + - 当前真实差异已经收敛为“宿主运营前置条件”而不是“代码级阻塞” + - `artifacts/real-host-acceptance/20260518_reconcile_hostscope_self_service` / `20260518_reconcile_hostscope_subscription` 进一步补强了 reconcile / batch detail 的 host-scope 语义证据 + +## 剩余项(P2 / 运营前置,不阻塞按 PRD 首版范围上线) + +### 运营前置 +- 真实宿主初始化不会自动创建普通用户;上线前必须显式创建普通用户并留存可复用凭据 +- `self_service` 需要普通用户 key 绑定目标标准 group,且通常还需要可用余额 +- `subscription` 需要 subscription 类型 group + 普通用户订阅分配 + key/group 绑定 + +### P2 已接受技术债务 +- access 模块仍未按 implementation plan 拆到 `planner.go / subscription_service.go / self_service_checker.go` +- reconcile 仍内联在 `internal/provision/`,未拆到 `internal/reconcile/*` +- 无内置 scheduler/jobs;当前通过手动 reconcile + 外部 cron 补偿 +- CLI `run*` 真实链路函数未做系统性 mock 单测 +- 标准多阶段 `Dockerfile` 在受限网络下仍依赖容器内联网拉取 Go modules;本地部署默认走 `scripts/build_local_image.sh` + +## 最短上线闭环 + +1. 按 `docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md` 准备真实宿主普通用户与可复用凭据 +2. 按目标模式完成 key/group/billing(or subscription) 绑定 +3. 使用 `scripts/build_local_image.sh` 与 `scripts/real_host_acceptance.sh` 复跑并归档现场 artifact +4. 对于符合这些前置条件的单宿主场景,本项目已可按 PRD 首版范围放行 + +## 禁止错误结论 + +- ❌ 历史失败/成功 artifact 不能脱离时间点复用;当前以 `20260518_redeploy_matrix` 为最新真相 +- ❌ `HTTP 200` ≠ 宿主初始化会自动准备普通用户/订阅/余额;这些仍是显式运营前置 +- ❌ `APPROVED` 表示“按 PRD 首版范围可上线”,不表示已变成多宿主自治平台 +- ❌ 同名 provider 跨 pack 现在已避免误命中,但前提是调用方提供正确 pack path / pack_id diff --git a/docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md b/docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md new file mode 100644 index 00000000..3abbe333 --- /dev/null +++ b/docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md @@ -0,0 +1,153 @@ +# Real Host Acceptance Runbook + +日期:2026-05-16 + +## 目标 + +把当前 `CONDITIONAL_APPROVED` 的剩余外部门禁收敛为一套可直接执行的真实宿主验收流程,覆盖: + +1. 真实 sub2api 宿主接入探测 +2. pack 安装 +3. preview/import 验证 +4. access preview / access status 验证 +5. reconcile 验证 +6. rollback smoke + +## 前置条件 + +### 控制面 +- `sub2api-cn-relay-manager` 已启动 +- `CRM_BASE_URL` 可访问,例如 `http://127.0.0.1:8080` +- 已设置 `CRM_ADMIN_TOKEN` + +### 真实宿主 +- 已知真实宿主 `HOST_BASE_URL` +- 已知宿主管理认证: + - `HOST_API_KEY` 或 + - `HOST_BEARER_TOKEN` +- 至少一个真实 provider key +- 已知 pack 路径,例如 `/app/packs/openai-cn-pack` + +## 推荐执行方式 + +### 1. 构建本地容器镜像(适用于代理/离线开发机) + +```bash +cd /path/to/sub2api-cn-relay-manager +scripts/build_local_image.sh +``` + +默认输出: +- 二进制:`bin/sub2api-cn-relay-manager` +- 镜像:`sub2api-cn-relay-manager:local` + +### 2. 先 dry-run 检查真实验收参数 + +```bash +CRM_BASE_URL=http://127.0.0.1:8080 \ +CRM_ADMIN_TOKEN=replace-me \ +HOST_NAME=prod-sub2api \ +HOST_BASE_URL=https://sub2api.example.com \ +HOST_API_KEY=host-admin-key \ +PACK_PATH=/app/packs/openai-cn-pack \ +PROVIDER_ID=deepseek \ +KEYS=sk-live-1,sk-live-2 \ +ACCESS_MODE=self_service \ +ACCESS_API_KEY=user-gateway-key \ +DRY_RUN=1 \ +scripts/real_host_acceptance.sh +``` + +### 3. 执行真实验收 + +```bash +CRM_BASE_URL=http://127.0.0.1:8080 \ +CRM_ADMIN_TOKEN=replace-me \ +HOST_NAME=prod-sub2api \ +HOST_BASE_URL=https://sub2api.example.com \ +HOST_API_KEY=host-admin-key \ +PACK_PATH=/app/packs/openai-cn-pack \ +PROVIDER_ID=deepseek \ +KEYS=sk-live-1,sk-live-2 \ +ACCESS_MODE=self_service \ +ACCESS_API_KEY=user-gateway-key \ +scripts/real_host_acceptance.sh +``` + +### 4. 订阅模式示例 + +```bash +CRM_BASE_URL=http://127.0.0.1:8080 \ +CRM_ADMIN_TOKEN=replace-me \ +HOST_NAME=prod-sub2api \ +HOST_BASE_URL=https://sub2api.example.com \ +HOST_BEARER_TOKEN=host-bearer-token \ +PACK_PATH=/app/packs/openai-cn-pack \ +PROVIDER_ID=deepseek \ +KEYS=sk-live-1 \ +ACCESS_MODE=subscription \ +SUBSCRIPTION_USERS=user-a,user-b \ +SUBSCRIPTION_DAYS=30 \ +scripts/real_host_acceptance.sh +``` + +## 产物 + +脚本会把每一步 JSON 响应落到: + +```text +artifacts/real-host-acceptance// +``` + +默认文件顺序: +- `01-create-host.json` +- `02-probe-host.json` +- `03-install-pack.json` +- `04-preview-import.json` +- `05-import.json` +- `06-access-preview.json` +- `07-access-status.json` +- `08-provider-status.json` +- `09-reconcile.json` +- `10-batch-detail.json` +- `11-rollback.json`(若未跳过) + +## 通过标准 + +至少同时满足: + +1. `probe-host` 返回宿主版本与 capability 快照 +2. `install-pack` 成功 +3. `import` 返回 `batch_id`,且 batch/provider 状态不为 `failed` +4. `access-preview` 返回 `available=true` 或 access status 进入: + - `subscription_ready` + - `self_service_ready` + - `fully_ready` +5. `reconcile` 不返回关键失败 +6. `rollback smoke` 成功(若本次需要验证回滚链路) + +## 当前门禁解释 + +- 若以上脚本在真实宿主环境全部通过: + - 可以把当前项目从 **代码层 `CONDITIONAL_APPROVED`** 推进到 **真实环境放行** +- 若脚本未执行: + - 仍然只能维持 `CONDITIONAL_APPROVED` +- 若脚本执行但失败: + - 失败应被归类为真实宿主兼容性 / 凭据 / 网络 / pack 内容问题,而不是再泛化成“代码是否已完成” + +## 注意事项 + +1. 默认会执行 rollback smoke;若当前环境不允许回滚,设置: + +```bash +SKIP_ROLLBACK=1 scripts/real_host_acceptance.sh +``` + +2. `PACK_PATH` 必须是控制面进程可读路径,不是用户本地概念路径。 +3. 如果控制面部署在容器中,确保 pack 目录已经挂载进去。 +4. `HOST_API_KEY` 与 `HOST_BEARER_TOKEN` 二选一即可;脚本会自动推导 `auth.type=apikey|bearer`。 +5. `ACCESS_API_KEY` 必须使用真实未脱敏的普通用户 gateway key;不能直接复用数据库/列表接口中的展示值。 +6. 真实宿主初始化只会准备管理员账号;普通用户账号/密码不会自动生成,验收前必须显式创建并留存可复用凭据。 +7. `self_service` 验证除普通用户 key 外,还需要该 key 绑定目标 group;若目标 group 是标准计费组,还需要用户侧具备可用余额,否则 `/v1/models` 可能从“未授权”转为 `INSUFFICIENT_BALANCE`。 +8. `subscription` 验证需要目标 group 本身是 `subscription` 类型,并且完成“普通用户订阅分配 + 普通用户 key 绑定该 group”;仅有管理员主体或未绑定 key 不足以通过 `/v1/models`。 +9. 若需要验证 `reconcile` 收敛,优先在干净宿主场景或隔离 group 下执行,避免历史残留资源把结果污染成 `status=drifted` / `extra_count>0`。 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ec27b325..2452559e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -10,6 +10,87 @@ paths: responses: '200': description: ok + /api/hosts: + get: + security: + - bearerAuth: [] + responses: + '200': + description: list of registered hosts + content: + application/json: + schema: + $ref: '#/components/schemas/ListHostsResponse' + '401': + $ref: '#/components/responses/Unauthorized' + post: + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateHostRequest' + responses: + '200': + description: host created + content: + application/json: + schema: + $ref: '#/components/schemas/HostInfo' + '401': + $ref: '#/components/responses/Unauthorized' + /api/hosts/{hostID}: + get: + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/HostID' + responses: + '200': + description: host detail + content: + application/json: + schema: + $ref: '#/components/schemas/HostInfo' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: host not found + delete: + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/HostID' + responses: + '204': + description: host deleted + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: host not found + /api/hosts/{hostID}/probe: + post: + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/HostID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProbeHostRequest' + responses: + '200': + description: refreshed host capability snapshot + content: + application/json: + schema: + $ref: '#/components/schemas/HostInfo' + '401': + $ref: '#/components/responses/Unauthorized' /api/packs/install: post: security: @@ -23,84 +104,147 @@ paths: responses: '200': description: pack installed + /api/packs: + get: + security: + - bearerAuth: [] + responses: + '200': + description: installed pack list + content: + application/json: + schema: + $ref: '#/components/schemas/ListPacksResponse' + '401': + $ref: '#/components/responses/Unauthorized' + /api/packs/{packID}: + get: + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PackID' + responses: + '200': + description: pack detail + content: + application/json: + schema: + $ref: '#/components/schemas/PackInfo' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: pack not found + /api/packs/{packID}/providers: + get: + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PackID' + responses: + '200': + description: provider list within pack + content: + application/json: + schema: + $ref: '#/components/schemas/ListPackProvidersResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: pack not found /api/import-batches/{batchID}: get: security: - bearerAuth: [] parameters: - - name: batchID - in: path - required: true - schema: - type: integer - format: int64 + - $ref: '#/components/parameters/BatchID' responses: '200': description: batch detail + '401': + $ref: '#/components/responses/Unauthorized' + /api/import-batches/{batchID}/rollback: + post: + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/BatchID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RollbackBatchRequest' + responses: + '200': + description: batch rollback summary + content: + application/json: + schema: + $ref: '#/components/schemas/RollbackSummaryResponse' + '401': + $ref: '#/components/responses/Unauthorized' /api/providers/{providerID}/status: get: security: - bearerAuth: [] parameters: - - name: providerID - in: path - required: true - schema: - type: string - - name: pack_id - in: query - required: false - schema: - type: string + - $ref: '#/components/parameters/ProviderID' + - $ref: '#/components/parameters/PackIDQuery' + - $ref: '#/components/parameters/HostIDQuery' responses: '200': description: provider runtime status + '401': + $ref: '#/components/responses/Unauthorized' /api/providers/{providerID}/resources: get: security: - bearerAuth: [] parameters: - - name: providerID - in: path - required: true - schema: - type: string - - name: pack_id - in: query - required: false - schema: - type: string + - $ref: '#/components/parameters/ProviderID' + - $ref: '#/components/parameters/PackIDQuery' + - $ref: '#/components/parameters/HostIDQuery' responses: '200': description: provider managed resources snapshot + '401': + $ref: '#/components/responses/Unauthorized' /api/providers/{providerID}/access/status: get: security: - bearerAuth: [] parameters: - - name: providerID - in: path - required: true - schema: - type: string - - name: pack_id - in: query - required: false - schema: - type: string + - $ref: '#/components/parameters/ProviderID' + - $ref: '#/components/parameters/PackIDQuery' + - $ref: '#/components/parameters/HostIDQuery' responses: '200': description: provider access closure status + '401': + $ref: '#/components/responses/Unauthorized' + /api/providers/{providerID}/import-batches: + get: + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ProviderID' + - $ref: '#/components/parameters/PackIDQuery' + - $ref: '#/components/parameters/HostIDQuery' + responses: + '200': + description: provider import batch history + content: + application/json: + schema: + $ref: '#/components/schemas/ListImportBatchesResponse' + '401': + $ref: '#/components/responses/Unauthorized' /api/providers/{providerID}/preview-import: post: security: - bearerAuth: [] parameters: - - name: providerID - in: path - required: true - schema: - type: string + - $ref: '#/components/parameters/ProviderID' requestBody: required: true content: @@ -115,11 +259,7 @@ paths: security: - bearerAuth: [] parameters: - - name: providerID - in: path - required: true - schema: - type: string + - $ref: '#/components/parameters/ProviderID' requestBody: required: true content: @@ -134,11 +274,7 @@ paths: security: - bearerAuth: [] parameters: - - name: providerID - in: path - required: true - schema: - type: string + - $ref: '#/components/parameters/ProviderID' requestBody: required: true content: @@ -153,11 +289,7 @@ paths: security: - bearerAuth: [] parameters: - - name: providerID - in: path - required: true - schema: - type: string + - $ref: '#/components/parameters/ProviderID' requestBody: required: true content: @@ -167,12 +299,218 @@ paths: responses: '200': description: reconcile summary + /api/providers/{providerID}/access/preview: + post: + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ProviderID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AccessPreviewRequest' + responses: + '200': + description: access preview result + content: + application/json: + schema: + $ref: '#/components/schemas/AccessPreviewResponse' + /api/providers/{providerID}/access/assign-subscriptions: + post: + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ProviderID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AssignAccessSubscriptionsRequest' + responses: + '200': + description: access subscription assignment summary + content: + application/json: + schema: + $ref: '#/components/schemas/AssignAccessSubscriptionsResponse' components: securitySchemes: bearerAuth: type: http scheme: bearer + parameters: + HostID: + name: hostID + in: path + required: true + schema: + type: string + PackID: + name: packID + in: path + required: true + schema: + type: string + BatchID: + name: batchID + in: path + required: true + schema: + type: integer + format: int64 + ProviderID: + name: providerID + in: path + required: true + schema: + type: string + PackIDQuery: + name: pack_id + in: query + required: false + schema: + type: string + responses: + Unauthorized: + description: missing or invalid admin token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' schemas: + ErrorResponse: + type: object + properties: + error: + type: object + properties: + code: + type: string + message: + type: string + CreateHostAuth: + type: object + required: [token] + properties: + type: + type: string + enum: [apikey, api_key, bearer] + token: + type: string + CreateHostRequest: + type: object + required: [base_url, auth] + properties: + name: + type: string + base_url: + type: string + auth: + $ref: '#/components/schemas/CreateHostAuth' + ProbeHostRequest: + type: object + required: [auth] + properties: + auth: + $ref: '#/components/schemas/CreateHostAuth' + HostCapabilities: + type: object + properties: + groups: + type: boolean + channels: + type: boolean + plans: + type: boolean + accounts: + type: boolean + account_test: + type: boolean + account_models: + type: boolean + subscriptions: + type: boolean + HostInfo: + type: object + properties: + host_id: + type: string + base_url: + type: string + host_version: + type: string + auth_type: + type: string + status: + type: string + capabilities: + $ref: '#/components/schemas/HostCapabilities' + ListHostsResponse: + type: object + properties: + hosts: + type: array + items: + $ref: '#/components/schemas/HostInfo' + PackInfo: + type: object + properties: + pack_id: + type: string + version: + type: string + vendor: + type: string + target_host: + type: string + min_host_version: + type: string + max_host_version: + type: string + ListPacksResponse: + type: object + properties: + packs: + type: array + items: + $ref: '#/components/schemas/PackInfo' + PackProviderInfo: + type: object + properties: + provider_id: + type: string + display_name: + type: string + platform: + type: string + ListPackProvidersResponse: + type: object + properties: + providers: + type: array + items: + $ref: '#/components/schemas/PackProviderInfo' + ImportBatchInfo: + type: object + properties: + batch_id: + type: integer + format: int64 + batch_status: + type: string + access_status: + type: string + ListImportBatchesResponse: + type: object + properties: + batches: + type: array + items: + $ref: '#/components/schemas/ImportBatchInfo' InstallPackRequest: type: object required: [host_base_url, pack_path] @@ -187,14 +525,19 @@ components: type: string PreviewProviderRequest: type: object - required: [host_base_url, pack_path, keys] + required: [host_id, pack_path, keys] properties: + host_id: + type: string host_base_url: type: string + description: legacy fallback; prefer host_id host_api_key: type: string + description: legacy fallback; prefer registered host auth host_bearer_token: type: string + description: legacy fallback; prefer registered host auth pack_path: type: string provider_id: @@ -207,14 +550,19 @@ components: type: string ImportProviderRequest: type: object - required: [host_base_url, pack_path, keys, access_api_key] + required: [host_id, pack_path, keys, access_api_key] properties: + host_id: + type: string host_base_url: type: string + description: legacy fallback; prefer host_id host_api_key: type: string + description: legacy fallback; prefer registered host auth host_bearer_token: type: string + description: legacy fallback; prefer registered host auth pack_path: type: string provider_id: @@ -237,29 +585,119 @@ components: type: integer RollbackProviderRequest: type: object - required: [host_base_url, pack_path] + required: [host_id, pack_path] properties: + host_id: + type: string host_base_url: type: string + description: legacy fallback; prefer host_id host_api_key: type: string + description: legacy fallback; prefer registered host auth host_bearer_token: type: string + description: legacy fallback; prefer registered host auth pack_path: type: string provider_id: type: string + RollbackBatchRequest: + type: object + required: [auth] + properties: + auth: + $ref: '#/components/schemas/CreateHostAuth' + RollbackSummaryResponse: + type: object + properties: + batch_id: + type: integer + format: int64 + deleted_accounts: + type: integer + deleted_plans: + type: integer + deleted_channels: + type: integer + deleted_groups: + type: integer ReconcileProviderRequest: type: object - required: [host_base_url, pack_path] + required: [host_id, pack_path] properties: + host_id: + type: string host_base_url: type: string + description: legacy fallback; prefer host_id host_api_key: type: string + description: legacy fallback; prefer registered host auth host_bearer_token: type: string + description: legacy fallback; prefer registered host auth + pack_path: + type: string + provider_id: + type: string + access_api_key: + type: string + AccessPreviewRequest: + type: object + properties: + provider_id: + type: string + pack_id: + type: string + host_id: + type: string + mode: + type: string + AccessPreviewResponse: + type: object + properties: + provider_id: + type: string + mode: + type: string + available: + type: boolean + message: + type: string + AssignAccessSubscriptionsRequest: + type: object + required: [host_id, pack_path, access_api_key] + properties: + host_id: + type: string pack_path: type: string provider_id: type: string + host_base_url: + type: string + description: legacy fallback; prefer host_id + host_api_key: + type: string + description: legacy fallback; prefer registered host auth + host_bearer_token: + type: string + description: legacy fallback; prefer registered host auth + access_api_key: + type: string + subscription_users: + type: array + items: + type: string + subscription_days: + type: integer + AssignAccessSubscriptionsResponse: + type: object + properties: + provider_id: + type: string + assigned: + type: integer + access_status: + type: string diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 80d0b181..639da5bc 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -399,6 +399,8 @@ func TestClassifyError(t *testing.T) { {name: "upstream error", err: &sub2api.HTTPError{Method: http.MethodGet, Path: "/x", StatusCode: http.StatusForbidden, Body: "nope"}, wantStatusCode: http.StatusBadGateway, wantCode: "host_request_failed", wantUpstream: http.StatusForbidden}, {name: "pack conflict already installed", err: errors.New("pack already installed"), wantStatusCode: http.StatusConflict, wantCode: "pack_conflict"}, {name: "pack conflict checksum drift", err: errors.New("checksum drift detected"), wantStatusCode: http.StatusConflict, wantCode: "pack_conflict"}, + {name: "reconcile blocked rolled_back", err: errors.New("latest import batch is rolled_back; run import again before reconcile"), wantStatusCode: http.StatusConflict, wantCode: "batch_not_reconcilable"}, + {name: "not found generic", err: errors.New("host x not found"), wantStatusCode: http.StatusNotFound, wantCode: "not_found"}, {name: "provider not found", err: errors.New("provider \"deepseek\" not found in pack \"openai\""), wantStatusCode: http.StatusBadRequest, wantCode: "provider_not_found"}, {name: "bad request pack path", err: errors.New("pack path is required"), wantStatusCode: http.StatusBadRequest, wantCode: "bad_request"}, {name: "bad request decode", err: errors.New("decode pack.json failed"), wantStatusCode: http.StatusBadRequest, wantCode: "bad_request"}, @@ -689,6 +691,45 @@ func TestPostHandlersFnNil(t *testing.T) { } } +func TestGetHandlersFnNil(t *testing.T) { + tests := []struct { + name string + path string + }{ + {name: "list-hosts", path: "/api/hosts"}, + {name: "get-host", path: "/api/hosts/my-host"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := NewAPIHandler("t", ActionSet{}) + req, _ := http.NewRequest(http.MethodGet, tt.path, nil) + req.Header.Set("Authorization", "Bearer t") + res := httptestRecorder(handler, req) + assertStatusCode(t, res, http.StatusInternalServerError) + assertJSONContains(t, res.Body().Bytes(), "error.code", "server_misconfigured") + }) + } +} + +func TestDeleteHandlersFnNil(t *testing.T) { + tests := []struct { + name string + path string + }{ + {name: "delete-host", path: "/api/hosts/my-host"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := NewAPIHandler("t", ActionSet{}) + req, _ := http.NewRequest(http.MethodDelete, tt.path, nil) + req.Header.Set("Authorization", "Bearer t") + res := httptestRecorder(handler, req) + assertStatusCode(t, res, http.StatusInternalServerError) + assertJSONContains(t, res.Body().Bytes(), "error.code", "server_misconfigured") + }) + } +} + func TestHandlerErrorPaths(t *testing.T) { tests := []struct { name string @@ -700,9 +741,9 @@ func TestHandlerErrorPaths(t *testing.T) { wantCode string }{ { - name: "access-status-error", + name: "access-status-error", method: http.MethodGet, - path: "/api/providers/x/access/status", + path: "/api/providers/x/access/status", actionSet: ActionSet{ GetProviderAccessStatus: func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) { return provision.ProviderSnapshot{}, errors.New("boom") @@ -712,10 +753,10 @@ func TestHandlerErrorPaths(t *testing.T) { wantCode: "internal_error", }, { - name: "preview-error", + name: "preview-error", method: http.MethodPost, - path: "/api/providers/x/preview-import", - body: `{}`, + path: "/api/providers/x/preview-import", + body: `{}`, actionSet: ActionSet{ PreviewProvider: func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error) { return provision.PreviewReport{}, errors.New("boom") @@ -725,10 +766,10 @@ func TestHandlerErrorPaths(t *testing.T) { wantCode: "internal_error", }, { - name: "rollback-error", + name: "rollback-error", method: http.MethodPost, - path: "/api/providers/x/rollback", - body: `{}`, + path: "/api/providers/x/rollback", + body: `{}`, actionSet: ActionSet{ RollbackProvider: func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error) { return provision.RollbackReport{}, errors.New("boom") @@ -738,10 +779,10 @@ func TestHandlerErrorPaths(t *testing.T) { wantCode: "internal_error", }, { - name: "reconcile-error", + name: "reconcile-error", method: http.MethodPost, - path: "/api/providers/x/reconcile", - body: `{}`, + path: "/api/providers/x/reconcile", + body: `{}`, actionSet: ActionSet{ ReconcileProvider: func(context.Context, ReconcileProviderRequest) (provision.ReconcileResult, error) { return provision.ReconcileResult{}, errors.New("boom") @@ -750,6 +791,66 @@ func TestHandlerErrorPaths(t *testing.T) { wantStatus: http.StatusInternalServerError, wantCode: "internal_error", }, + { + name: "list-hosts-error", + method: http.MethodGet, + path: "/api/hosts", + actionSet: ActionSet{ + ListHosts: func(context.Context) ([]HostInfo, error) { + return nil, errors.New("boom") + }, + }, + wantStatus: http.StatusInternalServerError, + wantCode: "internal_error", + }, + { + name: "get-host-error", + method: http.MethodGet, + path: "/api/hosts/my-host", + actionSet: ActionSet{ + GetHost: func(context.Context, string) (HostInfo, error) { + return HostInfo{}, errors.New("boom") + }, + }, + wantStatus: http.StatusInternalServerError, + wantCode: "internal_error", + }, + { + name: "get-host-not-found", + method: http.MethodGet, + path: "/api/hosts/unknown", + actionSet: ActionSet{ + GetHost: func(context.Context, string) (HostInfo, error) { + return HostInfo{}, errors.New("host unknown not found") + }, + }, + wantStatus: http.StatusNotFound, + wantCode: "not_found", + }, + { + name: "delete-host-error", + method: http.MethodDelete, + path: "/api/hosts/my-host", + actionSet: ActionSet{ + DeleteHost: func(context.Context, string) error { + return errors.New("boom") + }, + }, + wantStatus: http.StatusInternalServerError, + wantCode: "internal_error", + }, + { + name: "delete-host-not-found", + method: http.MethodDelete, + path: "/api/hosts/unknown", + actionSet: ActionSet{ + DeleteHost: func(context.Context, string) error { + return errors.New("host unknown not found") + }, + }, + wantStatus: http.StatusNotFound, + wantCode: "not_found", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -797,6 +898,34 @@ func TestProviderAccessStatusMultipleClosures(t *testing.T) { } } +func TestAccessStatusSupportsMode(t *testing.T) { + tests := []struct { + name string + status string + mode string + want bool + }{ + {name: "subscription ready supports subscription", status: provision.AccessStatusSubscriptionReady, mode: provision.AccessModeSubscription, want: true}, + {name: "subscription ready does not support self service", status: provision.AccessStatusSubscriptionReady, mode: provision.AccessModeSelfService, want: false}, + {name: "fully ready supports self service", status: provision.AccessStatusFullyReady, mode: provision.AccessModeSelfService, want: true}, + {name: "broken does not support any", status: provision.AccessStatusBroken, mode: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := accessStatusSupportsMode(tt.status, tt.mode); got != tt.want { + t.Fatalf("accessStatusSupportsMode(%q, %q) = %v, want %v", tt.status, tt.mode, got, tt.want) + } + }) + } +} + +func TestHostSupportStatusRequiresPlansCapability(t *testing.T) { + status := hostSupportStatus(sub2api.HostCapabilities{Groups: true, Channels: true, Plans: false, Accounts: true, AccountTest: true, AccountModels: true, Subscriptions: true}) + if status != "unsupported" { + t.Fatalf("hostSupportStatus() = %q, want unsupported when plans capability is missing", status) + } +} + func assertJSONContains(t *testing.T, payload []byte, key string, want any) { t.Helper() var decoded map[string]any @@ -870,6 +999,36 @@ func TestNewActionSetReturnsNonNil(t *testing.T) { t.Fatal("is nil") } }) + t.Run("ListHosts", func(t *testing.T) { + if as.ListHosts == nil { + t.Fatal("is nil") + } + }) + t.Run("GetHost", func(t *testing.T) { + if as.GetHost == nil { + t.Fatal("is nil") + } + }) + t.Run("DeleteHost", func(t *testing.T) { + if as.DeleteHost == nil { + t.Fatal("is nil") + } + }) + t.Run("ProbeHost", func(t *testing.T) { + if as.ProbeHost == nil { + t.Fatal("is nil") + } + }) + t.Run("ListProviderImportBatches", func(t *testing.T) { + if as.ListProviderImportBatches == nil { + t.Fatal("is nil") + } + }) + t.Run("RollbackBatch", func(t *testing.T) { + if as.RollbackBatch == nil { + t.Fatal("is nil") + } + }) } func TestBatchDetailReturnsNotFoundForMissingBatch(t *testing.T) { @@ -905,6 +1064,96 @@ func TestNewActionSetSQLiteClosures(t *testing.T) { t.Fatal("expected error from empty DB, got nil") } }) + + t.Run("ListHosts on empty DB", func(t *testing.T) { + hosts, err := as.ListHosts(ctx) + if err != nil { + t.Fatalf("ListHosts() on empty DB error = %v, want nil", err) + } + if len(hosts) != 0 { + t.Fatalf("ListHosts() len = %d, want 0", len(hosts)) + } + }) + + t.Run("GetHost on empty DB", func(t *testing.T) { + _, err := as.GetHost(ctx, "nonexistent") + if err == nil { + t.Fatal("expected error from empty DB, got nil") + } + }) + + t.Run("ListProviderImportBatches on empty DB", func(t *testing.T) { + batches, err := as.ListProviderImportBatches(ctx, ProviderQueryRequest{ProviderID: "x"}) + if err != nil { + t.Fatalf("ListProviderImportBatches() on empty DB error = %v, want nil", err) + } + if len(batches) != 0 { + t.Fatalf("ListProviderImportBatches() len = %d, want 0", len(batches)) + } + }) +} + +func TestAPIProbeHostReturnsHostSnapshot(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + ProbeHost: func(_ context.Context, req ProbeHostRequest) (HostInfo, error) { + if req.HostID != "prod-sub2api" { + t.Fatalf("ProbeHost hostID = %q, want prod-sub2api", req.HostID) + } + if req.Auth.Type != "bearer" || req.Auth.Token != "probe-token" { + t.Fatalf("ProbeHost auth = %#v, want bearer/probe-token", req.Auth) + } + return HostInfo{HostID: req.HostID, BaseURL: "https://sub2api.example.com", HostVersion: "0.1.126", Status: "supported"}, nil + }, + }) + req := httptestRequest(t, http.MethodPost, "/api/hosts/prod-sub2api/probe", map[string]any{ + "auth": map[string]any{"type": "bearer", "token": "probe-token"}, + }, "secret-token") + res := httptestRecorder(handler, req) + assertStatusCode(t, res, http.StatusOK) + assertJSONContains(t, res.Body().Bytes(), "host_id", "prod-sub2api") + assertJSONContains(t, res.Body().Bytes(), "host_version", "0.1.126") + assertJSONContains(t, res.Body().Bytes(), "status", "supported") +} + +func TestAPIListProviderImportBatchesReturnsItems(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + ListProviderImportBatches: func(_ context.Context, req ProviderQueryRequest) ([]ImportBatchInfo, error) { + if req.ProviderID != "deepseek" { + t.Fatalf("ListProviderImportBatches providerID = %q, want deepseek", req.ProviderID) + } + return []ImportBatchInfo{{BatchID: 7, BatchStatus: provision.BatchStatusSucceeded, AccessStatus: provision.AccessStatusSelfServiceReady}}, nil + }, + }) + req := httptestRequest(t, http.MethodGet, "/api/providers/deepseek/import-batches", nil, "secret-token") + res := httptestRecorder(handler, req) + assertStatusCode(t, res, http.StatusOK) + body := res.Body().String() + if !strings.Contains(body, `"batch_id":7`) || !strings.Contains(body, `"batch_status":"succeeded"`) || !strings.Contains(body, `"access_status":"self_service_ready"`) { + t.Fatalf("unexpected import batch payload: %s", body) + } +} + +func TestAPIRollbackBatchReturnsSummary(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + RollbackBatch: func(_ context.Context, req RollbackBatchRequest) (provision.RollbackReport, error) { + if req.BatchID != 11 { + t.Fatalf("RollbackBatch batchID = %d, want 11", req.BatchID) + } + if req.Auth.Type != "apikey" || req.Auth.Token != "admin-key" { + t.Fatalf("RollbackBatch auth = %#v, want apikey/admin-key", req.Auth) + } + return provision.RollbackReport{AccountsDeleted: 2, PlansDeleted: 1, ChannelsDeleted: 1, GroupsDeleted: 1}, nil + }, + }) + req := httptestRequest(t, http.MethodPost, "/api/import-batches/11/rollback", map[string]any{ + "auth": map[string]any{"type": "apikey", "token": "admin-key"}, + }, "secret-token") + res := httptestRecorder(handler, req) + assertStatusCode(t, res, http.StatusOK) + assertJSONContains(t, res.Body().Bytes(), "deleted_accounts", float64(2)) + assertJSONContains(t, res.Body().Bytes(), "deleted_plans", float64(1)) + assertJSONContains(t, res.Body().Bytes(), "deleted_channels", float64(1)) + assertJSONContains(t, res.Body().Bytes(), "deleted_groups", float64(1)) } func TestNewActionSetPackErrorPaths(t *testing.T) { diff --git a/internal/app/http_api.go b/internal/app/http_api.go index b754e688..84f1c083 100644 --- a/internal/app/http_api.go +++ b/internal/app/http_api.go @@ -14,18 +14,110 @@ import ( "sub2api-cn-relay-manager/internal/pack" "sub2api-cn-relay-manager/internal/provision" "sub2api-cn-relay-manager/internal/store/sqlite" + + "sub2api-cn-relay-manager/internal/access" ) type ActionSet struct { - InstallPack func(context.Context, InstallPackRequest) (provision.PackInstallResult, error) - BatchDetail func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error) - GetProviderStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) - GetProviderResources func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) - GetProviderAccessStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) - PreviewProvider func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error) - ImportProvider func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error) - RollbackProvider func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error) - ReconcileProvider func(context.Context, ReconcileProviderRequest) (provision.ReconcileResult, error) + InstallPack func(context.Context, InstallPackRequest) (provision.PackInstallResult, error) + BatchDetail func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error) + GetProviderStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) + GetProviderResources func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) + GetProviderAccessStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) + ListProviderImportBatches func(context.Context, ProviderQueryRequest) ([]ImportBatchInfo, error) + PreviewProvider func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error) + ImportProvider func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error) + RollbackProvider func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error) + RollbackBatch func(context.Context, RollbackBatchRequest) (provision.RollbackReport, error) + ReconcileProvider func(context.Context, ReconcileProviderRequest) (provision.ReconcileResult, error) + CreateHost func(context.Context, CreateHostRequest) (HostInfo, error) + ProbeHost func(context.Context, ProbeHostRequest) (HostInfo, error) + ListHosts func(context.Context) ([]HostInfo, error) + GetHost func(context.Context, string) (HostInfo, error) + DeleteHost func(context.Context, string) error + ListPacks func(context.Context) ([]PackInfo, error) + GetPack func(context.Context, string) (PackInfo, error) + ListPackProviders func(context.Context, string) ([]PackProviderInfo, error) + AssignAccessSubscriptions func(context.Context, AssignAccessSubscriptionsRequest) (AssignAccessSubscriptionsResult, error) + AccessPreview func(context.Context, AccessPreviewRequest) (AccessPreviewResult, error) +} + +type HostInfo struct { + HostID string `json:"host_id"` + BaseURL string `json:"base_url"` + HostVersion string `json:"host_version"` + AuthType string `json:"auth_type,omitempty"` + Status string `json:"status,omitempty"` + Capabilities *sub2api.HostCapabilities `json:"capabilities,omitempty"` +} + +type CreateHostRequest struct { + Name string `json:"name"` + BaseURL string `json:"base_url"` + Auth CreateHostAuth `json:"auth"` +} + +type ProbeHostRequest struct { + HostID string `json:"-"` + Auth CreateHostAuth `json:"auth"` +} + +type CreateHostAuth struct { + Type string `json:"type"` + Token string `json:"token"` +} + +type ImportBatchInfo struct { + BatchID int64 `json:"batch_id"` + BatchStatus string `json:"batch_status"` + AccessStatus string `json:"access_status"` +} + +type PackInfo struct { + PackID string `json:"pack_id"` + Version string `json:"version"` + Vendor string `json:"vendor,omitempty"` + TargetHost string `json:"target_host,omitempty"` + MinHostVersion string `json:"min_host_version,omitempty"` + MaxHostVersion string `json:"max_host_version,omitempty"` +} + +type PackProviderInfo struct { + ProviderID string `json:"provider_id"` + DisplayName string `json:"display_name"` + Platform string `json:"platform,omitempty"` +} + +type AssignAccessSubscriptionsRequest struct { + HostID string `json:"host_id,omitempty"` + PackPath string `json:"pack_path"` + ProviderID string `json:"provider_id"` + HostBaseURL string `json:"host_base_url,omitempty"` + HostAPIKey string `json:"host_api_key,omitempty"` + HostBearerToken string `json:"host_bearer_token,omitempty"` + AccessAPIKey string `json:"access_api_key"` + SubscriptionUsers []string `json:"subscription_users"` + SubscriptionDays int `json:"subscription_days"` +} + +type AssignAccessSubscriptionsResult struct { + ProviderID string `json:"provider_id"` + Assigned int `json:"assigned"` + AccessStatus string `json:"access_status"` +} + +type AccessPreviewRequest struct { + ProviderID string `json:"provider_id"` + PackID string `json:"pack_id,omitempty"` + HostID string `json:"host_id,omitempty"` + Mode string `json:"mode"` +} + +type AccessPreviewResult struct { + ProviderID string `json:"provider_id"` + Mode string `json:"mode"` + Available bool `json:"available"` + Message string `json:"message,omitempty"` } type InstallPackRequest struct { @@ -42,29 +134,38 @@ type BatchDetailRequest struct { type ProviderQueryRequest struct { ProviderID string PackID string + HostID string } type RollbackProviderRequest struct { - HostBaseURL string `json:"host_base_url"` - HostAPIKey string `json:"host_api_key"` - HostBearerToken string `json:"host_bearer_token"` + HostID string `json:"host_id,omitempty"` + HostBaseURL string `json:"host_base_url,omitempty"` + HostAPIKey string `json:"host_api_key,omitempty"` + HostBearerToken string `json:"host_bearer_token,omitempty"` PackPath string `json:"pack_path"` ProviderID string `json:"provider_id"` } +type RollbackBatchRequest struct { + BatchID int64 `json:"-"` + Auth CreateHostAuth `json:"auth"` +} + type ReconcileProviderRequest struct { - HostBaseURL string `json:"host_base_url"` - HostAPIKey string `json:"host_api_key"` - HostBearerToken string `json:"host_bearer_token"` + HostID string `json:"host_id,omitempty"` + HostBaseURL string `json:"host_base_url,omitempty"` + HostAPIKey string `json:"host_api_key,omitempty"` + HostBearerToken string `json:"host_bearer_token,omitempty"` PackPath string `json:"pack_path"` ProviderID string `json:"provider_id"` AccessAPIKey string `json:"access_api_key"` } type PreviewProviderRequest struct { - HostBaseURL string `json:"host_base_url"` - HostAPIKey string `json:"host_api_key"` - HostBearerToken string `json:"host_bearer_token"` + HostID string `json:"host_id,omitempty"` + HostBaseURL string `json:"host_base_url,omitempty"` + HostAPIKey string `json:"host_api_key,omitempty"` + HostBearerToken string `json:"host_bearer_token,omitempty"` PackPath string `json:"pack_path"` ProviderID string `json:"provider_id"` Keys []string `json:"keys"` @@ -72,9 +173,10 @@ type PreviewProviderRequest struct { } type ImportProviderRequest struct { - HostBaseURL string `json:"host_base_url"` - HostAPIKey string `json:"host_api_key"` - HostBearerToken string `json:"host_bearer_token"` + HostID string `json:"host_id,omitempty"` + HostBaseURL string `json:"host_base_url,omitempty"` + HostAPIKey string `json:"host_api_key,omitempty"` + HostBearerToken string `json:"host_bearer_token,omitempty"` PackPath string `json:"pack_path"` ProviderID string `json:"provider_id"` Keys []string `json:"keys"` @@ -102,6 +204,9 @@ func NewAPIHandler(adminToken string, actions ActionSet) http.Handler { mux.Handle("GET /api/import-batches/{batchID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handleBatchDetail(w, r, actions.BatchDetail) }))) + mux.Handle("POST /api/import-batches/{batchID}/rollback", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleRollbackBatch(w, r, actions.RollbackBatch) + }))) mux.Handle("GET /api/providers/{providerID}/status", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handleProviderStatus(w, r, actions.GetProviderStatus) }))) @@ -111,9 +216,27 @@ func NewAPIHandler(adminToken string, actions ActionSet) http.Handler { mux.Handle("GET /api/providers/{providerID}/access/status", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handleProviderAccessStatus(w, r, actions.GetProviderAccessStatus) }))) + mux.Handle("GET /api/providers/{providerID}/import-batches", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleListProviderImportBatches(w, r, actions.ListProviderImportBatches) + }))) + mux.Handle("POST /api/providers/{providerID}/access/assign-subscriptions", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleAssignAccessSubscriptions(w, r, actions.AssignAccessSubscriptions) + }))) + mux.Handle("POST /api/providers/{providerID}/access/preview", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleAccessPreview(w, r, actions.AccessPreview) + }))) mux.Handle("POST /api/packs/install", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handleInstallPack(w, r, actions.InstallPack) }))) + mux.Handle("GET /api/packs", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleListPacks(w, r, actions.ListPacks) + }))) + mux.Handle("GET /api/packs/{packID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleGetPack(w, r, actions.GetPack) + }))) + mux.Handle("GET /api/packs/{packID}/providers", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleListPackProviders(w, r, actions.ListPackProviders) + }))) mux.Handle("POST /api/providers/{providerID}/preview-import", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handlePreviewProvider(w, r, actions.PreviewProvider) }))) @@ -126,6 +249,21 @@ func NewAPIHandler(adminToken string, actions ActionSet) http.Handler { mux.Handle("POST /api/providers/{providerID}/reconcile", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handleReconcileProvider(w, r, actions.ReconcileProvider) }))) + mux.Handle("GET /api/hosts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleListHosts(w, r, actions.ListHosts) + }))) + mux.Handle("GET /api/hosts/{hostID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleGetHost(w, r, actions.GetHost) + }))) + mux.Handle("POST /api/hosts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleCreateHost(w, r, actions.CreateHost) + }))) + mux.Handle("POST /api/hosts/{hostID}/probe", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleProbeHost(w, r, actions.ProbeHost) + }))) + mux.Handle("DELETE /api/hosts/{hostID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleDeleteHost(w, r, actions.DeleteHost) + }))) return mux } @@ -188,6 +326,121 @@ func handleInstallPack(w http.ResponseWriter, r *http.Request, fn func(context.C }) } +func handleListPacks(w http.ResponseWriter, r *http.Request, fn func(context.Context) ([]PackInfo, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-packs action is not configured"}) + return + } + packs, err := fn(r.Context()) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + if packs == nil { + packs = []PackInfo{} + } + writeJSON(w, http.StatusOK, map[string]any{"packs": packs}) +} + +func handleListProviderImportBatches(w http.ResponseWriter, r *http.Request, fn func(context.Context, ProviderQueryRequest) ([]ImportBatchInfo, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-provider-import-batches action is not configured"}) + return + } + batches, err := fn(r.Context(), ProviderQueryRequest{ProviderID: r.PathValue("providerID"), PackID: r.URL.Query().Get("pack_id"), HostID: strings.TrimSpace(r.URL.Query().Get("host_id"))}) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + if batches == nil { + batches = []ImportBatchInfo{} + } + writeJSON(w, http.StatusOK, map[string]any{"batches": batches}) +} + +func handleAssignAccessSubscriptions(w http.ResponseWriter, r *http.Request, fn func(context.Context, AssignAccessSubscriptionsRequest) (AssignAccessSubscriptionsResult, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "assign-access-subscriptions action is not configured"}) + return + } + var req AssignAccessSubscriptionsRequest + if err := decodeJSON(r, &req); err != nil { + writeHTTPError(w, err) + return + } + req.ProviderID = r.PathValue("providerID") + result, err := fn(r.Context(), req) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + writeJSON(w, http.StatusOK, result) +} + +func handleAccessPreview(w http.ResponseWriter, r *http.Request, fn func(context.Context, AccessPreviewRequest) (AccessPreviewResult, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "access-preview action is not configured"}) + return + } + var req AccessPreviewRequest + if err := decodeJSON(r, &req); err != nil { + writeHTTPError(w, err) + return + } + req.ProviderID = r.PathValue("providerID") + if req.PackID == "" { + req.PackID = strings.TrimSpace(r.URL.Query().Get("pack_id")) + } + if req.HostID == "" { + req.HostID = strings.TrimSpace(r.URL.Query().Get("host_id")) + } + result, err := fn(r.Context(), req) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + writeJSON(w, http.StatusOK, result) +} + +func handleGetPack(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) (PackInfo, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "get-pack action is not configured"}) + return + } + packID := strings.TrimSpace(r.PathValue("packID")) + if packID == "" { + writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "pack_id is required"}) + return + } + pack, err := fn(r.Context(), packID) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + writeJSON(w, http.StatusOK, pack) +} + +func handleListPackProviders(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) ([]PackProviderInfo, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-pack-providers action is not configured"}) + return + } + packID := strings.TrimSpace(r.PathValue("packID")) + if packID == "" { + writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "pack_id is required"}) + return + } + providers, err := fn(r.Context(), packID) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + if providers == nil { + providers = []PackProviderInfo{} + } + writeJSON(w, http.StatusOK, map[string]any{"pack_id": packID, "providers": providers}) +} + func handleBatchDetail(w http.ResponseWriter, r *http.Request, fn func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error)) { if fn == nil { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "batch-detail action is not configured"}) @@ -239,7 +492,7 @@ func handleProviderStatus(w http.ResponseWriter, r *http.Request, fn func(contex writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "provider-status action is not configured"}) return } - result, err := fn(r.Context(), ProviderQueryRequest{ProviderID: r.PathValue("providerID"), PackID: strings.TrimSpace(r.URL.Query().Get("pack_id"))}) + result, err := fn(r.Context(), ProviderQueryRequest{ProviderID: r.PathValue("providerID"), PackID: strings.TrimSpace(r.URL.Query().Get("pack_id")), HostID: strings.TrimSpace(r.URL.Query().Get("host_id"))}) if err != nil { writeHTTPError(w, classifyError(err)) return @@ -264,7 +517,7 @@ func handleProviderAccessStatus(w http.ResponseWriter, r *http.Request, fn func( writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "provider-access-status action is not configured"}) return } - result, err := fn(r.Context(), ProviderQueryRequest{ProviderID: r.PathValue("providerID"), PackID: strings.TrimSpace(r.URL.Query().Get("pack_id"))}) + result, err := fn(r.Context(), ProviderQueryRequest{ProviderID: r.PathValue("providerID"), PackID: strings.TrimSpace(r.URL.Query().Get("pack_id")), HostID: strings.TrimSpace(r.URL.Query().Get("host_id"))}) if err != nil { writeHTTPError(w, classifyError(err)) return @@ -290,7 +543,7 @@ func handleProviderResources(w http.ResponseWriter, r *http.Request, fn func(con writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "provider-resources action is not configured"}) return } - result, err := fn(r.Context(), ProviderQueryRequest{ProviderID: r.PathValue("providerID"), PackID: strings.TrimSpace(r.URL.Query().Get("pack_id"))}) + result, err := fn(r.Context(), ProviderQueryRequest{ProviderID: r.PathValue("providerID"), PackID: strings.TrimSpace(r.URL.Query().Get("pack_id")), HostID: strings.TrimSpace(r.URL.Query().Get("host_id"))}) if err != nil { writeHTTPError(w, classifyError(err)) return @@ -409,6 +662,36 @@ func handleRollbackProvider(w http.ResponseWriter, r *http.Request, fn func(cont }) } +func handleRollbackBatch(w http.ResponseWriter, r *http.Request, fn func(context.Context, RollbackBatchRequest) (provision.RollbackReport, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "rollback-batch action is not configured"}) + return + } + var req RollbackBatchRequest + if err := decodeJSON(r, &req); err != nil { + writeHTTPError(w, err) + return + } + batchID, err := strconv.ParseInt(strings.TrimSpace(r.PathValue("batchID")), 10, 64) + if err != nil || batchID <= 0 { + writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "batch_id must be a positive integer"}) + return + } + req.BatchID = batchID + result, err := fn(r.Context(), req) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "batch_id": req.BatchID, + "deleted_accounts": result.AccountsDeleted, + "deleted_plans": result.PlansDeleted, + "deleted_channels": result.ChannelsDeleted, + "deleted_groups": result.GroupsDeleted, + }) +} + func handleReconcileProvider(w http.ResponseWriter, r *http.Request, fn func(context.Context, ReconcileProviderRequest) (provision.ReconcileResult, error)) { if fn == nil { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "reconcile-provider action is not configured"}) @@ -435,6 +718,98 @@ func handleReconcileProvider(w http.ResponseWriter, r *http.Request, fn func(con }) } +func handleListHosts(w http.ResponseWriter, r *http.Request, fn func(context.Context) ([]HostInfo, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-hosts action is not configured"}) + return + } + hosts, err := fn(r.Context()) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + if hosts == nil { + hosts = []HostInfo{} + } + writeJSON(w, http.StatusOK, map[string]any{"hosts": hosts}) +} + +func handleGetHost(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) (HostInfo, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "get-host action is not configured"}) + return + } + hostID := strings.TrimSpace(r.PathValue("hostID")) + if hostID == "" { + writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "host_id is required"}) + return + } + host, err := fn(r.Context(), hostID) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + writeJSON(w, http.StatusOK, host) +} + +func handleProbeHost(w http.ResponseWriter, r *http.Request, fn func(context.Context, ProbeHostRequest) (HostInfo, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "probe-host action is not configured"}) + return + } + var req ProbeHostRequest + if err := decodeJSON(r, &req); err != nil { + writeHTTPError(w, err) + return + } + req.HostID = strings.TrimSpace(r.PathValue("hostID")) + if req.HostID == "" { + writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "host_id is required"}) + return + } + host, err := fn(r.Context(), req) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + writeJSON(w, http.StatusOK, host) +} + +func handleCreateHost(w http.ResponseWriter, r *http.Request, fn func(context.Context, CreateHostRequest) (HostInfo, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "create-host action is not configured"}) + return + } + var req CreateHostRequest + if err := decodeJSON(r, &req); err != nil { + writeHTTPError(w, err) + return + } + result, err := fn(r.Context(), req) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + writeJSON(w, http.StatusOK, result) +} + +func handleDeleteHost(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "delete-host action is not configured"}) + return + } + hostID := strings.TrimSpace(r.PathValue("hostID")) + if hostID == "" { + writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "host_id is required"}) + return + } + if err := fn(r.Context(), hostID); err != nil { + writeHTTPError(w, classifyError(err)) + return + } + w.WriteHeader(http.StatusNoContent) +} + func decodeJSON(r *http.Request, dest any) *httpError { decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() @@ -476,8 +851,12 @@ func classifyError(err error) *httpError { switch { case strings.Contains(message, "already installed") || strings.Contains(message, "checksum drift"): return &httpError{StatusCode: http.StatusConflict, Code: "pack_conflict", Message: message} + case strings.Contains(message, "run import again before reconcile"): + return &httpError{StatusCode: http.StatusConflict, Code: "batch_not_reconcilable", Message: message} case strings.Contains(message, "not found in pack"): return &httpError{StatusCode: http.StatusBadRequest, Code: "provider_not_found", Message: message} + case strings.Contains(message, "not found"): + return &httpError{StatusCode: http.StatusNotFound, Code: "not_found", Message: message} case strings.Contains(message, "pack path") || strings.Contains(message, "pack dir") || strings.Contains(message, "required") || strings.Contains(message, "decode"): return &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: message} default: @@ -518,7 +897,7 @@ func NewActionSet(sqliteDSN string) ActionSet { return provision.ProviderSnapshot{}, err } defer store.Close() - return provision.NewProviderStatusService(store).GetStatus(ctx, provision.ProviderQuery{ProviderID: req.ProviderID, PackID: req.PackID}) + return provision.NewProviderStatusService(store).GetStatus(ctx, provision.ProviderQuery{ProviderID: req.ProviderID, PackID: req.PackID, HostID: req.HostID}) }, GetProviderResources: func(ctx context.Context, req ProviderQueryRequest) (provision.ProviderSnapshot, error) { store, err := sqlite.Open(ctx, sqliteDSN) @@ -526,7 +905,7 @@ func NewActionSet(sqliteDSN string) ActionSet { return provision.ProviderSnapshot{}, err } defer store.Close() - return provision.NewProviderStatusService(store).GetResources(ctx, provision.ProviderQuery{ProviderID: req.ProviderID, PackID: req.PackID}) + return provision.NewProviderStatusService(store).GetResources(ctx, provision.ProviderQuery{ProviderID: req.ProviderID, PackID: req.PackID, HostID: req.HostID}) }, GetProviderAccessStatus: func(ctx context.Context, req ProviderQueryRequest) (provision.ProviderSnapshot, error) { store, err := sqlite.Open(ctx, sqliteDSN) @@ -534,7 +913,49 @@ func NewActionSet(sqliteDSN string) ActionSet { return provision.ProviderSnapshot{}, err } defer store.Close() - return provision.NewProviderStatusService(store).GetStatus(ctx, provision.ProviderQuery{ProviderID: req.ProviderID, PackID: req.PackID}) + return provision.NewProviderStatusService(store).GetStatus(ctx, provision.ProviderQuery{ProviderID: req.ProviderID, PackID: req.PackID, HostID: req.HostID}) + }, + ListProviderImportBatches: func(ctx context.Context, req ProviderQueryRequest) ([]ImportBatchInfo, error) { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return nil, err + } + defer store.Close() + providers, err := resolveProvidersForQuery(ctx, store, req) + if err != nil { + return nil, err + } + batches := make([]ImportBatchInfo, 0) + for _, providerRow := range providers { + var rows []sqlite.ImportBatch + if strings.TrimSpace(req.HostID) != "" { + hostRow, err := store.Hosts().GetByHostID(ctx, req.HostID) + if err != nil { + return nil, err + } + rows, err = store.ImportBatches().ListByProviderIDAndHostID(ctx, providerRow.ID, hostRow.ID) + if err != nil { + return nil, err + } + } else { + rows, err = store.ImportBatches().ListByProviderID(ctx, providerRow.ID) + if err != nil { + return nil, err + } + if len(rows) > 1 { + firstHostID := rows[0].HostID + for _, row := range rows[1:] { + if row.HostID != firstHostID { + return nil, fmt.Errorf("provider exists on multiple hosts; host_id is required") + } + } + } + } + for _, batch := range rows { + batches = append(batches, ImportBatchInfo{BatchID: batch.ID, BatchStatus: batch.BatchStatus, AccessStatus: batch.AccessStatus}) + } + } + return batches, nil }, PreviewProvider: func(ctx context.Context, req PreviewProviderRequest) (provision.PreviewReport, error) { loadedPack, err := pack.LoadPath(req.PackPath) @@ -545,7 +966,12 @@ func NewActionSet(sqliteDSN string) ActionSet { if err != nil { return provision.PreviewReport{}, err } - client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken)) + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return provision.PreviewReport{}, err + } + defer store.Close() + _, client, err := resolveManagedHost(ctx, store, req.HostID, req.HostBaseURL, createHostAuthFromLegacyFields(req.HostAPIKey, req.HostBearerToken)) if err != nil { return provision.PreviewReport{}, err } @@ -561,22 +987,23 @@ func NewActionSet(sqliteDSN string) ActionSet { if err != nil { return provision.RuntimeImportResult{}, err } - client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken)) - if err != nil { - return provision.RuntimeImportResult{}, err - } store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return provision.RuntimeImportResult{}, err } defer store.Close() + hostRow, client, err := resolveManagedHost(ctx, store, req.HostID, req.HostBaseURL, createHostAuthFromLegacyFields(req.HostAPIKey, req.HostBearerToken)) + if err != nil { + return provision.RuntimeImportResult{}, err + } subscriptions := make([]provision.SubscriptionTarget, 0, len(req.SubscriptionUsers)) for _, userID := range req.SubscriptionUsers { subscriptions = append(subscriptions, provision.SubscriptionTarget{UserID: userID, DurationDays: req.SubscriptionDays}) } service := provision.NewRuntimeImportService(store, client) return service.Import(ctx, provision.RuntimeImportRequest{ - HostBaseURL: req.HostBaseURL, + HostID: hostRow.HostID, + HostBaseURL: hostRow.BaseURL, Pack: loadedPack, Provider: providerManifest, Mode: req.Mode, @@ -597,12 +1024,78 @@ func NewActionSet(sqliteDSN string) ActionSet { if err != nil { return provision.RollbackReport{}, err } - client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken)) + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return provision.RollbackReport{}, err + } + defer store.Close() + hostRow, client, err := resolveManagedHost(ctx, store, req.HostID, req.HostBaseURL, createHostAuthFromLegacyFields(req.HostAPIKey, req.HostBearerToken)) + if err != nil { + return provision.RollbackReport{}, err + } + + packRow, err := store.Packs().GetByPackID(ctx, loadedPack.Manifest.PackID) + if err != nil { + return provision.RollbackReport{}, err + } + providerRow, err := store.Providers().GetByPackIDAndProviderID(ctx, packRow.ID, providerManifest.ProviderID) + if err != nil { + return provision.RollbackReport{}, err + } + batch, err := store.ImportBatches().GetLatestByProviderIDAndHostID(ctx, providerRow.ID, hostRow.ID) + if err != nil { + return provision.RollbackReport{}, fmt.Errorf("find latest batch for provider %q on host %q: %w", providerManifest.ProviderID, hostRow.HostID, err) + } + managedResources, err := store.ManagedResources().GetByBatchID(ctx, batch.ID) + if err != nil { + return provision.RollbackReport{}, err + } + if len(managedResources) == 0 { + return provision.RollbackReport{}, fmt.Errorf("rollback requires stored managed resources for provider %q on host %q", providerManifest.ProviderID, hostRow.HostID) + } + service := provision.NewRollbackService(client) + report, rollbackErr := service.RollbackStoredResources(ctx, managedResources) + if rollbackErr != nil { + _ = store.ImportBatches().UpdateStatus(ctx, batch.ID, provision.BatchStatusFailed, batch.AccessStatus) + return report, rollbackErr + } + if err := store.ImportBatches().UpdateStatus(ctx, batch.ID, provision.BatchStatusRolledBack, batch.AccessStatus); err != nil { + return report, fmt.Errorf("rollback resources succeeded but update batch status: %w", err) + } + return report, nil + }, + RollbackBatch: func(ctx context.Context, req RollbackBatchRequest) (provision.RollbackReport, error) { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return provision.RollbackReport{}, err + } + defer store.Close() + batch, err := store.ImportBatches().GetByID(ctx, req.BatchID) + if err != nil { + return provision.RollbackReport{}, err + } + hostRow, err := store.Hosts().GetByID(ctx, batch.HostID) + if err != nil { + return provision.RollbackReport{}, err + } + client, err := newSub2APIClient(hostRow.BaseURL, authFromStoredHost(hostRow)) + if err != nil { + return provision.RollbackReport{}, err + } + managedResources, err := store.ManagedResources().GetByBatchID(ctx, batch.ID) if err != nil { return provision.RollbackReport{}, err } service := provision.NewRollbackService(client) - return service.Rollback(ctx, provision.RollbackRequest{Provider: providerManifest}) + report, rollbackErr := service.RollbackStoredResources(ctx, managedResources) + if rollbackErr != nil { + _ = store.ImportBatches().UpdateStatus(ctx, batch.ID, provision.BatchStatusFailed, batch.AccessStatus) + return report, rollbackErr + } + if err := store.ImportBatches().UpdateStatus(ctx, batch.ID, provision.BatchStatusRolledBack, batch.AccessStatus); err != nil { + return report, fmt.Errorf("rollback resources succeeded but update batch status: %w", err) + } + return report, nil }, ReconcileProvider: func(ctx context.Context, req ReconcileProviderRequest) (provision.ReconcileResult, error) { loadedPack, err := pack.LoadPath(req.PackPath) @@ -613,17 +1106,305 @@ func NewActionSet(sqliteDSN string) ActionSet { if err != nil { return provision.ReconcileResult{}, err } - client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken)) - if err != nil { - return provision.ReconcileResult{}, err - } store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return provision.ReconcileResult{}, err } defer store.Close() + hostRow, client, err := resolveManagedHost(ctx, store, req.HostID, req.HostBaseURL, createHostAuthFromLegacyFields(req.HostAPIKey, req.HostBearerToken)) + if err != nil { + return provision.ReconcileResult{}, err + } service := provision.NewReconcileService(store, client) - return service.Reconcile(ctx, provision.ReconcileRequest{HostBaseURL: req.HostBaseURL, AccessProbeAPIKey: req.AccessAPIKey, Pack: loadedPack, Provider: providerManifest}) + return service.Reconcile(ctx, provision.ReconcileRequest{HostID: hostRow.HostID, HostBaseURL: hostRow.BaseURL, AccessProbeAPIKey: req.AccessAPIKey, Pack: loadedPack, Provider: providerManifest}) + }, + CreateHost: func(ctx context.Context, req CreateHostRequest) (HostInfo, error) { + if strings.TrimSpace(req.BaseURL) == "" { + return HostInfo{}, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "base_url is required"} + } + client, err := newSub2APIClient(req.BaseURL, req.Auth) + if err != nil { + return HostInfo{}, err + } + + hostVersion, capabilities, err := probeHostSnapshot(ctx, client) + if err != nil { + return HostInfo{}, err + } + capabilityJSON, err := json.Marshal(capabilities) + if err != nil { + return HostInfo{}, fmt.Errorf("marshal capabilities: %w", err) + } + + name := strings.TrimSpace(req.Name) + if name == "" { + name = req.BaseURL + } + + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return HostInfo{}, err + } + defer store.Close() + if existing, err := store.Hosts().GetByBaseURL(ctx, req.BaseURL); err == nil && existing.HostID != name { + return HostInfo{}, &httpError{StatusCode: http.StatusConflict, Code: "host_conflict", Message: fmt.Sprintf("base_url %s already registered as host_id %s", req.BaseURL, existing.HostID)} + } + hostRecord := sqlite.Host{HostID: name, BaseURL: req.BaseURL, HostVersion: hostVersion, CapabilityProbeJSON: string(capabilityJSON), AuthType: req.Auth.Type, AuthToken: req.Auth.Token} + if _, err := store.Hosts().GetByHostID(ctx, name); err == nil { + if err := store.Hosts().UpdateConnectionByHostID(ctx, name, req.BaseURL, hostVersion, string(capabilityJSON), req.Auth.Type, req.Auth.Token); err != nil { + return HostInfo{}, fmt.Errorf("update host: %w", err) + } + } else { + if _, err := store.Hosts().Create(ctx, hostRecord); err != nil { + return HostInfo{}, fmt.Errorf("save host: %w", err) + } + } + stored, err := store.Hosts().GetByHostID(ctx, name) + if err != nil { + return HostInfo{}, err + } + return hostRecordToInfo(stored), nil + }, + ProbeHost: func(ctx context.Context, req ProbeHostRequest) (HostInfo, error) { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return HostInfo{}, err + } + defer store.Close() + hostRow, err := store.Hosts().GetByHostID(ctx, req.HostID) + if err != nil { + return HostInfo{}, err + } + client, err := newSub2APIClient(hostRow.BaseURL, authFromStoredHost(hostRow)) + if err != nil { + return HostInfo{}, err + } + hostVersion, capabilities, err := probeHostSnapshot(ctx, client) + if err != nil { + return HostInfo{}, err + } + capabilityJSON, err := json.Marshal(capabilities) + if err != nil { + return HostInfo{}, fmt.Errorf("marshal capabilities: %w", err) + } + if err := store.Hosts().UpdateProbeByHostID(ctx, req.HostID, hostVersion, string(capabilityJSON)); err != nil { + return HostInfo{}, err + } + return HostInfo{HostID: hostRow.HostID, BaseURL: hostRow.BaseURL, HostVersion: hostVersion, Status: hostSupportStatus(capabilities), Capabilities: &capabilities}, nil + }, + ListHosts: func(ctx context.Context) ([]HostInfo, error) { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return nil, err + } + defer store.Close() + hosts, err := store.Hosts().ListAll(ctx) + if err != nil { + return nil, err + } + result := make([]HostInfo, 0, len(hosts)) + for _, host := range hosts { + result = append(result, hostRecordToInfo(host)) + } + return result, nil + }, + GetHost: func(ctx context.Context, hostID string) (HostInfo, error) { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return HostInfo{}, err + } + defer store.Close() + host, err := store.Hosts().GetByHostID(ctx, hostID) + if err != nil { + return HostInfo{}, err + } + return hostRecordToInfo(host), nil + }, + DeleteHost: func(ctx context.Context, hostID string) error { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return err + } + defer store.Close() + return store.Hosts().DeleteByHostID(ctx, hostID) + }, + ListPacks: func(ctx context.Context) ([]PackInfo, error) { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return nil, err + } + defer store.Close() + packs, err := store.Packs().ListAll(ctx) + if err != nil { + return nil, err + } + result := make([]PackInfo, 0, len(packs)) + for _, p := range packs { + result = append(result, packRecordToInfo(p)) + } + return result, nil + }, + GetPack: func(ctx context.Context, packID string) (PackInfo, error) { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return PackInfo{}, err + } + defer store.Close() + pack, err := store.Packs().GetByPackID(ctx, packID) + if err != nil { + return PackInfo{}, err + } + return packRecordToInfo(pack), nil + }, + ListPackProviders: func(ctx context.Context, packID string) ([]PackProviderInfo, error) { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return nil, err + } + defer store.Close() + pack, err := store.Packs().GetByPackID(ctx, packID) + if err != nil { + return nil, err + } + providers, err := store.Providers().ListByPackID(ctx, pack.ID) + if err != nil { + return nil, err + } + result := make([]PackProviderInfo, 0, len(providers)) + for _, p := range providers { + result = append(result, PackProviderInfo{ + ProviderID: p.ProviderID, + DisplayName: p.DisplayName, + Platform: p.Platform, + }) + } + return result, nil + }, + AssignAccessSubscriptions: func(ctx context.Context, req AssignAccessSubscriptionsRequest) (AssignAccessSubscriptionsResult, error) { + loadedPack, err := pack.LoadPath(req.PackPath) + if err != nil { + return AssignAccessSubscriptionsResult{}, err + } + providerManifest, err := findProvider(loadedPack, req.ProviderID) + if err != nil { + return AssignAccessSubscriptionsResult{}, err + } + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return AssignAccessSubscriptionsResult{}, err + } + defer store.Close() + hostRow, client, err := resolveManagedHost(ctx, store, req.HostID, req.HostBaseURL, createHostAuthFromLegacyFields(req.HostAPIKey, req.HostBearerToken)) + if err != nil { + return AssignAccessSubscriptionsResult{}, err + } + + packRow, err := store.Packs().GetByPackID(ctx, loadedPack.Manifest.PackID) + if err != nil { + return AssignAccessSubscriptionsResult{}, err + } + providerRow, err := store.Providers().GetByPackIDAndProviderID(ctx, packRow.ID, providerManifest.ProviderID) + if err != nil { + return AssignAccessSubscriptionsResult{}, fmt.Errorf("provider %q not found in pack %q", req.ProviderID, loadedPack.Manifest.PackID) + } + batch, err := store.ImportBatches().GetLatestByProviderIDAndHostID(ctx, providerRow.ID, hostRow.ID) + if err != nil { + return AssignAccessSubscriptionsResult{}, fmt.Errorf("find batch for provider on host: %w", err) + } + + resources, err := store.ManagedResources().GetByBatchID(ctx, batch.ID) + if err != nil { + return AssignAccessSubscriptionsResult{}, err + } + groupID := "" + for _, r := range resources { + if r.ResourceType == "group" { + groupID = r.HostResourceID + break + } + } + if groupID == "" { + return AssignAccessSubscriptionsResult{}, fmt.Errorf("no group found for provider batch") + } + + subscriptions := make([]access.SubscriptionTarget, 0, len(req.SubscriptionUsers)) + for _, userID := range req.SubscriptionUsers { + subscriptions = append(subscriptions, access.SubscriptionTarget{UserID: userID, DurationDays: req.SubscriptionDays}) + } + + accessSvc := access.NewService(client) + gwResult, err := accessSvc.Close(ctx, access.ClosureRequest{Mode: access.ModeSubscription, ProbeAPIKey: req.AccessAPIKey, Subscriptions: subscriptions, GroupID: groupID, ExpectedModel: providerManifest.SmokeTestModel}) + if err != nil { + return AssignAccessSubscriptionsResult{}, err + } + + accessStatus := deriveAccessStatus(gwResult) + accessPayload, _ := json.Marshal(map[string]any{"status_code": gwResult.StatusCode, "ok": gwResult.OK, "has_expected_model": gwResult.HasExpectedModel, "models": gwResult.Models}) + if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batch.ID, ClosureType: access.ModeSubscription, Status: accessStatus, DetailsJSON: string(accessPayload)}); err != nil { + return AssignAccessSubscriptionsResult{}, fmt.Errorf("record access closure: %w", err) + } + if err := store.ImportBatches().UpdateStatus(ctx, batch.ID, batch.BatchStatus, accessStatus); err != nil { + return AssignAccessSubscriptionsResult{}, fmt.Errorf("update batch access status: %w", err) + } + + return AssignAccessSubscriptionsResult{ProviderID: req.ProviderID, Assigned: len(req.SubscriptionUsers), AccessStatus: accessStatus}, nil + }, + AccessPreview: func(ctx context.Context, req AccessPreviewRequest) (AccessPreviewResult, error) { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return AccessPreviewResult{}, err + } + defer store.Close() + + providers, err := resolveProvidersForQuery(ctx, store, ProviderQueryRequest{ProviderID: req.ProviderID, PackID: req.PackID, HostID: req.HostID}) + if err != nil { + return AccessPreviewResult{}, err + } + if len(providers) == 0 { + return AccessPreviewResult{}, fmt.Errorf("provider %q not found", req.ProviderID) + } + if len(providers) > 1 { + return AccessPreviewResult{}, fmt.Errorf("provider %q exists in multiple packs; pack_id is required", req.ProviderID) + } + providerRow := providers[0] + if strings.TrimSpace(req.HostID) != "" { + hostRow, err := store.Hosts().GetByHostID(ctx, req.HostID) + if err != nil { + return AccessPreviewResult{}, err + } + batch, err := store.ImportBatches().GetLatestByProviderIDAndHostID(ctx, providerRow.ID, hostRow.ID) + if err != nil { + return AccessPreviewResult{}, fmt.Errorf("find batch for provider: %w", err) + } + latestStatus := batch.AccessStatus + closures, err := store.AccessClosures().GetByBatchID(ctx, batch.ID) + if err == nil && len(closures) > 0 { + latestStatus = closures[len(closures)-1].Status + } + available := accessStatusSupportsMode(latestStatus, req.Mode) + message := fmt.Sprintf("latest access status: %s", latestStatus) + if !available { + message = fmt.Sprintf("access status %s does not satisfy mode %s", latestStatus, req.Mode) + } + return AccessPreviewResult{ProviderID: req.ProviderID, Mode: req.Mode, Available: available, Message: message}, nil + } + batch, err := store.ImportBatches().GetLatestByProviderID(ctx, providerRow.ID) + if err != nil { + return AccessPreviewResult{}, fmt.Errorf("find batch for provider: %w", err) + } + + latestStatus := batch.AccessStatus + closures, err := store.AccessClosures().GetByBatchID(ctx, batch.ID) + if err == nil && len(closures) > 0 { + latestStatus = closures[len(closures)-1].Status + } + available := accessStatusSupportsMode(latestStatus, req.Mode) + message := fmt.Sprintf("latest access status: %s", latestStatus) + if !available { + message = fmt.Sprintf("access status %s does not satisfy mode %s", latestStatus, req.Mode) + } + + return AccessPreviewResult{ProviderID: req.ProviderID, Mode: req.Mode, Available: available, Message: message}, nil }, } } @@ -636,3 +1417,158 @@ func findProvider(loaded pack.LoadedPack, providerID string) (pack.ProviderManif } return pack.ProviderManifest{}, fmt.Errorf("provider %q not found in pack %q", providerID, loaded.Manifest.PackID) } + +func resolveProvidersForQuery(ctx context.Context, store *sqlite.DB, req ProviderQueryRequest) ([]sqlite.Provider, error) { + if store == nil { + return nil, fmt.Errorf("store is required") + } + providerID := strings.TrimSpace(req.ProviderID) + if providerID == "" { + return nil, fmt.Errorf("provider_id is required") + } + if packID := strings.TrimSpace(req.PackID); packID != "" { + packRow, err := store.Packs().GetByPackID(ctx, packID) + if err != nil { + return nil, err + } + providerRow, err := store.Providers().GetByPackIDAndProviderID(ctx, packRow.ID, providerID) + if err != nil { + return nil, err + } + return []sqlite.Provider{providerRow}, nil + } + return store.Providers().ListByProviderID(ctx, providerID) +} + +func resolveManagedHost(ctx context.Context, store *sqlite.DB, hostID, baseURL string, auth CreateHostAuth) (sqlite.Host, *sub2api.Client, error) { + if store == nil { + return sqlite.Host{}, nil, fmt.Errorf("store is required") + } + hostID = strings.TrimSpace(hostID) + baseURL = strings.TrimSpace(baseURL) + if hostID != "" { + hostRow, err := store.Hosts().GetByHostID(ctx, hostID) + if err != nil { + return sqlite.Host{}, nil, err + } + if baseURL != "" && baseURL != strings.TrimSpace(hostRow.BaseURL) { + return sqlite.Host{}, nil, fmt.Errorf("host %q base_url mismatch: registered=%s runtime=%s", hostID, hostRow.BaseURL, baseURL) + } + client, err := newSub2APIClient(hostRow.BaseURL, authFromStoredHost(hostRow)) + if err != nil { + return sqlite.Host{}, nil, err + } + return hostRow, client, nil + } + if baseURL == "" { + return sqlite.Host{}, nil, fmt.Errorf("host_id is required") + } + hostRow, err := store.Hosts().GetByBaseURL(ctx, baseURL) + if err != nil { + return sqlite.Host{}, nil, fmt.Errorf("host_id is required for unregistered host_base_url %q: %w", baseURL, err) + } + client, err := newSub2APIClient(hostRow.BaseURL, authFromStoredHost(hostRow)) + if err != nil { + return sqlite.Host{}, nil, err + } + return hostRow, client, nil +} + +func authFromStoredHost(host sqlite.Host) CreateHostAuth { + authType := strings.TrimSpace(host.AuthType) + if authType == "" { + authType = "apikey" + } + return CreateHostAuth{Type: authType, Token: strings.TrimSpace(host.AuthToken)} +} + +func createHostAuthFromLegacyFields(apiKey, bearerToken string) CreateHostAuth { + if token := strings.TrimSpace(bearerToken); token != "" { + return CreateHostAuth{Type: "bearer", Token: token} + } + return CreateHostAuth{Type: "apikey", Token: strings.TrimSpace(apiKey)} +} + +func newSub2APIClient(baseURL string, auth CreateHostAuth) (*sub2api.Client, error) { + authToken := strings.TrimSpace(auth.Token) + if authToken == "" { + return nil, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "auth.token is required"} + } + switch strings.ToLower(strings.TrimSpace(auth.Type)) { + case "bearer": + return sub2api.NewClient(baseURL, sub2api.WithBearerToken(authToken)) + case "apikey", "api_key", "": + return sub2api.NewClient(baseURL, sub2api.WithAPIKey(authToken)) + default: + return nil, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: fmt.Sprintf("unsupported auth type %q (supported: bearer, apikey)", auth.Type)} + } +} + +func probeHostSnapshot(ctx context.Context, client *sub2api.Client) (string, sub2api.HostCapabilities, error) { + hostVersion, err := client.GetHostVersion(ctx) + if err != nil { + return "", sub2api.HostCapabilities{}, fmt.Errorf("get host version: %w", err) + } + capabilities, err := client.ProbeCapabilities(ctx) + if err != nil { + return "", sub2api.HostCapabilities{}, fmt.Errorf("probe host capabilities: %w", err) + } + return hostVersion, capabilities, nil +} + +func hostSupportStatus(capabilities sub2api.HostCapabilities) string { + if !capabilities.Groups || !capabilities.Channels || !capabilities.Plans || !capabilities.Accounts || !capabilities.AccountTest || !capabilities.AccountModels || !capabilities.Subscriptions { + return "unsupported" + } + return "supported" +} + +func accessStatusSupportsMode(status, mode string) bool { + status = strings.TrimSpace(status) + mode = strings.TrimSpace(mode) + switch mode { + case "", "any": + return status != provision.AccessStatusBroken && status != "" + case provision.AccessModeSubscription: + return status == provision.AccessStatusSubscriptionReady || status == provision.AccessStatusFullyReady + case provision.AccessModeSelfService: + return status == provision.AccessStatusSelfServiceReady || status == provision.AccessStatusFullyReady + default: + return status == provision.AccessStatusFullyReady + } +} + +func hostRecordToInfo(host sqlite.Host) HostInfo { + info := HostInfo{ + HostID: host.HostID, + BaseURL: host.BaseURL, + HostVersion: host.HostVersion, + AuthType: strings.TrimSpace(host.AuthType), + } + if strings.TrimSpace(host.CapabilityProbeJSON) != "" && host.CapabilityProbeJSON != "{}" { + var caps sub2api.HostCapabilities + if err := json.Unmarshal([]byte(host.CapabilityProbeJSON), &caps); err == nil { + info.Capabilities = &caps + info.Status = hostSupportStatus(caps) + } + } + return info +} + +func packRecordToInfo(pack sqlite.Pack) PackInfo { + return PackInfo{ + PackID: pack.PackID, + Version: pack.Version, + Vendor: pack.Vendor, + TargetHost: pack.TargetHost, + MinHostVersion: pack.MinHostVersion, + MaxHostVersion: pack.MaxHostVersion, + } +} + +func deriveAccessStatus(gw sub2api.GatewayAccessResult) string { + if gw.OK && gw.HasExpectedModel { + return provision.AccessStatusSubscriptionReady + } + return provision.AccessStatusBroken +} diff --git a/internal/host/sub2api/accounts.go b/internal/host/sub2api/accounts.go index 6d40f07c..f29694f0 100644 --- a/internal/host/sub2api/accounts.go +++ b/internal/host/sub2api/accounts.go @@ -76,13 +76,53 @@ func decodeAccountRefs(body []byte) ([]AccountRef, error) { var wrapper struct { Data struct { - Items []AccountRef `json:"items"` + Items []AccountRef `json:"items"` + Results []struct { + ID json.RawMessage `json:"id"` + Name string `json:"name"` + Success bool `json:"success"` + } `json:"results"` } `json:"data"` } - if err := json.Unmarshal(body, &wrapper); err != nil { + if err := json.Unmarshal(body, &wrapper); err == nil { + if len(wrapper.Data.Items) > 0 { + return wrapper.Data.Items, nil + } + if len(wrapper.Data.Results) > 0 { + return decodeBatchAccountResults(wrapper.Data.Results) + } + } + + var batch struct { + Results []struct { + ID json.RawMessage `json:"id"` + Name string `json:"name"` + Success bool `json:"success"` + } `json:"results"` + } + if err := json.Unmarshal(body, &batch); err != nil { return nil, err } - return wrapper.Data.Items, nil + return decodeBatchAccountResults(batch.Results) +} + +func decodeBatchAccountResults(results []struct { + ID json.RawMessage `json:"id"` + Name string `json:"name"` + Success bool `json:"success"` +}) ([]AccountRef, error) { + refs := make([]AccountRef, 0, len(results)) + for _, item := range results { + if !item.Success { + continue + } + id, err := decodeFlexibleID(item.ID) + if err != nil { + return nil, err + } + refs = append(refs, AccountRef{ID: id, Name: item.Name}) + } + return refs, nil } func decodeAccountModels(body []byte) ([]AccountModel, error) { diff --git a/internal/host/sub2api/capability_probe.go b/internal/host/sub2api/capability_probe.go index ed24dbfe..f5b89ac2 100644 --- a/internal/host/sub2api/capability_probe.go +++ b/internal/host/sub2api/capability_probe.go @@ -8,27 +8,27 @@ import ( ) func (c *Client) ProbeCapabilities(ctx context.Context) (HostCapabilities, error) { - groups, err := c.probeEndpoint(ctx, http.MethodPost, "/api/v1/admin/groups", map[string]any{}) + groups, err := c.probeEndpoint(ctx, http.MethodGet, "/api/v1/admin/groups", nil) if err != nil { return HostCapabilities{}, err } - channels, err := c.probeEndpoint(ctx, http.MethodPost, "/api/v1/admin/channels", map[string]any{}) + channels, err := c.probeEndpoint(ctx, http.MethodGet, "/api/v1/admin/channels", nil) if err != nil { return HostCapabilities{}, err } - plans, err := c.probeEndpoint(ctx, http.MethodPost, "/api/v1/admin/payment/plans", map[string]any{}) + plans, err := c.probeEndpoint(ctx, http.MethodGet, "/api/v1/admin/payment/plans", nil) if err != nil { return HostCapabilities{}, err } - accounts, err := c.probeEndpoint(ctx, http.MethodPost, "/api/v1/admin/accounts", map[string]any{}) + accounts, err := c.probeEndpoint(ctx, http.MethodGet, "/api/v1/admin/accounts", nil) if err != nil { return HostCapabilities{}, err } - accountTest, err := c.probeEndpoint(ctx, http.MethodPost, "/api/v1/admin/accounts/__probe__/test", map[string]any{}) + accountTest, err := c.probeEndpoint(ctx, http.MethodGet, "/api/v1/admin/accounts/__probe__/test", nil) if err != nil { return HostCapabilities{}, err } @@ -38,7 +38,7 @@ func (c *Client) ProbeCapabilities(ctx context.Context) (HostCapabilities, error return HostCapabilities{}, err } - subscriptions, err := c.probeEndpoint(ctx, http.MethodPost, "/api/v1/admin/subscriptions/assign", map[string]any{}) + subscriptions, err := c.probeEndpoint(ctx, http.MethodGet, "/api/v1/admin/subscriptions/assign", nil) if err != nil { return HostCapabilities{}, err } @@ -55,7 +55,7 @@ func (c *Client) ProbeCapabilities(ctx context.Context) (HostCapabilities, error } func (c *Client) probeEndpoint(ctx context.Context, method, path string, requestBody any) (bool, error) { - statusCode, headers, body, err := c.perform(ctx, method, path, requestBody) + statusCode, _, body, err := c.perform(ctx, method, path, requestBody) if err != nil { return false, err } @@ -66,7 +66,7 @@ func (c *Client) probeEndpoint(ctx context.Context, method, path string, request case statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden: return false, newHTTPError(method, path, statusCode, body) case statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed: - return looksLikeExistingEndpoint(headers, body), nil + return false, nil case statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError: return true, nil default: diff --git a/internal/host/sub2api/client.go b/internal/host/sub2api/client.go index b5664b1f..bdc5fd25 100644 --- a/internal/host/sub2api/client.go +++ b/internal/host/sub2api/client.go @@ -42,8 +42,10 @@ type HostCapabilities struct { } type CreateGroupRequest struct { - Name string `json:"name"` - RateMultiplier float64 `json:"rate_multiplier"` + Name string `json:"name"` + Platform string `json:"platform,omitempty"` + RateMultiplier float64 `json:"rate_multiplier"` + SubscriptionType string `json:"subscription_type,omitempty"` } type GroupRef struct { @@ -108,7 +110,7 @@ type AccountModel struct { type AssignSubscriptionRequest struct { UserID string `json:"user_id"` GroupID string `json:"group_id"` - DurationDays int `json:"duration_days,omitempty"` + DurationDays int `json:"validity_days,omitempty"` } type SubscriptionRef struct { diff --git a/internal/host/sub2api/flexible_id.go b/internal/host/sub2api/flexible_id.go new file mode 100644 index 00000000..ca2f5b8f --- /dev/null +++ b/internal/host/sub2api/flexible_id.go @@ -0,0 +1,206 @@ +package sub2api + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +func decodeFlexibleID(raw json.RawMessage) (string, error) { + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return "", nil + } + + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + return asString, nil + } + + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var asNumber json.Number + if err := decoder.Decode(&asNumber); err == nil { + return asNumber.String(), nil + } + + return "", fmt.Errorf("unsupported id payload: %s", string(raw)) +} + +func flexibleIDValue(raw string) any { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + if id, err := strconv.ParseInt(trimmed, 10, 64); err == nil { + return id + } + return trimmed +} + +func flexibleIDSliceValues(raw []string) []any { + values := make([]any, 0, len(raw)) + for _, item := range raw { + values = append(values, flexibleIDValue(item)) + } + return values +} + +func (r CreateChannelRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Name string `json:"name"` + GroupIDs []any `json:"group_ids"` + }{ + Name: r.Name, + GroupIDs: flexibleIDSliceValues(r.GroupIDs), + }) +} + +func (r CreatePlanRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + GroupID any `json:"group_id"` + Name string `json:"name"` + Price float64 `json:"price"` + ValidityDays int `json:"validity_days"` + ValidityUnit string `json:"validity_unit"` + }{ + GroupID: flexibleIDValue(r.GroupID), + Name: r.Name, + Price: r.Price, + ValidityDays: r.ValidityDays, + ValidityUnit: r.ValidityUnit, + }) +} + +func (r CreateAccountRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Name string `json:"name"` + Platform string `json:"platform"` + Type string `json:"type"` + Credentials map[string]any `json:"credentials"` + GroupIDs []any `json:"group_ids"` + }{ + Name: r.Name, + Platform: r.Platform, + Type: r.Type, + Credentials: r.Credentials, + GroupIDs: flexibleIDSliceValues(r.GroupIDs), + }) +} + +func (r AssignSubscriptionRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + UserID any `json:"user_id"` + GroupID any `json:"group_id"` + DurationDays int `json:"validity_days,omitempty"` + }{ + UserID: flexibleIDValue(r.UserID), + GroupID: flexibleIDValue(r.GroupID), + DurationDays: r.DurationDays, + }) +} + +func (r *NamedResource) UnmarshalJSON(data []byte) error { + var aux struct { + ID json.RawMessage `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + id, err := decodeFlexibleID(aux.ID) + if err != nil { + return err + } + r.ID = id + r.Name = aux.Name + return nil +} + +func (r *GroupRef) UnmarshalJSON(data []byte) error { + var aux struct { + ID json.RawMessage `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + id, err := decodeFlexibleID(aux.ID) + if err != nil { + return err + } + r.ID = id + r.Name = aux.Name + return nil +} + +func (r *ChannelRef) UnmarshalJSON(data []byte) error { + var aux struct { + ID json.RawMessage `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + id, err := decodeFlexibleID(aux.ID) + if err != nil { + return err + } + r.ID = id + r.Name = aux.Name + return nil +} + +func (r *PlanRef) UnmarshalJSON(data []byte) error { + var aux struct { + ID json.RawMessage `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + id, err := decodeFlexibleID(aux.ID) + if err != nil { + return err + } + r.ID = id + r.Name = aux.Name + return nil +} + +func (r *AccountRef) UnmarshalJSON(data []byte) error { + var aux struct { + ID json.RawMessage `json:"id"` + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + Type string `json:"type,omitempty"` + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + id, err := decodeFlexibleID(aux.ID) + if err != nil { + return err + } + r.ID = id + r.Name = aux.Name + r.Platform = aux.Platform + r.Type = aux.Type + return nil +} + +func (r *SubscriptionRef) UnmarshalJSON(data []byte) error { + var aux struct { + ID json.RawMessage `json:"id"` + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + id, err := decodeFlexibleID(aux.ID) + if err != nil { + return err + } + r.ID = id + return nil +} diff --git a/internal/host/sub2api/sub2api_test.go b/internal/host/sub2api/sub2api_test.go index 62476d3f..84660d4b 100644 --- a/internal/host/sub2api/sub2api_test.go +++ b/internal/host/sub2api/sub2api_test.go @@ -2,6 +2,7 @@ package sub2api import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -234,6 +235,15 @@ func TestDecodeNamedResources(t *testing.T) { t.Fatalf("got %+v", resources) } }) + t.Run("numeric id", func(t *testing.T) { + resources, err := decodeNamedResources([]byte(`{"data":{"items":[{"id":1,"name":"default"}]}}`)) + if err != nil { + t.Fatal(err) + } + if len(resources) != 1 || resources[0].ID != "1" { + t.Fatalf("got %+v", resources) + } + }) t.Run("wrapper with items", func(t *testing.T) { resources, err := decodeNamedResources([]byte(`{"data":{"items":[{"id":"r2","name":"n2"}]}}`)) if err != nil { @@ -261,6 +271,15 @@ func TestDecodeAccountRefs(t *testing.T) { t.Fatalf("got %+v", refs) } }) + t.Run("numeric id", func(t *testing.T) { + refs, err := decodeAccountRefs([]byte(`{"data":{"items":[{"id":42}]}}`)) + if err != nil { + t.Fatal(err) + } + if len(refs) != 1 || refs[0].ID != "42" { + t.Fatalf("got %+v", refs) + } + }) t.Run("wrapper with items", func(t *testing.T) { refs, err := decodeAccountRefs([]byte(`{"data":{"items":[{"id":"a2"}]}}`)) if err != nil { @@ -270,6 +289,33 @@ func TestDecodeAccountRefs(t *testing.T) { t.Fatalf("got %+v", refs) } }) + t.Run("batch results", func(t *testing.T) { + refs, err := decodeAccountRefs([]byte(`{"success":1,"failed":0,"results":[{"name":"k1","id":123,"success":true}]}`)) + if err != nil { + t.Fatal(err) + } + if len(refs) != 1 || refs[0].ID != "123" || refs[0].Name != "k1" { + t.Fatalf("got %+v", refs) + } + }) + t.Run("batch results ignores failed items", func(t *testing.T) { + refs, err := decodeAccountRefs([]byte(`{"success":1,"failed":1,"results":[{"name":"k1","id":123,"success":true},{"name":"k2","id":456,"success":false}]}`)) + if err != nil { + t.Fatal(err) + } + if len(refs) != 1 || refs[0].ID != "123" { + t.Fatalf("got %+v", refs) + } + }) + t.Run("data wrapped batch results", func(t *testing.T) { + refs, err := decodeAccountRefs([]byte(`{"code":0,"message":"success","data":{"failed":0,"results":[{"id":5,"name":"deepseek-01","success":true}],"success":1}}`)) + if err != nil { + t.Fatal(err) + } + if len(refs) != 1 || refs[0].ID != "5" || refs[0].Name != "deepseek-01" { + t.Fatalf("got %+v", refs) + } + }) t.Run("invalid json", func(t *testing.T) { _, err := decodeAccountRefs([]byte(`not json`)) if err == nil { @@ -439,7 +485,6 @@ func TestNewHTTPError(t *testing.T) { } } - func TestPerformWithMockServer(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -514,6 +559,18 @@ func TestPerformWithMockServer(t *testing.T) { func TestCreateGroupWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Platform string `json:"platform"` + RateMultiplier float64 `json:"rate_multiplier"` + SubscriptionType string `json:"subscription_type"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.Name != "demo" || req.Platform != "openai" || req.RateMultiplier != 1.0 { + t.Fatalf("unexpected request: %+v", req) + } w.Write([]byte(`{"data":{"id":"g1","name":"demo"}}`)) })) defer srv.Close() @@ -522,7 +579,7 @@ func TestCreateGroupWithMock(t *testing.T) { if err != nil { t.Fatal(err) } - ref, err := client.CreateGroup(context.Background(), CreateGroupRequest{Name: "demo", RateMultiplier: 1.0}) + ref, err := client.CreateGroup(context.Background(), CreateGroupRequest{Name: "demo", Platform: "openai", RateMultiplier: 1.0}) if err != nil { t.Fatal(err) } @@ -533,26 +590,58 @@ func TestCreateGroupWithMock(t *testing.T) { func TestCreateChannelWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`{"data":{"id":"c1","name":"ch"}}`)) + var req struct { + Name string `json:"name"` + GroupIDs []int64 `json:"group_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.Name != "ch" { + t.Fatalf("name = %q, want ch", req.Name) + } + if len(req.GroupIDs) != 1 || req.GroupIDs[0] != 101 { + t.Fatalf("group_ids = %v, want [101]", req.GroupIDs) + } + w.Write([]byte(`{"data":{"id":201,"name":"ch"}}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) - _, err := client.CreateChannel(context.Background(), CreateChannelRequest{Name: "ch"}) + ref, err := client.CreateChannel(context.Background(), CreateChannelRequest{Name: "ch", GroupIDs: []string{"101"}}) if err != nil { t.Fatal(err) } + if ref.ID != "201" { + t.Fatalf("id = %q, want 201", ref.ID) + } } func TestCreatePlanWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`{"data":{"id":"p1","name":"plan"}}`)) + var req struct { + GroupID int64 `json:"group_id"` + Name string `json:"name"` + Price float64 `json:"price"` + ValidityDays int `json:"validity_days"` + ValidityUnit string `json:"validity_unit"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.GroupID != 101 || req.Name != "plan" || req.Price != 19.9 || req.ValidityDays != 30 || req.ValidityUnit != "day" { + t.Fatalf("unexpected request: %+v", req) + } + w.Write([]byte(`{"data":{"id":301,"name":"plan"}}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) - _, err := client.CreatePlan(context.Background(), CreatePlanRequest{Name: "plan"}) + ref, err := client.CreatePlan(context.Background(), CreatePlanRequest{GroupID: "101", Name: "plan", Price: 19.9, ValidityDays: 30, ValidityUnit: "day"}) if err != nil { t.Fatal(err) } + if ref.ID != "301" { + t.Fatalf("id = %q, want 301", ref.ID) + } } func TestDeleteWithMock(t *testing.T) { @@ -586,15 +675,26 @@ func TestDeleteWithMock(t *testing.T) { func TestAssignSubscriptionWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`{"data":{"id":"s1"}}`)) + var req struct { + UserID int64 `json:"user_id"` + GroupID int64 `json:"group_id"` + DurationDays int `json:"validity_days"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.UserID != 501 || req.GroupID != 101 || req.DurationDays != 30 { + t.Fatalf("unexpected request: %+v", req) + } + w.Write([]byte(`{"data":{"id":401}}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) - ref, err := client.AssignSubscription(context.Background(), AssignSubscriptionRequest{UserID: "u1"}) + ref, err := client.AssignSubscription(context.Background(), AssignSubscriptionRequest{UserID: "501", GroupID: "101", DurationDays: 30}) if err != nil { t.Fatal(err) } - if ref.ID != "s1" { + if ref.ID != "401" { t.Fatalf("id = %q", ref.ID) } } @@ -619,17 +719,39 @@ func TestCheckGatewayAccessWithMock(t *testing.T) { func TestBatchCreateAccountsWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`{"data":[{"id":"a1","name":"acct1"}]}`)) + var req struct { + Accounts []struct { + Name string `json:"name"` + Platform string `json:"platform"` + Type string `json:"type"` + Credentials map[string]any `json:"credentials"` + GroupIDs []int64 `json:"group_ids"` + } `json:"accounts"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if len(req.Accounts) != 1 { + t.Fatalf("accounts len = %d, want 1", len(req.Accounts)) + } + acct := req.Accounts[0] + if acct.Name != "acct1" || acct.Platform != "openai" || acct.Type != "apikey" { + t.Fatalf("unexpected account metadata: %+v", acct) + } + if len(acct.GroupIDs) != 1 || acct.GroupIDs[0] != 101 { + t.Fatalf("group_ids = %v, want [101]", acct.GroupIDs) + } + w.Write([]byte(`{"data":[{"id":601,"name":"acct1"}]}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) refs, err := client.BatchCreateAccounts(context.Background(), BatchCreateAccountsRequest{ - Accounts: []CreateAccountRequest{{Name: "acct1"}}, + Accounts: []CreateAccountRequest{{Name: "acct1", Platform: "openai", Type: "apikey", GroupIDs: []string{"101"}, Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://api.example.com"}}}, }) if err != nil { t.Fatal(err) } - if len(refs) != 1 || refs[0].ID != "a1" { + if len(refs) != 1 || refs[0].ID != "601" { t.Fatalf("got %+v", refs) } } @@ -638,7 +760,9 @@ func TestProbeCapabilitiesWithMock(t *testing.T) { callCount := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ - w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) @@ -649,6 +773,41 @@ func TestProbeCapabilitiesWithMock(t *testing.T) { if !caps.Groups || !caps.Channels || !caps.Plans || !caps.Accounts || !caps.AccountTest || !caps.AccountModels || !caps.Subscriptions { t.Fatalf("all capabilities should be true, got %+v", caps) } + if callCount != 7 { + t.Fatalf("callCount = %d, want 7", callCount) + } +} + +func TestProbeCapabilitiesDoesNotTreat404AsSupportForAccountOrSubscriptionRoutes(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/admin/groups", "/api/v1/admin/channels", "/api/v1/admin/payment/plans", "/api/v1/admin/accounts": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + case "/api/v1/admin/accounts/__probe__/test", "/api/v1/admin/accounts/__probe__/models", "/api/v1/admin/subscriptions/assign": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"not found"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + client, _ := NewClient(srv.URL, WithAPIKey("k")) + caps, err := client.ProbeCapabilities(context.Background()) + if err != nil { + t.Fatalf("ProbeCapabilities() error = %v", err) + } + if caps.AccountTest { + t.Fatal("AccountTest = true, want false on 404 probe route") + } + if caps.AccountModels { + t.Fatal("AccountModels = true, want false on 404 probe route") + } + if caps.Subscriptions { + t.Fatal("Subscriptions = true, want false on 404 probe route") + } } func TestListManagedResourcesWithMock(t *testing.T) { diff --git a/internal/pack/extra_test.go b/internal/pack/extra_test.go index 830268d8..5f0194af 100644 --- a/internal/pack/extra_test.go +++ b/internal/pack/extra_test.go @@ -88,7 +88,7 @@ func TestValidateProvidersRejectsInvalidProviderFields(t *testing.T) { DisplayName: "DeepSeek", BaseURL: "https://api.deepseek.com", Platform: "openai", - AccountType: "api", + AccountType: "apikey", DefaultModels: []string{"deepseek-chat"}, SmokeTestModel: "deepseek-chat", GroupTemplate: GroupTemplate{Name: "g"}, @@ -107,7 +107,7 @@ func TestValidateProvidersRejectsInvalidProviderFields(t *testing.T) { DisplayName: "DeepSeek", BaseURL: "", Platform: "openai", - AccountType: "api", + AccountType: "apikey", DefaultModels: []string{"deepseek-chat"}, SmokeTestModel: "deepseek-chat", GroupTemplate: GroupTemplate{Name: "g"}, @@ -126,7 +126,7 @@ func TestValidateProvidersRejectsInvalidProviderFields(t *testing.T) { DisplayName: "", BaseURL: "https://api.deepseek.com", Platform: "openai", - AccountType: "api", + AccountType: "apikey", DefaultModels: []string{"deepseek-chat"}, SmokeTestModel: "deepseek-chat", GroupTemplate: GroupTemplate{Name: "g"}, diff --git a/internal/pack/loader_test.go b/internal/pack/loader_test.go index 89974f0f..12f20dc7 100644 --- a/internal/pack/loader_test.go +++ b/internal/pack/loader_test.go @@ -26,7 +26,7 @@ func TestLoadDirParsesAndValidatesPack(t *testing.T) { "display_name": "DeepSeek OpenAI Compatible", "base_url": "https://api.deepseek.com", "platform": "openai", - "account_type": "api", + "account_type": "apikey", "default_models": ["deepseek-chat", "deepseek-reasoner"], "smoke_test_model": "deepseek-chat", "group_template": {"name": "DeepSeek 默认分组", "rate_multiplier": 1.0}, diff --git a/internal/pack/source_loader_test.go b/internal/pack/source_loader_test.go index b37d86ee..3646c7d9 100644 --- a/internal/pack/source_loader_test.go +++ b/internal/pack/source_loader_test.go @@ -2,8 +2,10 @@ package pack import ( "archive/zip" + "bufio" "os" "path/filepath" + "strings" "testing" ) @@ -46,10 +48,26 @@ func writePackArchive(t *testing.T, archivePath string) { defer writer.Close() sourceRoot := filepath.Join("..", "..", "packs", "openai-cn-pack") - files := []string{ - "pack.json", - "checksums.txt", - filepath.Join("providers", "deepseek.json"), + files := []string{"pack.json", "checksums.txt"} + checksumFile, err := os.Open(filepath.Join(sourceRoot, "checksums.txt")) + if err != nil { + t.Fatalf("os.Open(checksums.txt) error = %v", err) + } + defer checksumFile.Close() + scanner := bufio.NewScanner(checksumFile) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) != 2 { + t.Fatalf("invalid checksum line %q", line) + } + files = append(files, filepath.FromSlash(parts[1])) + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan checksums.txt error = %v", err) } for _, relativePath := range files { body, err := os.ReadFile(filepath.Join(sourceRoot, relativePath)) diff --git a/internal/provision/batch_detail_and_reconcile_service.go b/internal/provision/batch_detail_and_reconcile_service.go index 07099bd5..7765d510 100644 --- a/internal/provision/batch_detail_and_reconcile_service.go +++ b/internal/provision/batch_detail_and_reconcile_service.go @@ -47,7 +47,7 @@ func (s *BatchDetailService) Get(ctx context.Context, batchID int64) (BatchDetai if err != nil { return BatchDetailResult{}, err } - reconcileRuns, err := s.store.ReconcileRuns().GetByProviderID(ctx, batch.ProviderID) + reconcileRuns, err := s.store.ReconcileRuns().GetByBatchID(ctx, batchID) if err != nil { return BatchDetailResult{}, err } @@ -61,6 +61,7 @@ func (s *BatchDetailService) Get(ctx context.Context, batchID int64) (BatchDetai } type ReconcileRequest struct { + HostID string HostBaseURL string AccessProbeAPIKey string Pack pack.LoadedPack @@ -93,6 +94,9 @@ func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest) if s.host == nil { return ReconcileResult{}, fmt.Errorf("host adapter is required") } + if strings.TrimSpace(req.HostID) == "" { + return ReconcileResult{}, fmt.Errorf("host_id is required") + } if strings.TrimSpace(req.HostBaseURL) == "" { return ReconcileResult{}, fmt.Errorf("host_base_url is required") } @@ -111,11 +115,20 @@ func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest) if err != nil { return ReconcileResult{}, err } - batchRow, err := s.store.ImportBatches().GetLatestByProviderID(ctx, providerRow.ID) + hostRow, err := s.store.Hosts().GetByHostID(ctx, req.HostID) if err != nil { return ReconcileResult{}, err } - storedResources, err := s.store.ManagedResources().GetByBatchID(ctx, batchRow.ID) + batchRow, err := s.store.ImportBatches().GetLatestByProviderIDAndHostID(ctx, providerRow.ID, hostRow.ID) + if err != nil { + return ReconcileResult{}, err + } + switch strings.TrimSpace(batchRow.BatchStatus) { + case BatchStatusSucceeded, BatchStatusPartial: + default: + return ReconcileResult{}, fmt.Errorf("latest import batch is %s; run import again before reconcile", batchRow.BatchStatus) + } + storedResources, err := s.store.ManagedResources().ListByProviderIDAndHostID(ctx, providerRow.ID, hostRow.ID) if err != nil { return ReconcileResult{}, err } @@ -128,9 +141,10 @@ func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest) return ReconcileResult{}, err } snapshot, err := s.host.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{ - GroupName: SuggestResourceNames(req.Provider).Group, - ChannelName: SuggestResourceNames(req.Provider).Channel, - PlanName: SuggestResourceNames(req.Provider).Plan, + GroupName: SuggestResourceNames(req.Provider).Group, + ChannelName: SuggestResourceNames(req.Provider).Channel, + PlanName: SuggestResourceNames(req.Provider).Plan, + AccountNamePrefix: SuggestAccountNamePrefix(req.Provider), }) if err != nil { return ReconcileResult{}, fmt.Errorf("list managed resources: %w", err) @@ -162,7 +176,7 @@ func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest) if err != nil { return ReconcileResult{}, fmt.Errorf("marshal reconcile summary: %w", err) } - if _, err := s.store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{ProviderID: providerRow.ID, Status: status, SummaryJSON: string(summaryJSON)}); err != nil { + if _, err := s.store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{BatchID: batchRow.ID, HostID: hostRow.ID, ProviderID: providerRow.ID, Status: status, SummaryJSON: string(summaryJSON)}); err != nil { return ReconcileResult{}, err } return ReconcileResult{BatchID: batchRow.ID, Status: status, MissingCount: missing, ExtraCount: extra, ProbeFailureCount: probeFailures, AccessStatus: accessStatus, Summary: summary}, nil diff --git a/internal/provision/batch_detail_service_test.go b/internal/provision/batch_detail_service_test.go index 83b92b13..0107035e 100644 --- a/internal/provision/batch_detail_service_test.go +++ b/internal/provision/batch_detail_service_test.go @@ -33,7 +33,11 @@ func TestBatchDetailServiceGetReturnsPersistedArtifacts(t *testing.T) { if len(providerRow) != 1 { t.Fatalf("providers = %d, want 1", len(providerRow)) } - if _, err := store.ReconcileRuns().Create(context.Background(), sqlite.ReconcileRun{ProviderID: providerRow[0].ID, Status: "active", SummaryJSON: `{"missing_count":0}`}); err != nil { + batchRow, err := store.ImportBatches().GetByID(context.Background(), batchID) + if err != nil { + t.Fatalf("ImportBatches().GetByID() error = %v", err) + } + if _, err := store.ReconcileRuns().Create(context.Background(), sqlite.ReconcileRun{BatchID: batchID, HostID: batchRow.HostID, ProviderID: providerRow[0].ID, Status: "active", SummaryJSON: `{"missing_count":0}`}); err != nil { t.Fatalf("ReconcileRuns().Create() error = %v", err) } @@ -58,6 +62,59 @@ func TestBatchDetailServiceGetReturnsPersistedArtifacts(t *testing.T) { } } +func TestBatchDetailServiceScopesReconcileRunsByBatchID(t *testing.T) { + store := openProvisionTestStore(t) + defer closeProvisionTestStore(t, store) + + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_1": {OK: true, Status: "passed"}, + "account_2": {OK: true, Status: "passed"}, + }, + models: map[string][]sub2api.AccountModel{ + "account_1": {{ID: "deepseek-chat"}}, + "account_2": {{ID: "deepseek-chat"}}, + }, + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + } + + ctx := context.Background() + batchID := seedRuntimeImportForReconcile(t, store, host) + batchRow, err := store.ImportBatches().GetByID(ctx, batchID) + if err != nil { + t.Fatalf("ImportBatches().GetByID() error = %v", err) + } + providerRow, err := store.Providers().ListByProviderID(ctx, sampleProviderManifest().ProviderID) + if err != nil { + t.Fatalf("Providers().ListByProviderID() error = %v", err) + } + if len(providerRow) != 1 { + t.Fatalf("providers = %d, want 1", len(providerRow)) + } + secondBatchID, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: batchRow.HostID, PackID: batchRow.PackID, ProviderID: providerRow[0].ID, Mode: ImportModePartial, BatchStatus: BatchStatusSucceeded, AccessStatus: AccessStatusSelfServiceReady}) + if err != nil { + t.Fatalf("ImportBatches().Create(second) error = %v", err) + } + if _, err := store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{BatchID: batchID, HostID: batchRow.HostID, ProviderID: providerRow[0].ID, Status: "drifted", SummaryJSON: `{"batch":"first"}`}); err != nil { + t.Fatalf("ReconcileRuns().Create(first batch) error = %v", err) + } + if _, err := store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{BatchID: secondBatchID, HostID: batchRow.HostID, ProviderID: providerRow[0].ID, Status: "active", SummaryJSON: `{"batch":"second"}`}); err != nil { + t.Fatalf("ReconcileRuns().Create(second batch) error = %v", err) + } + + result, err := NewBatchDetailService(store).Get(ctx, secondBatchID) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if len(result.ReconcileRuns) != 1 { + t.Fatalf("len(ReconcileRuns) = %d, want 1 for this batch only", len(result.ReconcileRuns)) + } + if result.ReconcileRuns[0].Status != "active" { + t.Fatalf("ReconcileRuns[0].Status = %q, want active", result.ReconcileRuns[0].Status) + } +} + func TestBatchDetailServiceGetValidatesStore(t *testing.T) { _, err := (*BatchDetailService)(nil).Get(context.Background(), 1) if err == nil || err.Error() != "store is required" { @@ -225,6 +282,9 @@ func TestResourceSlugFallsBackToProvider(t *testing.T) { } provider := sampleProviderManifest() provider.ProviderID = " DeepSeek CN / Prod " + provider.GroupTemplate.Name = "" + provider.ChannelTemplate.Name = "" + provider.PlanTemplate.Name = "" if got := SuggestAccountNamePrefix(provider); got != "deepseek-cn-prod-" { t.Fatalf("SuggestAccountNamePrefix() = %q, want deepseek-cn-prod-", got) } diff --git a/internal/provision/import_service.go b/internal/provision/import_service.go index 0760bf54..54878f1a 100644 --- a/internal/provision/import_service.go +++ b/internal/provision/import_service.go @@ -18,9 +18,10 @@ const ( AccessModeSubscription = "subscription" AccessModeSelfService = "self_service" - BatchStatusSucceeded = "succeeded" - BatchStatusPartial = "partially_succeeded" - BatchStatusFailed = "failed" + BatchStatusSucceeded = "succeeded" + BatchStatusPartial = "partially_succeeded" + BatchStatusFailed = "failed" + BatchStatusRolledBack = "rolled_back" ProviderStatusActive = "active" ProviderStatusDegraded = "degraded" @@ -28,6 +29,7 @@ const ( AccessStatusSubscriptionReady = "subscription_ready" AccessStatusSelfServiceReady = "self_service_ready" + AccessStatusFullyReady = "fully_ready" AccessStatusBroken = "broken" ) @@ -73,6 +75,16 @@ type hostAdapter interface { CheckGatewayAccess(ctx context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error) } +type resolvedManagedResources struct { + Group sub2api.GroupRef + Channel sub2api.ChannelRef + Plan *sub2api.PlanRef + + CreatedGroup bool + CreatedChannel bool + CreatedPlan bool +} + type ImportService struct { host hostAdapter } @@ -107,42 +119,24 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I err = errors.Join(err, fmt.Errorf("rollback managed resources: %w", rollbackErr)) } }() - group, err := s.host.CreateGroup(ctx, sub2api.CreateGroupRequest{ - Name: req.Provider.GroupTemplate.Name, - RateMultiplier: req.Provider.GroupTemplate.RateMultiplier, - }) + resources, err := s.ensureManagedResources(ctx, req.Provider, req.Access.Mode) if err != nil { - return report, fmt.Errorf("create group: %w", err) + return report, err } - report.Group = group - rollback.AddGroup(group.ID) - - channel, err := s.host.CreateChannel(ctx, sub2api.CreateChannelRequest{ - Name: req.Provider.ChannelTemplate.Name, - GroupIDs: []string{group.ID}, - }) - if err != nil { - return report, fmt.Errorf("create channel: %w", err) + report.Group = resources.Group + report.Channel = resources.Channel + report.Plan = resources.Plan + if resources.CreatedGroup { + rollback.AddGroup(resources.Group.ID) } - report.Channel = channel - rollback.AddChannel(channel.ID) - - if req.Access.Mode == AccessModeSubscription { - plan, err := s.host.CreatePlan(ctx, sub2api.CreatePlanRequest{ - GroupID: group.ID, - Name: req.Provider.PlanTemplate.Name, - Price: req.Provider.PlanTemplate.Price, - ValidityDays: req.Provider.PlanTemplate.ValidityDays, - ValidityUnit: req.Provider.PlanTemplate.ValidityUnit, - }) - if err != nil { - return report, fmt.Errorf("create plan: %w", err) - } - report.Plan = &plan - rollback.AddPlan(plan.ID) + if resources.CreatedChannel { + rollback.AddChannel(resources.Channel.ID) + } + if resources.CreatedPlan && resources.Plan != nil { + rollback.AddPlan(resources.Plan.ID) } - accounts, err := s.host.BatchCreateAccounts(ctx, buildBatchAccountsRequest(req.Provider, group.ID, normalizedKeys)) + accounts, err := s.host.BatchCreateAccounts(ctx, buildBatchAccountsRequest(req.Provider, resources.Group.ID, normalizedKeys)) if err != nil { return report, fmt.Errorf("batch create accounts: %w", err) } @@ -178,7 +172,7 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I Mode: req.Access.Mode, ProbeAPIKey: req.Access.ProbeAPIKey, Subscriptions: toAccessSubscriptionTargets(req.Access.Subscriptions), - GroupID: group.ID, + GroupID: resources.Group.ID, ExpectedModel: req.Provider.SmokeTestModel, }) if err != nil { @@ -204,6 +198,84 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I return report, nil } +func (s *ImportService) ensureManagedResources(ctx context.Context, provider pack.ProviderManifest, accessMode string) (resolvedManagedResources, error) { + names := SuggestResourceNames(provider) + snapshot, err := s.host.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{ + GroupName: names.Group, + ChannelName: names.Channel, + PlanName: names.Plan, + }) + if err != nil { + return resolvedManagedResources{}, fmt.Errorf("list managed resources: %w", err) + } + + result := resolvedManagedResources{} + group, created, err := ensureGroup(ctx, s.host, snapshot.Groups, provider, accessMode) + if err != nil { + return resolvedManagedResources{}, fmt.Errorf("ensure group: %w", err) + } + result.Group = group + result.CreatedGroup = created + + channel, created, err := ensureChannel(ctx, s.host, snapshot.Channels, provider, group.ID) + if err != nil { + return resolvedManagedResources{}, fmt.Errorf("ensure channel: %w", err) + } + result.Channel = channel + result.CreatedChannel = created + + if accessMode == AccessModeSubscription { + plan, created, err := ensurePlan(ctx, s.host, snapshot.Plans, provider, group.ID) + if err != nil { + return resolvedManagedResources{}, fmt.Errorf("ensure plan: %w", err) + } + result.Plan = &plan + result.CreatedPlan = created + } + + return result, nil +} + +func ensureGroup(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, accessMode string) (sub2api.GroupRef, bool, error) { + switch len(existing) { + case 0: + groupReq := sub2api.CreateGroupRequest{Name: provider.GroupTemplate.Name, Platform: provider.Platform, RateMultiplier: provider.GroupTemplate.RateMultiplier} + if accessMode == AccessModeSubscription { + groupReq.SubscriptionType = "subscription" + } + group, err := host.CreateGroup(ctx, groupReq) + return group, true, err + case 1: + return sub2api.GroupRef{ID: existing[0].ID, Name: existing[0].Name}, false, nil + default: + return sub2api.GroupRef{}, false, fmt.Errorf("multiple groups already exist for %q", provider.GroupTemplate.Name) + } +} + +func ensureChannel(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, groupID string) (sub2api.ChannelRef, bool, error) { + switch len(existing) { + case 0: + channel, err := host.CreateChannel(ctx, sub2api.CreateChannelRequest{Name: provider.ChannelTemplate.Name, GroupIDs: []string{groupID}}) + return channel, true, err + case 1: + return sub2api.ChannelRef{ID: existing[0].ID, Name: existing[0].Name}, false, nil + default: + return sub2api.ChannelRef{}, false, fmt.Errorf("multiple channels already exist for %q", provider.ChannelTemplate.Name) + } +} + +func ensurePlan(ctx context.Context, host hostAdapter, existing []sub2api.NamedResource, provider pack.ProviderManifest, groupID string) (sub2api.PlanRef, bool, error) { + switch len(existing) { + case 0: + plan, err := host.CreatePlan(ctx, sub2api.CreatePlanRequest{GroupID: groupID, Name: provider.PlanTemplate.Name, Price: provider.PlanTemplate.Price, ValidityDays: provider.PlanTemplate.ValidityDays, ValidityUnit: provider.PlanTemplate.ValidityUnit}) + return plan, true, err + case 1: + return sub2api.PlanRef{ID: existing[0].ID, Name: existing[0].Name}, false, nil + default: + return sub2api.PlanRef{}, false, fmt.Errorf("multiple plans already exist for %q", provider.PlanTemplate.Name) + } +} + func validateMode(mode string) error { switch strings.TrimSpace(mode) { case ImportModeStrict, ImportModePartial: diff --git a/internal/provision/import_service_test.go b/internal/provision/import_service_test.go index c451d795..22aaac16 100644 --- a/internal/provision/import_service_test.go +++ b/internal/provision/import_service_test.go @@ -56,6 +56,12 @@ func TestImportServiceImportSubscriptionFlow(t *testing.T) { if len(host.assignedSubscriptions) != 1 { t.Fatalf("assigned subscriptions = %d, want 1", len(host.assignedSubscriptions)) } + if host.createGroupReq.SubscriptionType != "subscription" { + t.Fatalf("CreateGroup subscription_type = %q, want %q", host.createGroupReq.SubscriptionType, "subscription") + } + if host.createGroupReq.Platform != "openai" { + t.Fatalf("CreateGroup platform = %q, want %q", host.createGroupReq.Platform, "openai") + } if host.gatewayProbe.ExpectedModel != "deepseek-chat" { t.Fatalf("gateway probe model = %q, want %q", host.gatewayProbe.ExpectedModel, "deepseek-chat") } @@ -135,13 +141,49 @@ func TestImportServiceStrictModeRollsBackCreatedResources(t *testing.T) { } } +func TestImportServiceReusesExistingManagedResources(t *testing.T) { + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_1"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_1": {OK: true, Status: "passed"}, + }, + models: map[string][]sub2api.AccountModel{ + "account_1": {{ID: "deepseek-chat"}}, + }, + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + managedSnapshot: sub2api.ManagedResourceSnapshot{ + Groups: []sub2api.NamedResource{{ID: "group_existing", Name: "DeepSeek 默认分组"}}, + }, + } + + svc := NewImportService(host) + report, err := svc.Import(context.Background(), ImportRequest{ + Provider: sampleProviderManifest(), + Mode: ImportModePartial, + Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"}, + Keys: []string{"key-1"}, + }) + if err != nil { + t.Fatalf("Import() error = %v", err) + } + if report.Group.ID != "group_existing" { + t.Fatalf("Group.ID = %q, want reused group_existing", report.Group.ID) + } + if host.createGroupCalls != 0 { + t.Fatalf("CreateGroup() calls = %d, want 0 when group already exists", host.createGroupCalls) + } + if host.createChannelCalls != 1 { + t.Fatalf("CreateChannel() calls = %d, want 1", host.createChannelCalls) + } +} + func sampleProviderManifest() pack.ProviderManifest { return pack.ProviderManifest{ ProviderID: "deepseek", DisplayName: "DeepSeek OpenAI Compatible", BaseURL: "https://api.deepseek.com", Platform: "openai", - AccountType: "api", + AccountType: "apikey", DefaultModels: []string{"deepseek-chat", "deepseek-reasoner"}, SmokeTestModel: "deepseek-chat", GroupTemplate: pack.GroupTemplate{Name: "DeepSeek 默认分组", RateMultiplier: 1}, @@ -163,6 +205,11 @@ type fakeHostAdapter struct { gatewayProbe sub2api.GatewayAccessCheckRequest deletedResources []string managedSnapshot sub2api.ManagedResourceSnapshot + listManagedReq sub2api.ListManagedResourcesRequest + createGroupCalls int + createChannelCalls int + createPlanCalls int + createGroupReq sub2api.CreateGroupRequest } func (f *fakeHostAdapter) GetHostVersion(context.Context) (string, error) { @@ -174,7 +221,9 @@ func (f *fakeHostAdapter) GetHostVersion(context.Context) (string, error) { func (f *fakeHostAdapter) ProbeCapabilities(context.Context) (sub2api.HostCapabilities, error) { return sub2api.HostCapabilities{}, nil } -func (f *fakeHostAdapter) CreateGroup(context.Context, sub2api.CreateGroupRequest) (sub2api.GroupRef, error) { +func (f *fakeHostAdapter) CreateGroup(_ context.Context, req sub2api.CreateGroupRequest) (sub2api.GroupRef, error) { + f.createGroupCalls++ + f.createGroupReq = req return sub2api.GroupRef{ID: "group_1", Name: "g"}, nil } func (f *fakeHostAdapter) DeleteGroup(_ context.Context, groupID string) error { @@ -182,6 +231,7 @@ func (f *fakeHostAdapter) DeleteGroup(_ context.Context, groupID string) error { return nil } func (f *fakeHostAdapter) CreateChannel(context.Context, sub2api.CreateChannelRequest) (sub2api.ChannelRef, error) { + f.createChannelCalls++ return sub2api.ChannelRef{ID: "channel_1", Name: "c"}, nil } func (f *fakeHostAdapter) DeleteChannel(_ context.Context, channelID string) error { @@ -189,6 +239,7 @@ func (f *fakeHostAdapter) DeleteChannel(_ context.Context, channelID string) err return nil } func (f *fakeHostAdapter) CreatePlan(context.Context, sub2api.CreatePlanRequest) (sub2api.PlanRef, error) { + f.createPlanCalls++ return sub2api.PlanRef{ID: "plan_1", Name: "p"}, nil } func (f *fakeHostAdapter) DeletePlan(_ context.Context, planID string) error { @@ -236,6 +287,7 @@ func (f *fakeHostAdapter) CheckGatewayAccess(_ context.Context, req sub2api.Gate } return f.gatewayResult, nil } -func (f *fakeHostAdapter) ListManagedResources(context.Context, sub2api.ListManagedResourcesRequest) (sub2api.ManagedResourceSnapshot, error) { +func (f *fakeHostAdapter) ListManagedResources(_ context.Context, req sub2api.ListManagedResourcesRequest) (sub2api.ManagedResourceSnapshot, error) { + f.listManagedReq = req return f.managedSnapshot, nil } diff --git a/internal/provision/naming.go b/internal/provision/naming.go index 8aef13be..27d2aae8 100644 --- a/internal/provision/naming.go +++ b/internal/provision/naming.go @@ -23,9 +23,9 @@ func SuggestAccountNamePrefix(provider pack.ProviderManifest) string { func SuggestResourceNames(provider pack.ProviderManifest) ResourceNames { slug := resourceSlug(provider.ProviderID) return ResourceNames{ - Group: fmt.Sprintf("crm-%s-group", slug), - Channel: fmt.Sprintf("crm-%s-channel", slug), - Plan: fmt.Sprintf("crm-%s-plan", slug), + Group: fallbackString(strings.TrimSpace(provider.GroupTemplate.Name), fmt.Sprintf("crm-%s-group", slug)), + Channel: fallbackString(strings.TrimSpace(provider.ChannelTemplate.Name), fmt.Sprintf("crm-%s-channel", slug)), + Plan: fallbackString(strings.TrimSpace(provider.PlanTemplate.Name), fmt.Sprintf("crm-%s-plan", slug)), } } @@ -38,3 +38,12 @@ func resourceSlug(raw string) string { } return slug } + +func fallbackString(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/provision/preview_service.go b/internal/provision/preview_service.go index 5252a4c7..b5b7e879 100644 --- a/internal/provision/preview_service.go +++ b/internal/provision/preview_service.go @@ -59,9 +59,10 @@ func (s *PreviewService) PreviewImport(ctx context.Context, req PreviewRequest) names := SuggestResourceNames(req.Provider) snapshot, err := s.host.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{ - GroupName: names.Group, - ChannelName: names.Channel, - PlanName: names.Plan, + GroupName: names.Group, + ChannelName: names.Channel, + PlanName: names.Plan, + AccountNamePrefix: SuggestAccountNamePrefix(req.Provider), }) if err != nil { return PreviewReport{}, fmt.Errorf("list managed resources: %w", err) diff --git a/internal/provision/preview_service_test.go b/internal/provision/preview_service_test.go index 3b2b2d5c..d693158c 100644 --- a/internal/provision/preview_service_test.go +++ b/internal/provision/preview_service_test.go @@ -14,9 +14,9 @@ func TestSuggestResourceNames(t *testing.T) { names := SuggestResourceNames(provider) want := ResourceNames{ - Group: "crm-deepseek-group", - Channel: "crm-deepseek-channel", - Plan: "crm-deepseek-plan", + Group: "DeepSeek 默认分组", + Channel: "DeepSeek 默认渠道", + Plan: "DeepSeek 默认套餐", } if !reflect.DeepEqual(names, want) { t.Fatalf("SuggestResourceNames() = %#v, want %#v", names, want) @@ -52,9 +52,9 @@ func TestPreviewServiceReportsCreateActionsWhenHostHasNoResources(t *testing.T) func TestPreviewServiceReportsReuseAndConflict(t *testing.T) { host := &fakePreviewHost{snapshot: sub2api.ManagedResourceSnapshot{ - Groups: []sub2api.NamedResource{{ID: "group_1", Name: "crm-deepseek-group"}}, - Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "crm-deepseek-channel"}, {ID: "channel_2", Name: "crm-deepseek-channel"}}, - Plans: []sub2api.NamedResource{{ID: "plan_1", Name: "crm-deepseek-plan"}}, + Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}}, + Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}, {ID: "channel_2", Name: "DeepSeek 默认渠道"}}, + Plans: []sub2api.NamedResource{{ID: "plan_1", Name: "DeepSeek 默认套餐"}}, }} svc := NewPreviewService(host) @@ -78,10 +78,30 @@ func TestPreviewServiceReportsReuseAndConflict(t *testing.T) { } } -type fakePreviewHost struct { - snapshot sub2api.ManagedResourceSnapshot +func TestPreviewServicePassesAccountNamePrefixToManagedResourceSnapshot(t *testing.T) { + host := &fakePreviewHost{} + svc := NewPreviewService(host) + + provider := sampleProviderManifest() + provider.ProviderID = "openai-zhongzhuan" + if _, err := svc.PreviewImport(context.Background(), PreviewRequest{ + Provider: provider, + Mode: ImportModePartial, + Keys: []string{"key-1"}, + }); err != nil { + t.Fatalf("PreviewImport() error = %v", err) + } + if host.req.AccountNamePrefix != "openai-zhongzhuan-" { + t.Fatalf("AccountNamePrefix = %q, want %q", host.req.AccountNamePrefix, "openai-zhongzhuan-") + } } -func (f *fakePreviewHost) ListManagedResources(context.Context, sub2api.ListManagedResourcesRequest) (sub2api.ManagedResourceSnapshot, error) { +type fakePreviewHost struct { + snapshot sub2api.ManagedResourceSnapshot + req sub2api.ListManagedResourcesRequest +} + +func (f *fakePreviewHost) ListManagedResources(_ context.Context, req sub2api.ListManagedResourcesRequest) (sub2api.ManagedResourceSnapshot, error) { + f.req = req return f.snapshot, nil } diff --git a/internal/provision/provider_status_service.go b/internal/provision/provider_status_service.go index f10889c0..16577d8c 100644 --- a/internal/provision/provider_status_service.go +++ b/internal/provision/provider_status_service.go @@ -12,6 +12,7 @@ import ( type ProviderQuery struct { ProviderID string PackID string + HostID string } type ProviderSnapshot struct { @@ -56,15 +57,11 @@ func (s *ProviderStatusService) snapshot(ctx context.Context, query ProviderQuer if err != nil { return ProviderSnapshot{}, err } - batchRow, err := s.store.ImportBatches().GetLatestByProviderID(ctx, provider.ID) + hostRow, batchRow, err := s.resolveHostAndBatch(ctx, provider.ID, query.HostID) if err != nil { return ProviderSnapshot{}, err } - hostRow, err := s.store.Hosts().GetByID(ctx, batchRow.HostID) - if err != nil { - return ProviderSnapshot{}, err - } - managedResources, err := s.store.ManagedResources().GetByBatchID(ctx, batchRow.ID) + managedResources, err := s.store.ManagedResources().ListByProviderIDAndHostID(ctx, provider.ID, hostRow.ID) if err != nil { return ProviderSnapshot{}, err } @@ -72,7 +69,7 @@ func (s *ProviderStatusService) snapshot(ctx context.Context, query ProviderQuer if err != nil { return ProviderSnapshot{}, err } - reconcileRuns, err := s.store.ReconcileRuns().GetByProviderID(ctx, provider.ID) + reconcileRuns, err := s.store.ReconcileRuns().GetByProviderIDAndHostID(ctx, provider.ID, hostRow.ID) if err != nil { return ProviderSnapshot{}, err } @@ -132,6 +129,39 @@ func (s *ProviderStatusService) resolveProvider(ctx context.Context, query Provi return providers[0], nil } +func (s *ProviderStatusService) resolveHostAndBatch(ctx context.Context, providerID int64, hostQuery string) (sqlite.Host, sqlite.ImportBatch, error) { + if strings.TrimSpace(hostQuery) != "" { + hostRow, err := s.store.Hosts().GetByHostID(ctx, hostQuery) + if err != nil { + return sqlite.Host{}, sqlite.ImportBatch{}, err + } + batchRow, err := s.store.ImportBatches().GetLatestByProviderIDAndHostID(ctx, providerID, hostRow.ID) + if err != nil { + return sqlite.Host{}, sqlite.ImportBatch{}, err + } + return hostRow, batchRow, nil + } + + batches, err := s.store.ImportBatches().ListByProviderID(ctx, providerID) + if err != nil { + return sqlite.Host{}, sqlite.ImportBatch{}, err + } + if len(batches) == 0 { + return sqlite.Host{}, sqlite.ImportBatch{}, fmt.Errorf("latest import batch not found for provider") + } + hostID := batches[0].HostID + for _, batch := range batches[1:] { + if batch.HostID != hostID { + return sqlite.Host{}, sqlite.ImportBatch{}, fmt.Errorf("provider exists on multiple hosts; host_id is required") + } + } + hostRow, err := s.store.Hosts().GetByID(ctx, hostID) + if err != nil { + return sqlite.Host{}, sqlite.ImportBatch{}, err + } + return hostRow, batches[0], nil +} + func deriveProviderStatus(batchStatus, reconcileStatus string) string { reconcileStatus = strings.TrimSpace(reconcileStatus) if reconcileStatus != "" && reconcileStatus != "not_run" { @@ -142,6 +172,8 @@ func deriveProviderStatus(batchStatus, reconcileStatus string) string { return ProviderStatusActive case BatchStatusFailed: return ProviderStatusFailed + case BatchStatusRolledBack: + return ProviderStatusFailed case "running": return "running" default: diff --git a/internal/provision/provider_status_service_test.go b/internal/provision/provider_status_service_test.go index acf9d7fc..e83fbeef 100644 --- a/internal/provision/provider_status_service_test.go +++ b/internal/provision/provider_status_service_test.go @@ -28,20 +28,20 @@ func TestProviderStatusServiceReturnsLatestSnapshot(t *testing.T) { if err != nil { t.Fatalf("ImportBatches().Create() error = %v", err) } - if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: batchID, ResourceType: "group", HostResourceID: "group-1", ResourceName: "deepseek-group"}); err != nil { + if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: batchID, HostID: hostID, ResourceType: "group", HostResourceID: "group-1", ResourceName: "deepseek-group"}); err != nil { t.Fatalf("ManagedResources().Create(group) error = %v", err) } - if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: batchID, ResourceType: "account", HostResourceID: "account-1", ResourceName: "deepseek-account-1"}); err != nil { + if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: batchID, HostID: hostID, ResourceType: "account", HostResourceID: "account-1", ResourceName: "deepseek-account-1"}); err != nil { t.Fatalf("ManagedResources().Create(account) error = %v", err) } if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batchID, ClosureType: AccessModeSelfService, Status: AccessStatusSelfServiceReady, DetailsJSON: `{"ok":true}`}); err != nil { t.Fatalf("AccessClosures().Create() error = %v", err) } - if _, err := store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{ProviderID: providerID, Status: "drifted", SummaryJSON: `{"missing_count":1}`}); err != nil { + if _, err := store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{BatchID: batchID, HostID: hostID, ProviderID: providerID, Status: "drifted", SummaryJSON: `{"missing_count":1}`}); err != nil { t.Fatalf("ReconcileRuns().Create() error = %v", err) } - snapshot, err := NewProviderStatusService(store).GetStatus(ctx, ProviderQuery{ProviderID: "deepseek"}) + snapshot, err := NewProviderStatusService(store).GetStatus(ctx, ProviderQuery{ProviderID: "deepseek", HostID: "host-1"}) if err != nil { t.Fatalf("GetStatus() error = %v", err) } @@ -71,6 +71,95 @@ func TestProviderStatusServiceReturnsLatestSnapshot(t *testing.T) { } } +func TestProviderStatusServiceIncludesManagedResourcesFromEarlierBatch(t *testing.T) { + store := openProvisionTestStore(t) + defer closeProvisionTestStore(t, store) + + ctx := context.Background() + hostID, err := store.Hosts().Create(ctx, sqlite.Host{HostID: "host-1", BaseURL: "https://sub2api.example.com", HostVersion: "0.1.126", CapabilityProbeJSON: `{}`}) + if err != nil { + t.Fatalf("Hosts().Create() error = %v", err) + } + packID, err := store.Packs().Create(ctx, sqlite.Pack{PackID: "openai-cn-pack", Version: "1.0.0", Checksum: "checksum-1"}) + if err != nil { + t.Fatalf("Packs().Create() error = %v", err) + } + providerID, err := store.Providers().Create(ctx, sqlite.Provider{PackID: packID, ProviderID: "deepseek", DisplayName: "DeepSeek", BaseURL: "https://api.deepseek.com", Platform: "openai"}) + if err != nil { + t.Fatalf("Providers().Create() error = %v", err) + } + firstBatch, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostID, PackID: packID, ProviderID: providerID, Mode: ImportModePartial, BatchStatus: BatchStatusSucceeded, AccessStatus: AccessStatusSelfServiceReady}) + if err != nil { + t.Fatalf("ImportBatches().Create(first) error = %v", err) + } + if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: firstBatch, HostID: hostID, ResourceType: "group", HostResourceID: "group-1", ResourceName: "deepseek-group"}); err != nil { + t.Fatalf("ManagedResources().Create(group) error = %v", err) + } + secondBatch, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostID, PackID: packID, ProviderID: providerID, Mode: ImportModePartial, BatchStatus: BatchStatusSucceeded, AccessStatus: AccessStatusSelfServiceReady}) + if err != nil { + t.Fatalf("ImportBatches().Create(second) error = %v", err) + } + if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: secondBatch, ClosureType: AccessModeSelfService, Status: AccessStatusSelfServiceReady, DetailsJSON: `{"ok":true}`}); err != nil { + t.Fatalf("AccessClosures().Create() error = %v", err) + } + + snapshot, err := NewProviderStatusService(store).GetStatus(ctx, ProviderQuery{ProviderID: "deepseek", HostID: "host-1"}) + if err != nil { + t.Fatalf("GetStatus() error = %v", err) + } + if got := len(snapshot.ManagedResources); got != 1 { + t.Fatalf("len(ManagedResources) = %d, want 1 resource retained from earlier batch", got) + } + if snapshot.ManagedResources[0].HostResourceID != "group-1" { + t.Fatalf("HostResourceID = %q, want group-1", snapshot.ManagedResources[0].HostResourceID) + } +} + +func TestProviderStatusServiceScopesReconcileRunsByHost(t *testing.T) { + store := openProvisionTestStore(t) + defer closeProvisionTestStore(t, store) + + ctx := context.Background() + hostA, err := store.Hosts().Create(ctx, sqlite.Host{HostID: "host-a", BaseURL: "https://a.example.com", HostVersion: "0.1.126", CapabilityProbeJSON: `{}`}) + if err != nil { + t.Fatalf("Hosts().Create(host-a) error = %v", err) + } + hostB, err := store.Hosts().Create(ctx, sqlite.Host{HostID: "host-b", BaseURL: "https://b.example.com", HostVersion: "0.1.126", CapabilityProbeJSON: `{}`}) + if err != nil { + t.Fatalf("Hosts().Create(host-b) error = %v", err) + } + packID, err := store.Packs().Create(ctx, sqlite.Pack{PackID: "openai-cn-pack", Version: "1.0.0", Checksum: "checksum-1"}) + if err != nil { + t.Fatalf("Packs().Create() error = %v", err) + } + providerID, err := store.Providers().Create(ctx, sqlite.Provider{PackID: packID, ProviderID: "deepseek", DisplayName: "DeepSeek", BaseURL: "https://api.deepseek.com", Platform: "openai"}) + if err != nil { + t.Fatalf("Providers().Create() error = %v", err) + } + batchA, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostA, PackID: packID, ProviderID: providerID, Mode: ImportModePartial, BatchStatus: BatchStatusSucceeded, AccessStatus: AccessStatusSelfServiceReady}) + if err != nil { + t.Fatalf("ImportBatches().Create(host-a) error = %v", err) + } + batchB, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostB, PackID: packID, ProviderID: providerID, Mode: ImportModePartial, BatchStatus: BatchStatusSucceeded, AccessStatus: AccessStatusSelfServiceReady}) + if err != nil { + t.Fatalf("ImportBatches().Create(host-b) error = %v", err) + } + if _, err := store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{BatchID: batchA, HostID: hostA, ProviderID: providerID, Status: "drifted", SummaryJSON: `{"host":"a"}`}); err != nil { + t.Fatalf("ReconcileRuns().Create(host-a) error = %v", err) + } + if _, err := store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{BatchID: batchB, HostID: hostB, ProviderID: providerID, Status: "active", SummaryJSON: `{"host":"b"}`}); err != nil { + t.Fatalf("ReconcileRuns().Create(host-b) error = %v", err) + } + + snapshot, err := NewProviderStatusService(store).GetStatus(ctx, ProviderQuery{ProviderID: "deepseek", HostID: "host-a"}) + if err != nil { + t.Fatalf("GetStatus() error = %v", err) + } + if snapshot.LatestReconcileStatus != "drifted" { + t.Fatalf("LatestReconcileStatus = %q, want drifted for host-a", snapshot.LatestReconcileStatus) + } +} + func TestProviderStatusServiceRequiresPackIDWhenProviderIDIsAmbiguous(t *testing.T) { store := openProvisionTestStore(t) defer closeProvisionTestStore(t, store) diff --git a/internal/provision/reconcile_service_test.go b/internal/provision/reconcile_service_test.go index 75101940..65acff7c 100644 --- a/internal/provision/reconcile_service_test.go +++ b/internal/provision/reconcile_service_test.go @@ -34,6 +34,7 @@ func TestReconcileServiceReturnsActiveAfterProbeRerun(t *testing.T) { } result, err := NewReconcileService(store, host).Reconcile(context.Background(), ReconcileRequest{ + HostID: "host-1", HostBaseURL: "https://sub2api.example.com", AccessProbeAPIKey: "user-key", Pack: pack.LoadedPack{Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}}, @@ -87,6 +88,7 @@ func TestReconcileServiceReturnsDegradedWhenProbeRerunFails(t *testing.T) { } result, err := NewReconcileService(store, host).Reconcile(context.Background(), ReconcileRequest{ + HostID: "host-1", HostBaseURL: "https://sub2api.example.com", AccessProbeAPIKey: "user-key", Pack: pack.LoadedPack{Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}}, @@ -128,6 +130,7 @@ func TestReconcileServiceReturnsDriftedWhenManagedResourceMissing(t *testing.T) } result, err := NewReconcileService(store, host).Reconcile(context.Background(), ReconcileRequest{ + HostID: "host-1", HostBaseURL: "https://sub2api.example.com", AccessProbeAPIKey: "user-key", Pack: pack.LoadedPack{Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}}, @@ -144,6 +147,76 @@ func TestReconcileServiceReturnsDriftedWhenManagedResourceMissing(t *testing.T) } } +func TestReconcileServicePassesAccountNamePrefixToManagedResourceSnapshot(t *testing.T) { + store := openProvisionTestStore(t) + defer closeProvisionTestStore(t, store) + + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_1": {OK: true, Status: "passed"}, + "account_2": {OK: true, Status: "passed"}, + }, + models: map[string][]sub2api.AccountModel{ + "account_1": {{ID: "deepseek-chat"}}, + "account_2": {{ID: "deepseek-chat"}}, + }, + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + } + + seedRuntimeImportForReconcile(t, store, host) + host.managedSnapshot = sub2api.ManagedResourceSnapshot{ + Groups: []sub2api.NamedResource{{ID: "group_1", Name: "g"}}, + Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "c"}}, + Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}}, + } + + provider := sampleProviderManifest() + if _, err := NewReconcileService(store, host).Reconcile(context.Background(), ReconcileRequest{ + HostID: "host-1", + HostBaseURL: "https://sub2api.example.com", + AccessProbeAPIKey: "user-key", + Pack: pack.LoadedPack{Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}}, + Provider: provider, + }); err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if host.listManagedReq.AccountNamePrefix != "deepseek-" { + t.Fatalf("AccountNamePrefix = %q, want %q", host.listManagedReq.AccountNamePrefix, "deepseek-") + } +} + +func TestReconcileServiceRejectsRolledBackLatestBatch(t *testing.T) { + store := openProvisionTestStore(t) + defer closeProvisionTestStore(t, store) + + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_1": {OK: true, Status: "passed"}, + }, + models: map[string][]sub2api.AccountModel{ + "account_1": {{ID: "deepseek-chat"}}, + }, + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + } + + batchID := seedRuntimeImportForReconcile(t, store, host) + if err := store.ImportBatches().UpdateStatus(context.Background(), batchID, BatchStatusRolledBack, AccessStatusBroken); err != nil { + t.Fatalf("UpdateStatus() error = %v", err) + } + _, err := NewReconcileService(store, host).Reconcile(context.Background(), ReconcileRequest{ + HostID: "host-1", + HostBaseURL: "https://sub2api.example.com", + AccessProbeAPIKey: "user-key", + Pack: pack.LoadedPack{Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}}, + Provider: sampleProviderManifest(), + }) + if err == nil || err.Error() != "latest import batch is rolled_back; run import again before reconcile" { + t.Fatalf("Reconcile() error = %v, want rolled_back guard", err) + } +} + func TestDeriveHealthyAccessStatus(t *testing.T) { tests := []struct { name string @@ -165,6 +238,7 @@ func TestDeriveHealthyAccessStatus(t *testing.T) { func seedRuntimeImportForReconcile(t *testing.T, store *sqlite.DB, host *fakeHostAdapter) int64 { t.Helper() + seedProvisionHost(t, store, "host-1", "https://sub2api.example.com") result, err := NewRuntimeImportService(store, host).Import(context.Background(), RuntimeImportRequest{ HostID: "host-1", HostBaseURL: "https://sub2api.example.com", diff --git a/internal/provision/rollback_service.go b/internal/provision/rollback_service.go index 26188522..166430a3 100644 --- a/internal/provision/rollback_service.go +++ b/internal/provision/rollback_service.go @@ -7,6 +7,7 @@ import ( "sub2api-cn-relay-manager/internal/host/sub2api" "sub2api-cn-relay-manager/internal/pack" + "sub2api-cn-relay-manager/internal/store/sqlite" ) type rollbackHost interface { @@ -51,7 +52,17 @@ func (s *RollbackService) Rollback(ctx context.Context, req RollbackRequest) (Ro if err != nil { return RollbackReport{}, fmt.Errorf("list managed resources: %w", err) } + return s.rollbackNamedResources(ctx, snapshot) +} +func (s *RollbackService) RollbackStoredResources(ctx context.Context, resources []sqlite.ManagedResource) (RollbackReport, error) { + if s.host == nil { + return RollbackReport{}, fmt.Errorf("rollback host is required") + } + return s.rollbackNamedResources(ctx, namedResourceSnapshotFromStored(resources)) +} + +func (s *RollbackService) rollbackNamedResources(ctx context.Context, snapshot sub2api.ManagedResourceSnapshot) (RollbackReport, error) { var report RollbackReport var errs []error for index := len(snapshot.Accounts) - 1; index >= 0; index-- { @@ -88,3 +99,21 @@ func (s *RollbackService) Rollback(ctx context.Context, req RollbackRequest) (Ro } return report, nil } + +func namedResourceSnapshotFromStored(resources []sqlite.ManagedResource) sub2api.ManagedResourceSnapshot { + snapshot := sub2api.ManagedResourceSnapshot{} + for _, resource := range resources { + ref := sub2api.NamedResource{ID: resource.HostResourceID, Name: resource.ResourceName} + switch resource.ResourceType { + case "account": + snapshot.Accounts = append(snapshot.Accounts, ref) + case "plan": + snapshot.Plans = append(snapshot.Plans, ref) + case "channel": + snapshot.Channels = append(snapshot.Channels, ref) + case "group": + snapshot.Groups = append(snapshot.Groups, ref) + } + } + return snapshot +} diff --git a/internal/provision/rollback_service_test.go b/internal/provision/rollback_service_test.go index 4ec2415d..761758ca 100644 --- a/internal/provision/rollback_service_test.go +++ b/internal/provision/rollback_service_test.go @@ -6,14 +6,15 @@ import ( "testing" "sub2api-cn-relay-manager/internal/host/sub2api" + "sub2api-cn-relay-manager/internal/store/sqlite" ) func TestRollbackServiceDeletesManagedResourcesInDependencyOrder(t *testing.T) { host := &fakeHostAdapter{ managedSnapshot: sub2api.ManagedResourceSnapshot{ - Groups: []sub2api.NamedResource{{ID: "group_1", Name: "crm-deepseek-group"}}, - Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "crm-deepseek-channel"}}, - Plans: []sub2api.NamedResource{{ID: "plan_1", Name: "crm-deepseek-plan"}}, + Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}}, + Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}}, + Plans: []sub2api.NamedResource{{ID: "plan_1", Name: "DeepSeek 默认套餐"}}, Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}}, }, } @@ -48,3 +49,23 @@ func TestRollbackServiceReturnsEmptyReportWhenNoManagedResourcesExist(t *testing t.Fatalf("deleted resources = %#v, want none", host.deletedResources) } } + +func TestRollbackServiceRollbackStoredResourcesDeletesOnlyProvidedIDs(t *testing.T) { + host := &fakeHostAdapter{} + svc := NewRollbackService(host) + + report, err := svc.RollbackStoredResources(context.Background(), []sqlite.ManagedResource{ + {BatchID: 2, ResourceType: "group", HostResourceID: "group_shared", ResourceName: "DeepSeek 默认分组"}, + {BatchID: 2, ResourceType: "account", HostResourceID: "account_2", ResourceName: "deepseek-02"}, + }) + if err != nil { + t.Fatalf("RollbackStoredResources() error = %v", err) + } + if report.AccountsDeleted != 1 || report.GroupsDeleted != 1 || report.ChannelsDeleted != 0 || report.PlansDeleted != 0 { + t.Fatalf("RollbackStoredResources() report = %+v", report) + } + want := []string{"account:account_2", "group:group_shared"} + if !reflect.DeepEqual(host.deletedResources, want) { + t.Fatalf("deleted resources = %#v, want %#v", host.deletedResources, want) + } +} diff --git a/internal/provision/runtime_import_service.go b/internal/provision/runtime_import_service.go index 2a3a8d7d..b40382f8 100644 --- a/internal/provision/runtime_import_service.go +++ b/internal/provision/runtime_import_service.go @@ -42,12 +42,17 @@ func (s *RuntimeImportService) Import(ctx context.Context, req RuntimeImportRequ if s.host == nil { return RuntimeImportResult{}, fmt.Errorf("host adapter is required") } + req.HostID = strings.TrimSpace(req.HostID) req.HostBaseURL = strings.TrimSpace(req.HostBaseURL) - if req.HostBaseURL == "" { - return RuntimeImportResult{}, fmt.Errorf("host_base_url is required") - } - if strings.TrimSpace(req.HostID) == "" { - req.HostID = req.HostBaseURL + if req.HostID == "" { + if req.HostBaseURL == "" { + return RuntimeImportResult{}, fmt.Errorf("host_id is required") + } + hostRow, err := s.store.Hosts().GetByBaseURL(ctx, req.HostBaseURL) + if err != nil { + return RuntimeImportResult{}, fmt.Errorf("host_id is required for unregistered host_base_url %q: %w", req.HostBaseURL, err) + } + req.HostID = hostRow.HostID } hostVersion, err := s.host.GetHostVersion(ctx) @@ -104,7 +109,8 @@ func (s *RuntimeImportService) Import(ctx context.Context, req RuntimeImportRequ report.AccessStatus = AccessStatusBroken } - if persistErr := s.persistRuntimeArtifacts(ctx, batchID, req.Access.Mode, report, importErr == nil); persistErr != nil { + includeManagedResources := importErr == nil || req.Mode != ImportModeStrict + if persistErr := s.persistRuntimeArtifacts(ctx, batchID, hostRow.ID, req.Access.Mode, report, includeManagedResources); persistErr != nil { return RuntimeImportResult{}, persistErr } if err := s.store.ImportBatches().UpdateStatus(ctx, batchID, report.BatchStatus, report.AccessStatus); err != nil { @@ -118,16 +124,14 @@ func (s *RuntimeImportService) Import(ctx context.Context, req RuntimeImportRequ func (s *RuntimeImportService) ensureHost(ctx context.Context, hostID, baseURL, hostVersion, capabilityProbeJSON string) (sqlite.Host, error) { host, err := s.store.Hosts().GetByHostID(ctx, hostID) - if err == nil { - return host, nil + if err != nil { + return sqlite.Host{}, fmt.Errorf("registered host %q not found: %w", hostID, err) } - if _, createErr := s.store.Hosts().Create(ctx, sqlite.Host{ - HostID: hostID, - BaseURL: baseURL, - HostVersion: hostVersion, - CapabilityProbeJSON: capabilityProbeJSON, - }); createErr != nil { - return sqlite.Host{}, createErr + if baseURL != "" && strings.TrimSpace(host.BaseURL) != strings.TrimSpace(baseURL) { + return sqlite.Host{}, fmt.Errorf("host %q base_url mismatch: registered=%s runtime=%s", hostID, host.BaseURL, baseURL) + } + if err := s.store.Hosts().UpdateProbeByHostID(ctx, hostID, hostVersion, capabilityProbeJSON); err != nil { + return sqlite.Host{}, err } return s.store.Hosts().GetByHostID(ctx, hostID) } @@ -163,7 +167,7 @@ func (s *RuntimeImportService) ensureProvider(ctx context.Context, packID int64, return s.store.Providers().GetByPackIDAndProviderID(ctx, packID, provider.ProviderID) } -func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batchID int64, accessMode string, report ImportReport, includeManagedResources bool) error { +func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batchID, hostID int64, accessMode string, report ImportReport, includeManagedResources bool) error { for i, account := range report.Accounts { payload, err := json.Marshal(map[string]any{ "account_id": account.Ref.ID, @@ -196,23 +200,19 @@ func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batc } if includeManagedResources { - if report.Group.ID != "" { - if _, err := s.store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: batchID, ResourceType: "group", HostResourceID: report.Group.ID, ResourceName: firstNonEmpty(report.Group.Name, report.Group.ID)}); err != nil { - return err - } + if err := s.persistManagedResourceIfAbsent(ctx, batchID, hostID, "group", report.Group.ID, report.Group.Name); err != nil { + return err } - if report.Channel.ID != "" { - if _, err := s.store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: batchID, ResourceType: "channel", HostResourceID: report.Channel.ID, ResourceName: firstNonEmpty(report.Channel.Name, report.Channel.ID)}); err != nil { - return err - } + if err := s.persistManagedResourceIfAbsent(ctx, batchID, hostID, "channel", report.Channel.ID, report.Channel.Name); err != nil { + return err } - if report.Plan != nil && report.Plan.ID != "" { - if _, err := s.store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: batchID, ResourceType: "plan", HostResourceID: report.Plan.ID, ResourceName: firstNonEmpty(report.Plan.Name, report.Plan.ID)}); err != nil { + if report.Plan != nil { + if err := s.persistManagedResourceIfAbsent(ctx, batchID, hostID, "plan", report.Plan.ID, report.Plan.Name); err != nil { return err } } for _, account := range report.Accounts { - if _, err := s.store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: batchID, ResourceType: "account", HostResourceID: account.Ref.ID, ResourceName: firstNonEmpty(account.Ref.Name, account.Ref.ID)}); err != nil { + if err := s.persistManagedResourceIfAbsent(ctx, batchID, hostID, "account", account.Ref.ID, account.Ref.Name); err != nil { return err } } @@ -238,6 +238,28 @@ func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batc return nil } +func (s *RuntimeImportService) persistManagedResourceIfAbsent(ctx context.Context, batchID, hostID int64, resourceType, hostResourceID, resourceName string) error { + resourceType = strings.TrimSpace(resourceType) + hostResourceID = strings.TrimSpace(hostResourceID) + resourceName = firstNonEmpty(resourceName, hostResourceID) + if resourceType == "" || hostResourceID == "" { + return nil + } + if _, err := s.store.ManagedResources().GetByResourceIdentity(ctx, hostID, resourceType, hostResourceID); err == nil { + return nil + } + if _, err := s.store.ManagedResources().Create(ctx, sqlite.ManagedResource{ + BatchID: batchID, + HostID: hostID, + ResourceType: resourceType, + HostResourceID: hostResourceID, + ResourceName: resourceName, + }); err != nil { + return err + } + return nil +} + func fingerprintKey(keys []string, index int) string { if index >= 0 && index < len(keys) { key := strings.TrimSpace(keys[index]) diff --git a/internal/provision/runtime_import_service_test.go b/internal/provision/runtime_import_service_test.go index 51c3d54d..cfe7b82c 100644 --- a/internal/provision/runtime_import_service_test.go +++ b/internal/provision/runtime_import_service_test.go @@ -17,6 +17,8 @@ func TestRuntimeImportServicePersistsOperationalState(t *testing.T) { store := openProvisionTestStore(t) defer closeProvisionTestStore(t, store) + seedProvisionHost(t, store, "host-1", "https://sub2api.example.com") + host := &fakeHostAdapter{ batchAccounts: []sub2api.AccountRef{{ID: "account_1"}, {ID: "account_2"}}, testResults: map[string]sub2api.ProbeResult{ @@ -110,6 +112,8 @@ func TestRuntimeImportServicePersistsFailedBatchAfterStrictRollback(t *testing.T store := openProvisionTestStore(t) defer closeProvisionTestStore(t, store) + seedProvisionHost(t, store, "host-1", "https://sub2api.example.com") + host := &fakeHostAdapter{ batchAccounts: []sub2api.AccountRef{{ID: "account_1"}, {ID: "account_2"}}, testResults: map[string]sub2api.ProbeResult{ @@ -167,6 +171,111 @@ func TestRuntimeImportServicePersistsFailedBatchAfterStrictRollback(t *testing.T } } +func TestRuntimeImportServicePersistsPartialManagedResourcesOnAccessFailure(t *testing.T) { + store := openProvisionTestStore(t) + defer closeProvisionTestStore(t, store) + + seedProvisionHost(t, store, "host-1", "https://sub2api.example.com") + + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_1"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_1": {OK: true, Status: "passed"}, + }, + models: map[string][]sub2api.AccountModel{ + "account_1": {{ID: "deepseek-chat"}}, + }, + assignErr: fmt.Errorf("group is not a subscription type"), + } + + svc := NewRuntimeImportService(store, host) + result, err := svc.Import(context.Background(), RuntimeImportRequest{ + HostID: "host-1", + HostBaseURL: "https://sub2api.example.com", + Pack: pack.LoadedPack{ + Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}, + Checksum: "checksum-1", + }, + Provider: sampleProviderManifest(), + Mode: ImportModePartial, + Keys: []string{"key-1"}, + Access: AccessRequest{ + Mode: AccessModeSubscription, + ProbeAPIKey: "user-key", + Subscriptions: []SubscriptionTarget{{UserID: "1", DurationDays: 30}}, + }, + }) + if err == nil { + t.Fatal("RuntimeImportService.Import() error = nil, want partial failure") + } + if result.BatchID <= 0 { + t.Fatalf("BatchID = %d, want positive id", result.BatchID) + } + if got := queryCount(t, store.SQLDB(), "managed_resources"); got != 4 { + t.Fatalf("managed_resources row count = %d, want 4 persisted resources on partial failure", got) + } + var batchStatus string + if err := store.SQLDB().QueryRowContext(context.Background(), "SELECT batch_status FROM import_batches WHERE id = ?", result.BatchID).Scan(&batchStatus); err != nil { + t.Fatalf("query import batch status: %v", err) + } + if batchStatus != BatchStatusPartial { + t.Fatalf("persisted batch_status = %q, want %q", batchStatus, BatchStatusPartial) + } +} + +func TestRuntimeImportServiceRepeatedImportReusesManagedResources(t *testing.T) { + store := openProvisionTestStore(t) + defer closeProvisionTestStore(t, store) + + seedProvisionHost(t, store, "host-1", "https://sub2api.example.com") + + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "key-1"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_1": {OK: true, Status: "passed"}, + }, + models: map[string][]sub2api.AccountModel{ + "account_1": {{ID: "deepseek-chat"}}, + }, + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + } + + svc := NewRuntimeImportService(store, host) + request := RuntimeImportRequest{ + HostID: "host-1", + HostBaseURL: "https://sub2api.example.com", + Pack: pack.LoadedPack{ + Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}, + Checksum: "checksum-1", + }, + Provider: sampleProviderManifest(), + Mode: ImportModePartial, + Keys: []string{"key-1"}, + Access: AccessRequest{ + Mode: AccessModeSelfService, + ProbeAPIKey: "user-key", + }, + } + + first, err := svc.Import(context.Background(), request) + if err != nil { + t.Fatalf("first Import() error = %v", err) + } + second, err := svc.Import(context.Background(), request) + if err != nil { + t.Fatalf("second Import() error = %v", err) + } + if second.BatchID <= first.BatchID { + t.Fatalf("second BatchID = %d, want > first BatchID %d", second.BatchID, first.BatchID) + } + if got := queryCount(t, store.SQLDB(), "managed_resources"); got != 3 { + t.Fatalf("managed_resources row count = %d, want 3 after reused import", got) + } + if got := queryCount(t, store.SQLDB(), "import_batches"); got != 2 { + t.Fatalf("import_batches row count = %d, want 2", got) + } +} + func openProvisionTestStore(t *testing.T) *sqlite.DB { t.Helper() @@ -186,6 +295,21 @@ func closeProvisionTestStore(t *testing.T, store *sqlite.DB) { } } +func seedProvisionHost(t *testing.T, store *sqlite.DB, hostID, baseURL string) int64 { + t.Helper() + id, err := store.Hosts().Create(context.Background(), sqlite.Host{ + HostID: hostID, + BaseURL: baseURL, + HostVersion: "0.1.126", + AuthType: "apikey", + AuthToken: "test-host-token", + }) + if err != nil { + t.Fatalf("Hosts().Create() error = %v", err) + } + return id +} + func queryCount(t *testing.T, db *sql.DB, table string) int { t.Helper() var count int diff --git a/internal/store/migrations/0004_host_identity_and_managed_resources.sql b/internal/store/migrations/0004_host_identity_and_managed_resources.sql new file mode 100644 index 00000000..637456c6 --- /dev/null +++ b/internal/store/migrations/0004_host_identity_and_managed_resources.sql @@ -0,0 +1,23 @@ +ALTER TABLE hosts ADD COLUMN auth_type TEXT NOT NULL DEFAULT 'apikey'; +ALTER TABLE hosts ADD COLUMN auth_token TEXT NOT NULL DEFAULT ''; + +CREATE TABLE managed_resources_v2 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id INTEGER NOT NULL, + host_id INTEGER NOT NULL, + resource_type TEXT NOT NULL, + host_resource_id TEXT NOT NULL, + resource_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_managed_resources_batch FOREIGN KEY (batch_id) REFERENCES import_batches(id) ON DELETE CASCADE, + CONSTRAINT fk_managed_resources_host FOREIGN KEY (host_id) REFERENCES hosts(id) ON DELETE CASCADE, + CONSTRAINT uq_managed_resources_host_scoped UNIQUE (host_id, resource_type, host_resource_id) +); + +INSERT INTO managed_resources_v2 (id, batch_id, host_id, resource_type, host_resource_id, resource_name, created_at) +SELECT mr.id, mr.batch_id, ib.host_id, mr.resource_type, mr.host_resource_id, mr.resource_name, mr.created_at +FROM managed_resources mr +JOIN import_batches ib ON ib.id = mr.batch_id; + +DROP TABLE managed_resources; +ALTER TABLE managed_resources_v2 RENAME TO managed_resources; diff --git a/internal/store/migrations/0005_reconcile_runs_host_scope.sql b/internal/store/migrations/0005_reconcile_runs_host_scope.sql new file mode 100644 index 00000000..776fb977 --- /dev/null +++ b/internal/store/migrations/0005_reconcile_runs_host_scope.sql @@ -0,0 +1,23 @@ +CREATE TABLE reconcile_runs_v2 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + provider_id INTEGER NOT NULL, + status TEXT NOT NULL, + summary_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_reconcile_runs_host FOREIGN KEY (host_id) REFERENCES hosts(id) ON DELETE CASCADE, + CONSTRAINT fk_reconcile_runs_provider FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE +); + +INSERT INTO reconcile_runs_v2 (id, host_id, provider_id, status, summary_json, created_at) +SELECT rr.id, ib.host_id, rr.provider_id, rr.status, rr.summary_json, rr.created_at +FROM reconcile_runs rr +JOIN ( + SELECT provider_id, MAX(id) AS latest_batch_id + FROM import_batches + GROUP BY provider_id +) latest ON latest.provider_id = rr.provider_id +JOIN import_batches ib ON ib.id = latest.latest_batch_id; + +DROP TABLE reconcile_runs; +ALTER TABLE reconcile_runs_v2 RENAME TO reconcile_runs; diff --git a/internal/store/migrations/0006_reconcile_runs_batch_scope.sql b/internal/store/migrations/0006_reconcile_runs_batch_scope.sql new file mode 100644 index 00000000..82626f29 --- /dev/null +++ b/internal/store/migrations/0006_reconcile_runs_batch_scope.sql @@ -0,0 +1,19 @@ +CREATE TABLE reconcile_runs_v3 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id INTEGER, + host_id INTEGER NOT NULL, + provider_id INTEGER NOT NULL, + status TEXT NOT NULL, + summary_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_reconcile_runs_batch FOREIGN KEY (batch_id) REFERENCES import_batches(id) ON DELETE CASCADE, + CONSTRAINT fk_reconcile_runs_host FOREIGN KEY (host_id) REFERENCES hosts(id) ON DELETE CASCADE, + CONSTRAINT fk_reconcile_runs_provider FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE +); + +INSERT INTO reconcile_runs_v3 (id, batch_id, host_id, provider_id, status, summary_json, created_at) +SELECT rr.id, NULL, rr.host_id, rr.provider_id, rr.status, rr.summary_json, rr.created_at +FROM reconcile_runs rr; + +DROP TABLE reconcile_runs; +ALTER TABLE reconcile_runs_v3 RENAME TO reconcile_runs; diff --git a/internal/store/sqlite/hosts_repo.go b/internal/store/sqlite/hosts_repo.go index 8089758a..3afe37d2 100644 --- a/internal/store/sqlite/hosts_repo.go +++ b/internal/store/sqlite/hosts_repo.go @@ -12,6 +12,8 @@ type Host struct { BaseURL string HostVersion string CapabilityProbeJSON string + AuthType string + AuthToken string } type HostsRepo struct { @@ -28,7 +30,7 @@ func (r *HostsRepo) GetByID(ctx context.Context, id int64) (Host, error) { } var host Host - if err := r.db.QueryRowContext(ctx, `SELECT id, host_id, base_url, host_version, capability_probe_json FROM hosts WHERE id = ?`, id).Scan(&host.ID, &host.HostID, &host.BaseURL, &host.HostVersion, &host.CapabilityProbeJSON); err != nil { + if err := r.db.QueryRowContext(ctx, `SELECT id, host_id, base_url, host_version, capability_probe_json, auth_type, auth_token FROM hosts WHERE id = ?`, id).Scan(&host.ID, &host.HostID, &host.BaseURL, &host.HostVersion, &host.CapabilityProbeJSON, &host.AuthType, &host.AuthToken); err != nil { return Host{}, err } return host, nil @@ -41,7 +43,20 @@ func (r *HostsRepo) GetByHostID(ctx context.Context, hostID string) (Host, error } var host Host - if err := r.db.QueryRowContext(ctx, `SELECT id, host_id, base_url, host_version, capability_probe_json FROM hosts WHERE host_id = ?`, hostID).Scan(&host.ID, &host.HostID, &host.BaseURL, &host.HostVersion, &host.CapabilityProbeJSON); err != nil { + if err := r.db.QueryRowContext(ctx, `SELECT id, host_id, base_url, host_version, capability_probe_json, auth_type, auth_token FROM hosts WHERE host_id = ?`, hostID).Scan(&host.ID, &host.HostID, &host.BaseURL, &host.HostVersion, &host.CapabilityProbeJSON, &host.AuthType, &host.AuthToken); err != nil { + return Host{}, err + } + return host, nil +} + +func (r *HostsRepo) GetByBaseURL(ctx context.Context, baseURL string) (Host, error) { + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return Host{}, fmt.Errorf("base_url is required") + } + + var host Host + if err := r.db.QueryRowContext(ctx, `SELECT id, host_id, base_url, host_version, capability_probe_json, auth_type, auth_token FROM hosts WHERE base_url = ?`, baseURL).Scan(&host.ID, &host.HostID, &host.BaseURL, &host.HostVersion, &host.CapabilityProbeJSON, &host.AuthType, &host.AuthToken); err != nil { return Host{}, err } return host, nil @@ -52,6 +67,8 @@ func (r *HostsRepo) Create(ctx context.Context, host Host) (int64, error) { baseURL := strings.TrimSpace(host.BaseURL) hostVersion := strings.TrimSpace(host.HostVersion) capabilityProbeJSON := strings.TrimSpace(host.CapabilityProbeJSON) + authType := firstNonEmptyTrimmed(host.AuthType, "apikey") + authToken := strings.TrimSpace(host.AuthToken) switch { case hostID == "": @@ -66,12 +83,14 @@ func (r *HostsRepo) Create(ctx context.Context, host Host) (int64, error) { result, err := r.db.ExecContext( ctx, - `INSERT INTO hosts (host_id, base_url, host_version, capability_probe_json) - VALUES (?, ?, ?, ?)`, + `INSERT INTO hosts (host_id, base_url, host_version, capability_probe_json, auth_type, auth_token) + VALUES (?, ?, ?, ?, ?, ?)`, hostID, baseURL, hostVersion, capabilityProbeJSON, + authType, + authToken, ) if err != nil { return 0, fmt.Errorf("insert host %q: %w", hostID, err) @@ -84,3 +103,115 @@ func (r *HostsRepo) Create(ctx context.Context, host Host) (int64, error) { return id, nil } + +func (r *HostsRepo) UpdateConnectionByHostID(ctx context.Context, hostID, baseURL, hostVersion, capabilityProbeJSON, authType, authToken string) error { + hostID = strings.TrimSpace(hostID) + baseURL = strings.TrimSpace(baseURL) + hostVersion = strings.TrimSpace(hostVersion) + capabilityProbeJSON = strings.TrimSpace(capabilityProbeJSON) + authType = firstNonEmptyTrimmed(authType, "apikey") + authToken = strings.TrimSpace(authToken) + if hostID == "" { + return fmt.Errorf("host_id is required") + } + if baseURL == "" { + return fmt.Errorf("base_url is required") + } + if hostVersion == "" { + return fmt.Errorf("host_version is required") + } + if capabilityProbeJSON == "" { + capabilityProbeJSON = "{}" + } + + result, err := r.db.ExecContext(ctx, `UPDATE hosts SET base_url = ?, host_version = ?, capability_probe_json = ?, auth_type = ?, auth_token = ? WHERE host_id = ?`, baseURL, hostVersion, capabilityProbeJSON, authType, authToken, hostID) + if err != nil { + return fmt.Errorf("update host %q connection: %w", hostID, err) + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("host %q not found", hostID) + } + return nil +} + +func (r *HostsRepo) ListAll(ctx context.Context) ([]Host, error) { + rows, err := r.db.QueryContext(ctx, `SELECT id, host_id, base_url, host_version, capability_probe_json, auth_type, auth_token FROM hosts ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("list hosts: %w", err) + } + defer rows.Close() + + var hosts []Host + for rows.Next() { + var host Host + if err := rows.Scan(&host.ID, &host.HostID, &host.BaseURL, &host.HostVersion, &host.CapabilityProbeJSON, &host.AuthType, &host.AuthToken); err != nil { + return nil, fmt.Errorf("scan host: %w", err) + } + hosts = append(hosts, host) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate hosts: %w", err) + } + return hosts, nil +} + +func (r *HostsRepo) DeleteByHostID(ctx context.Context, hostID string) error { + hostID = strings.TrimSpace(hostID) + if hostID == "" { + return fmt.Errorf("host_id is required") + } + + result, err := r.db.ExecContext(ctx, `DELETE FROM hosts WHERE host_id = ?`, hostID) + if err != nil { + return fmt.Errorf("delete host %q: %w", hostID, err) + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("host %q not found", hostID) + } + return nil +} + +func (r *HostsRepo) UpdateProbeByHostID(ctx context.Context, hostID, hostVersion, capabilityProbeJSON string) error { + hostID = strings.TrimSpace(hostID) + hostVersion = strings.TrimSpace(hostVersion) + capabilityProbeJSON = strings.TrimSpace(capabilityProbeJSON) + if hostID == "" { + return fmt.Errorf("host_id is required") + } + if hostVersion == "" { + return fmt.Errorf("host_version is required") + } + if capabilityProbeJSON == "" { + capabilityProbeJSON = "{}" + } + + result, err := r.db.ExecContext(ctx, `UPDATE hosts SET host_version = ?, capability_probe_json = ? WHERE host_id = ?`, hostVersion, capabilityProbeJSON, hostID) + if err != nil { + return fmt.Errorf("update host %q probe: %w", hostID, err) + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("host %q not found", hostID) + } + return nil +} + +func firstNonEmptyTrimmed(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/store/sqlite/hosts_repo_test.go b/internal/store/sqlite/hosts_repo_test.go index 1c6cacac..7e8522d3 100644 --- a/internal/store/sqlite/hosts_repo_test.go +++ b/internal/store/sqlite/hosts_repo_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "path/filepath" "testing" ) @@ -56,6 +57,17 @@ func createTestHost(t *testing.T, store *DB) int64 { return id } +func createTestHostWithBaseURL(t *testing.T, store *DB, hostID, baseURL string) int64 { + t.Helper() + id, err := store.Hosts().Create(context.Background(), Host{ + HostID: hostID, BaseURL: baseURL, HostVersion: "0.1.0", + }) + if err != nil { + t.Fatalf("createTestHostWithBaseURL error = %v", err) + } + return id +} + func createTestBatch(t *testing.T, store *DB) int64 { t.Helper() hostID := createTestHost(t, store) @@ -197,3 +209,85 @@ func TestHostsRepoGetByHostIDNotFound(t *testing.T) { t.Fatalf("GetByHostID('nonexistent') error = %v, want sql.ErrNoRows", err) } } + +func TestHostsRepoListAll(t *testing.T) { + store := openTestDB(t) + + hosts, err := store.Hosts().ListAll(context.Background()) + if err != nil { + t.Fatalf("ListAll() on empty DB error = %v", err) + } + if len(hosts) != 0 { + t.Fatalf("ListAll() len = %d, want 0", len(hosts)) + } + + for i := 0; i < 2; i++ { + _, err := store.Hosts().Create(context.Background(), Host{ + HostID: fmt.Sprintf("host-listall-%d", i), BaseURL: "https://h.com", HostVersion: "0.1.0", + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + } + + hosts, err = store.Hosts().ListAll(context.Background()) + if err != nil { + t.Fatalf("ListAll() error = %v", err) + } + if len(hosts) != 2 { + t.Fatalf("ListAll() len = %d, want 2", len(hosts)) + } +} + +func TestHostsRepoDeleteByHostID(t *testing.T) { + store := openTestDB(t) + createTestHost(t, store) + + if err := store.Hosts().DeleteByHostID(context.Background(), "host-"+sanitizeTestName(t.Name())); err != nil { + t.Fatalf("DeleteByHostID() error = %v", err) + } + + hosts, err := store.Hosts().ListAll(context.Background()) + if err != nil { + t.Fatalf("ListAll() error = %v", err) + } + if len(hosts) != 0 { + t.Fatalf("ListAll() after delete len = %d, want 0", len(hosts)) + } +} + +func TestHostsRepoUpdateProbeByHostID(t *testing.T) { + store := openTestDB(t) + createTestHost(t, store) + + if err := store.Hosts().UpdateProbeByHostID(context.Background(), "host-"+sanitizeTestName(t.Name()), "0.2.0", `{"groups":true}`); err != nil { + t.Fatalf("UpdateProbeByHostID() error = %v", err) + } + + host, err := store.Hosts().GetByHostID(context.Background(), "host-"+sanitizeTestName(t.Name())) + if err != nil { + t.Fatalf("GetByHostID() error = %v", err) + } + if host.HostVersion != "0.2.0" || host.CapabilityProbeJSON != `{"groups":true}` { + t.Fatalf("updated host = %+v, want version/capability update", host) + } +} + +func TestHostsRepoDeleteByHostIDNotFound(t *testing.T) { + store := openTestDB(t) + err := store.Hosts().DeleteByHostID(context.Background(), "nonexistent") + if err == nil { + t.Fatal("DeleteByHostID('nonexistent') error = nil, want error") + } + if err.Error() != `host "nonexistent" not found` { + t.Fatalf("DeleteByHostID() error = %q, want not found error", err) + } +} + +func TestHostsRepoDeleteByHostIDEmptyError(t *testing.T) { + store := openTestDB(t) + err := store.Hosts().DeleteByHostID(context.Background(), "") + if err == nil { + t.Fatal("DeleteByHostID('') error = nil, want error") + } +} diff --git a/internal/store/sqlite/import_batches_repo.go b/internal/store/sqlite/import_batches_repo.go index 42cdbc53..8799074d 100644 --- a/internal/store/sqlite/import_batches_repo.go +++ b/internal/store/sqlite/import_batches_repo.go @@ -114,6 +114,74 @@ func (r *ImportBatchesRepo) GetLatestByProviderID(ctx context.Context, providerI return batch, nil } +func (r *ImportBatchesRepo) GetLatestByProviderIDAndHostID(ctx context.Context, providerID, hostID int64) (ImportBatch, error) { + if providerID <= 0 { + return ImportBatch{}, fmt.Errorf("provider_id is required") + } + if hostID <= 0 { + return ImportBatch{}, fmt.Errorf("host_id is required") + } + + var batch ImportBatch + if err := r.db.QueryRowContext(ctx, `SELECT id, host_id, pack_id, provider_id, mode, batch_status, access_status FROM import_batches WHERE provider_id = ? AND host_id = ? ORDER BY id DESC LIMIT 1`, providerID, hostID).Scan(&batch.ID, &batch.HostID, &batch.PackID, &batch.ProviderID, &batch.Mode, &batch.BatchStatus, &batch.AccessStatus); err != nil { + return ImportBatch{}, err + } + return batch, nil +} + +func (r *ImportBatchesRepo) ListByProviderID(ctx context.Context, providerID int64) ([]ImportBatch, error) { + if providerID <= 0 { + return nil, fmt.Errorf("provider_id is required") + } + + rows, err := r.db.QueryContext(ctx, `SELECT id, host_id, pack_id, provider_id, mode, batch_status, access_status FROM import_batches WHERE provider_id = ? ORDER BY id DESC`, providerID) + if err != nil { + return nil, fmt.Errorf("query import batches by provider_id %d: %w", providerID, err) + } + defer rows.Close() + + batches := make([]ImportBatch, 0) + for rows.Next() { + var batch ImportBatch + if err := rows.Scan(&batch.ID, &batch.HostID, &batch.PackID, &batch.ProviderID, &batch.Mode, &batch.BatchStatus, &batch.AccessStatus); err != nil { + return nil, fmt.Errorf("scan import batch by provider_id %d: %w", providerID, err) + } + batches = append(batches, batch) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate import batches by provider_id %d: %w", providerID, err) + } + return batches, nil +} + +func (r *ImportBatchesRepo) ListByProviderIDAndHostID(ctx context.Context, providerID, hostID int64) ([]ImportBatch, error) { + if providerID <= 0 { + return nil, fmt.Errorf("provider_id is required") + } + if hostID <= 0 { + return nil, fmt.Errorf("host_id is required") + } + + rows, err := r.db.QueryContext(ctx, `SELECT id, host_id, pack_id, provider_id, mode, batch_status, access_status FROM import_batches WHERE provider_id = ? AND host_id = ? ORDER BY id DESC`, providerID, hostID) + if err != nil { + return nil, fmt.Errorf("query import batches by provider_id %d and host_id %d: %w", providerID, hostID, err) + } + defer rows.Close() + + batches := make([]ImportBatch, 0) + for rows.Next() { + var batch ImportBatch + if err := rows.Scan(&batch.ID, &batch.HostID, &batch.PackID, &batch.ProviderID, &batch.Mode, &batch.BatchStatus, &batch.AccessStatus); err != nil { + return nil, fmt.Errorf("scan import batch by provider_id %d and host_id %d: %w", providerID, hostID, err) + } + batches = append(batches, batch) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate import batches by provider_id %d and host_id %d: %w", providerID, hostID, err) + } + return batches, nil +} + func (r *ImportBatchItemsRepo) GetByBatchID(ctx context.Context, batchID int64) ([]ImportBatchItem, error) { if batchID <= 0 { return nil, fmt.Errorf("batch_id is required") diff --git a/internal/store/sqlite/import_batches_repo_test.go b/internal/store/sqlite/import_batches_repo_test.go index 9827320d..875433ca 100644 --- a/internal/store/sqlite/import_batches_repo_test.go +++ b/internal/store/sqlite/import_batches_repo_test.go @@ -69,6 +69,39 @@ func TestImportBatchesRepoGetLatestByProviderID(t *testing.T) { } } +func TestImportBatchesRepoListByProviderID(t *testing.T) { + store := openTestDB(t) + hostID := createTestHost(t, store) + packID := createTestPack(t, store) + providerID := createTestProviderWithPack(t, store, packID) + + olderID, err := store.ImportBatches().Create(context.Background(), ImportBatch{ + HostID: hostID, PackID: packID, ProviderID: providerID, + Mode: "partial", BatchStatus: "running", AccessStatus: "pending", + }) + if err != nil { + t.Fatalf("Create older batch error = %v", err) + } + newerID, err := store.ImportBatches().Create(context.Background(), ImportBatch{ + HostID: hostID, PackID: packID, ProviderID: providerID, + Mode: "strict", BatchStatus: "succeeded", AccessStatus: "subscription_ready", + }) + if err != nil { + t.Fatalf("Create newer batch error = %v", err) + } + + batches, err := store.ImportBatches().ListByProviderID(context.Background(), providerID) + if err != nil { + t.Fatalf("ListByProviderID() error = %v", err) + } + if len(batches) != 2 { + t.Fatalf("ListByProviderID() len = %d, want 2", len(batches)) + } + if batches[0].ID != newerID || batches[1].ID != olderID { + t.Fatalf("ListByProviderID() ids = [%d %d], want [%d %d]", batches[0].ID, batches[1].ID, newerID, olderID) + } +} + func TestImportBatchesRepoGetByIDNotFound(t *testing.T) { store := openTestDB(t) _, err := store.ImportBatches().GetByID(context.Background(), 999) @@ -116,9 +149,9 @@ func TestImportBatchItemsRepoCreateAndGet(t *testing.T) { batchID := createTestBatch(t, store) id, err := store.ImportBatchItems().Create(context.Background(), ImportBatchItem{ - BatchID: batchID, - KeyFingerprint: "sha256:abc", - AccountStatus: "passed", + BatchID: batchID, + KeyFingerprint: "sha256:abc", + AccountStatus: "passed", ProbeSummaryJSON: `{"ok":true}`, }) if err != nil { @@ -190,9 +223,10 @@ func TestImportBatchItemsRepoValidation(t *testing.T) { func TestManagedResourcesRepoCreateAndGet(t *testing.T) { store := openTestDB(t) batchID := createTestBatch(t, store) + batch, _ := store.ImportBatches().GetByID(context.Background(), batchID) id, err := store.ManagedResources().Create(context.Background(), ManagedResource{ - BatchID: batchID, ResourceType: "group", HostResourceID: "g_01", ResourceName: "test-group", + BatchID: batchID, HostID: batch.HostID, ResourceType: "group", HostResourceID: "g_01", ResourceName: "test-group", }) if err != nil { t.Fatalf("Create() error = %v", err) @@ -207,11 +241,12 @@ func TestManagedResourcesRepoCreateAndGet(t *testing.T) { func TestManagedResourcesRepoMultipleResources(t *testing.T) { store := openTestDB(t) batchID := createTestBatch(t, store) + batch, _ := store.ImportBatches().GetByID(context.Background(), batchID) for _, r := range []ManagedResource{ - {BatchID: batchID, ResourceType: "group", HostResourceID: "g_01", ResourceName: "group-1"}, - {BatchID: batchID, ResourceType: "channel", HostResourceID: "c_01", ResourceName: "channel-1"}, - {BatchID: batchID, ResourceType: "account", HostResourceID: "a_01", ResourceName: "account-1"}, + {BatchID: batchID, HostID: batch.HostID, ResourceType: "group", HostResourceID: "g_01", ResourceName: "group-1"}, + {BatchID: batchID, HostID: batch.HostID, ResourceType: "channel", HostResourceID: "c_01", ResourceName: "channel-1"}, + {BatchID: batchID, HostID: batch.HostID, ResourceType: "account", HostResourceID: "a_01", ResourceName: "account-1"}, } { store.ManagedResources().Create(context.Background(), r) } @@ -236,10 +271,11 @@ func TestManagedResourcesRepoValidationErrors(t *testing.T) { name string r ManagedResource }{ - {"batch_id zero", ManagedResource{ResourceType: "g", HostResourceID: "h", ResourceName: "n"}}, - {"empty resource_type", ManagedResource{BatchID: 1, HostResourceID: "h", ResourceName: "n"}}, - {"empty host_resource_id", ManagedResource{BatchID: 1, ResourceType: "g", ResourceName: "n"}}, - {"empty resource_name", ManagedResource{BatchID: 1, ResourceType: "g", HostResourceID: "h"}}, + {"batch_id zero", ManagedResource{HostID: 1, ResourceType: "g", HostResourceID: "h", ResourceName: "n"}}, + {"host_id zero", ManagedResource{BatchID: 1, ResourceType: "g", HostResourceID: "h", ResourceName: "n"}}, + {"empty resource_type", ManagedResource{BatchID: 1, HostID: 1, HostResourceID: "h", ResourceName: "n"}}, + {"empty host_resource_id", ManagedResource{BatchID: 1, HostID: 1, ResourceType: "g", ResourceName: "n"}}, + {"empty resource_name", ManagedResource{BatchID: 1, HostID: 1, ResourceType: "g", HostResourceID: "h"}}, } { t.Run(tt.name, func(t *testing.T) { _, err := store.ManagedResources().Create(context.Background(), tt.r) @@ -383,15 +419,19 @@ func createTestProvider(t *testing.T, store *DB) int64 { func TestReconcileRunsRepoCreateAndGet(t *testing.T) { store := openTestDB(t) - providerID := createTestProvider(t, store) + batchID := createTestBatch(t, store) + batch, err := store.ImportBatches().GetByID(context.Background(), batchID) + if err != nil { + t.Fatalf("ImportBatches().GetByID() error = %v", err) + } id, err := store.ReconcileRuns().Create(context.Background(), ReconcileRun{ - ProviderID: providerID, Status: "active", SummaryJSON: `{"drifted":false}`, + BatchID: batchID, HostID: batch.HostID, ProviderID: batch.ProviderID, Status: "active", SummaryJSON: `{"drifted":false}`, }) if err != nil { t.Fatalf("Create() error = %v", err) } - runs, _ := store.ReconcileRuns().GetByProviderID(context.Background(), providerID) + runs, _ := store.ReconcileRuns().GetByBatchID(context.Background(), batchID) if len(runs) != 1 || runs[0].Status != "active" { t.Fatalf("runs = %+v, want 1 active", runs) } @@ -400,19 +440,62 @@ func TestReconcileRunsRepoCreateAndGet(t *testing.T) { func TestReconcileRunsRepoMultipleRunsOrderedDesc(t *testing.T) { store := openTestDB(t) - providerID := createTestProvider(t, store) + batchID := createTestBatch(t, store) + batch, err := store.ImportBatches().GetByID(context.Background(), batchID) + if err != nil { + t.Fatalf("ImportBatches().GetByID() error = %v", err) + } - id1, _ := store.ReconcileRuns().Create(context.Background(), ReconcileRun{ProviderID: providerID, Status: "first", SummaryJSON: "{}"}) - id2, _ := store.ReconcileRuns().Create(context.Background(), ReconcileRun{ProviderID: providerID, Status: "second", SummaryJSON: "{}"}) - runs, _ := store.ReconcileRuns().GetByProviderID(context.Background(), providerID) + id1, _ := store.ReconcileRuns().Create(context.Background(), ReconcileRun{BatchID: batchID, HostID: batch.HostID, ProviderID: batch.ProviderID, Status: "first", SummaryJSON: "{}"}) + id2, _ := store.ReconcileRuns().Create(context.Background(), ReconcileRun{BatchID: batchID, HostID: batch.HostID, ProviderID: batch.ProviderID, Status: "second", SummaryJSON: "{}"}) + runs, _ := store.ReconcileRuns().GetByBatchID(context.Background(), batchID) if len(runs) != 2 || runs[0].ID != id2 || runs[1].ID != id1 { t.Fatalf("order: got %d, %d; want %d, %d (DESC)", runs[0].ID, runs[1].ID, id2, id1) } } -func TestReconcileRunsRepoGetByProviderIDEmpty(t *testing.T) { +func TestReconcileRunsRepoSeparatesHosts(t *testing.T) { store := openTestDB(t) - runs, _ := store.ReconcileRuns().GetByProviderID(context.Background(), 999) + hostA := createTestHost(t, store) + hostB := createTestHostWithBaseURL(t, store, "host-b", "https://host-b.example.com") + packID := createTestPack(t, store) + providerID := createTestProviderWithPack(t, store, packID) + batchA, err := store.ImportBatches().Create(context.Background(), ImportBatch{HostID: hostA, PackID: packID, ProviderID: providerID, Mode: "partial", BatchStatus: "running", AccessStatus: "pending"}) + if err != nil { + t.Fatalf("ImportBatches().Create(hostA) error = %v", err) + } + batchB, err := store.ImportBatches().Create(context.Background(), ImportBatch{HostID: hostB, PackID: packID, ProviderID: providerID, Mode: "partial", BatchStatus: "running", AccessStatus: "pending"}) + if err != nil { + t.Fatalf("ImportBatches().Create(hostB) error = %v", err) + } + + if _, err := store.ReconcileRuns().Create(context.Background(), ReconcileRun{BatchID: batchA, HostID: hostA, ProviderID: providerID, Status: "drifted", SummaryJSON: `{"host":"a"}`}); err != nil { + t.Fatalf("Create(hostA) error = %v", err) + } + if _, err := store.ReconcileRuns().Create(context.Background(), ReconcileRun{BatchID: batchB, HostID: hostB, ProviderID: providerID, Status: "active", SummaryJSON: `{"host":"b"}`}); err != nil { + t.Fatalf("Create(hostB) error = %v", err) + } + + runs, _ := store.ReconcileRuns().GetByProviderIDAndHostID(context.Background(), providerID, hostA) + if len(runs) != 1 { + t.Fatalf("len(runs) = %d, want 1", len(runs)) + } + if runs[0].Status != "drifted" { + t.Fatalf("runs[0].Status = %q, want drifted", runs[0].Status) + } +} + +func TestReconcileRunsRepoGetByProviderIDAndHostIDEmpty(t *testing.T) { + store := openTestDB(t) + runs, _ := store.ReconcileRuns().GetByProviderIDAndHostID(context.Background(), 999, 1) + if len(runs) != 0 { + t.Fatalf("count = %d, want 0", len(runs)) + } +} + +func TestReconcileRunsRepoGetByBatchIDEmpty(t *testing.T) { + store := openTestDB(t) + runs, _ := store.ReconcileRuns().GetByBatchID(context.Background(), 999) if len(runs) != 0 { t.Fatalf("count = %d, want 0", len(runs)) } @@ -420,10 +503,9 @@ func TestReconcileRunsRepoGetByProviderIDEmpty(t *testing.T) { func TestReconcileRunsRepoValidation(t *testing.T) { store := openTestDB(t) - _, err := store.ReconcileRuns().Create(context.Background(), ReconcileRun{ProviderID: 0, Status: "s"}) + hostID := createTestHost(t, store) + _, err := store.ReconcileRuns().Create(context.Background(), ReconcileRun{BatchID: 1, HostID: hostID, ProviderID: 0, Status: "s"}) if err == nil { t.Fatal("Create provider_id=0 error = nil") } } - - diff --git a/internal/store/sqlite/managed_resources_repo.go b/internal/store/sqlite/managed_resources_repo.go index ab86e5dd..b328bad2 100644 --- a/internal/store/sqlite/managed_resources_repo.go +++ b/internal/store/sqlite/managed_resources_repo.go @@ -9,6 +9,7 @@ import ( type ManagedResource struct { ID int64 BatchID int64 + HostID int64 ResourceType string HostResourceID string ResourceName string @@ -30,6 +31,8 @@ func (r *ManagedResourcesRepo) Create(ctx context.Context, resource ManagedResou switch { case resource.BatchID <= 0: return 0, fmt.Errorf("batch_id is required") + case resource.HostID <= 0: + return 0, fmt.Errorf("host_id is required") case resourceType == "": return 0, fmt.Errorf("resource_type is required") case hostResourceID == "": @@ -38,7 +41,7 @@ func (r *ManagedResourcesRepo) Create(ctx context.Context, resource ManagedResou return 0, fmt.Errorf("resource_name is required") } - result, err := r.db.ExecContext(ctx, `INSERT INTO managed_resources (batch_id, resource_type, host_resource_id, resource_name) VALUES (?, ?, ?, ?)`, resource.BatchID, resourceType, hostResourceID, resourceName) + result, err := r.db.ExecContext(ctx, `INSERT INTO managed_resources (batch_id, host_id, resource_type, host_resource_id, resource_name) VALUES (?, ?, ?, ?, ?)`, resource.BatchID, resource.HostID, resourceType, hostResourceID, resourceName) if err != nil { return 0, fmt.Errorf("insert managed resource %q: %w", hostResourceID, err) } @@ -50,12 +53,32 @@ func (r *ManagedResourcesRepo) Create(ctx context.Context, resource ManagedResou return id, nil } +func (r *ManagedResourcesRepo) GetByResourceIdentity(ctx context.Context, hostID int64, resourceType, hostResourceID string) (ManagedResource, error) { + resourceType = strings.TrimSpace(resourceType) + hostResourceID = strings.TrimSpace(hostResourceID) + if hostID <= 0 { + return ManagedResource{}, fmt.Errorf("host_id is required") + } + if resourceType == "" { + return ManagedResource{}, fmt.Errorf("resource_type is required") + } + if hostResourceID == "" { + return ManagedResource{}, fmt.Errorf("host_resource_id is required") + } + + var resource ManagedResource + if err := r.db.QueryRowContext(ctx, `SELECT id, batch_id, host_id, resource_type, host_resource_id, resource_name FROM managed_resources WHERE host_id = ? AND resource_type = ? AND host_resource_id = ?`, hostID, resourceType, hostResourceID).Scan(&resource.ID, &resource.BatchID, &resource.HostID, &resource.ResourceType, &resource.HostResourceID, &resource.ResourceName); err != nil { + return ManagedResource{}, err + } + return resource, nil +} + func (r *ManagedResourcesRepo) GetByBatchID(ctx context.Context, batchID int64) ([]ManagedResource, error) { if batchID <= 0 { return nil, fmt.Errorf("batch_id is required") } - rows, err := r.db.QueryContext(ctx, `SELECT id, batch_id, resource_type, host_resource_id, resource_name FROM managed_resources WHERE batch_id = ? ORDER BY id`, batchID) + rows, err := r.db.QueryContext(ctx, `SELECT id, batch_id, host_id, resource_type, host_resource_id, resource_name FROM managed_resources WHERE batch_id = ? ORDER BY id`, batchID) if err != nil { return nil, fmt.Errorf("query managed resources: %w", err) } @@ -64,7 +87,7 @@ func (r *ManagedResourcesRepo) GetByBatchID(ctx context.Context, batchID int64) resources := make([]ManagedResource, 0) for rows.Next() { var resource ManagedResource - if err := rows.Scan(&resource.ID, &resource.BatchID, &resource.ResourceType, &resource.HostResourceID, &resource.ResourceName); err != nil { + if err := rows.Scan(&resource.ID, &resource.BatchID, &resource.HostID, &resource.ResourceType, &resource.HostResourceID, &resource.ResourceName); err != nil { return nil, fmt.Errorf("scan managed resource: %w", err) } resources = append(resources, resource) @@ -74,3 +97,64 @@ func (r *ManagedResourcesRepo) GetByBatchID(ctx context.Context, batchID int64) } return resources, nil } + +func (r *ManagedResourcesRepo) ListByProviderID(ctx context.Context, providerID int64) ([]ManagedResource, error) { + if providerID <= 0 { + return nil, fmt.Errorf("provider_id is required") + } + + rows, err := r.db.QueryContext(ctx, `SELECT mr.id, mr.batch_id, mr.host_id, mr.resource_type, mr.host_resource_id, mr.resource_name + FROM managed_resources mr + JOIN import_batches ib ON ib.id = mr.batch_id + WHERE ib.provider_id = ? + ORDER BY mr.id`, providerID) + if err != nil { + return nil, fmt.Errorf("query managed resources by provider_id %d: %w", providerID, err) + } + defer rows.Close() + + resources := make([]ManagedResource, 0) + for rows.Next() { + var resource ManagedResource + if err := rows.Scan(&resource.ID, &resource.BatchID, &resource.HostID, &resource.ResourceType, &resource.HostResourceID, &resource.ResourceName); err != nil { + return nil, fmt.Errorf("scan managed resource by provider_id %d: %w", providerID, err) + } + resources = append(resources, resource) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate managed resources by provider_id %d: %w", providerID, err) + } + return resources, nil +} + +func (r *ManagedResourcesRepo) ListByProviderIDAndHostID(ctx context.Context, providerID, hostID int64) ([]ManagedResource, error) { + if providerID <= 0 { + return nil, fmt.Errorf("provider_id is required") + } + if hostID <= 0 { + return nil, fmt.Errorf("host_id is required") + } + + rows, err := r.db.QueryContext(ctx, `SELECT mr.id, mr.batch_id, mr.host_id, mr.resource_type, mr.host_resource_id, mr.resource_name + FROM managed_resources mr + JOIN import_batches ib ON ib.id = mr.batch_id + WHERE ib.provider_id = ? AND mr.host_id = ? + ORDER BY mr.id`, providerID, hostID) + if err != nil { + return nil, fmt.Errorf("query managed resources by provider_id %d and host_id %d: %w", providerID, hostID, err) + } + defer rows.Close() + + resources := make([]ManagedResource, 0) + for rows.Next() { + var resource ManagedResource + if err := rows.Scan(&resource.ID, &resource.BatchID, &resource.HostID, &resource.ResourceType, &resource.HostResourceID, &resource.ResourceName); err != nil { + return nil, fmt.Errorf("scan managed resource by provider_id %d and host_id %d: %w", providerID, hostID, err) + } + resources = append(resources, resource) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate managed resources by provider_id %d and host_id %d: %w", providerID, hostID, err) + } + return resources, nil +} diff --git a/internal/store/sqlite/packs_repo.go b/internal/store/sqlite/packs_repo.go index 0766678b..fdbd58a9 100644 --- a/internal/store/sqlite/packs_repo.go +++ b/internal/store/sqlite/packs_repo.go @@ -113,6 +113,37 @@ func (r *PacksRepo) Create(ctx context.Context, pack Pack) (int64, error) { return id, nil } +func (r *PacksRepo) ListAll(ctx context.Context) ([]Pack, error) { + rows, err := r.db.QueryContext(ctx, `SELECT id, pack_id, version, checksum, vendor, target_host, min_host_version, max_host_version, manifest_json FROM packs ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("list packs: %w", err) + } + defer rows.Close() + + var packs []Pack + for rows.Next() { + var pack Pack + if err := rows.Scan( + &pack.ID, + &pack.PackID, + &pack.Version, + &pack.Checksum, + &pack.Vendor, + &pack.TargetHost, + &pack.MinHostVersion, + &pack.MaxHostVersion, + &pack.ManifestJSON, + ); err != nil { + return nil, fmt.Errorf("scan pack: %w", err) + } + packs = append(packs, pack) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate packs: %w", err) + } + return packs, nil +} + func (r *PacksRepo) Upsert(ctx context.Context, pack Pack) (int64, error) { packID := strings.TrimSpace(pack.PackID) version := strings.TrimSpace(pack.Version) diff --git a/internal/store/sqlite/packs_repo_test.go b/internal/store/sqlite/packs_repo_test.go index f1d895b9..8028497e 100644 --- a/internal/store/sqlite/packs_repo_test.go +++ b/internal/store/sqlite/packs_repo_test.go @@ -11,14 +11,14 @@ func TestPacksRepoCreateAndGet(t *testing.T) { store := openTestDB(t) id, err := store.Packs().Create(context.Background(), Pack{ - PackID: "test-pack", - Version: "1.0.0", - Checksum: "abc123", - Vendor: "test-vendor", - TargetHost: "sub2api", + PackID: "test-pack", + Version: "1.0.0", + Checksum: "abc123", + Vendor: "test-vendor", + TargetHost: "sub2api", MinHostVersion: "0.1.0", MaxHostVersion: "0.2.x", - ManifestJSON: `{"name":"test"}`, + ManifestJSON: `{"name":"test"}`, }) if err != nil { t.Fatalf("Create() error = %v", err) diff --git a/internal/store/sqlite/providers_repo.go b/internal/store/sqlite/providers_repo.go index 526ed9e2..de9f3f70 100644 --- a/internal/store/sqlite/providers_repo.go +++ b/internal/store/sqlite/providers_repo.go @@ -31,6 +31,46 @@ func newProvidersRepo(db execQuerier) *ProvidersRepo { return &ProvidersRepo{db: db} } +func (r *ProvidersRepo) ListByPackID(ctx context.Context, packID int64) ([]Provider, error) { + if packID <= 0 { + return nil, fmt.Errorf("pack_id is required") + } + + rows, err := r.db.QueryContext(ctx, `SELECT id, pack_id, provider_id, display_name, base_url, platform, account_type, default_models_json, smoke_test_model, group_template_json, channel_template_json, plan_template_json, import_options_json, manifest_json FROM providers WHERE pack_id = ? ORDER BY id`, packID) + if err != nil { + return nil, fmt.Errorf("query providers by pack_id %d: %w", packID, err) + } + defer rows.Close() + + providers := make([]Provider, 0) + for rows.Next() { + var provider Provider + if err := rows.Scan( + &provider.ID, + &provider.PackID, + &provider.ProviderID, + &provider.DisplayName, + &provider.BaseURL, + &provider.Platform, + &provider.AccountType, + &provider.DefaultModelsJSON, + &provider.SmokeTestModel, + &provider.GroupTemplateJSON, + &provider.ChannelTemplateJSON, + &provider.PlanTemplateJSON, + &provider.ImportOptionsJSON, + &provider.ManifestJSON, + ); err != nil { + return nil, fmt.Errorf("scan provider: %w", err) + } + providers = append(providers, provider) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate providers: %w", err) + } + return providers, nil +} + func (r *ProvidersRepo) ListByProviderID(ctx context.Context, providerID string) ([]Provider, error) { providerID = strings.TrimSpace(providerID) if providerID == "" { @@ -103,6 +143,33 @@ func (r *ProvidersRepo) GetByPackIDAndProviderID(ctx context.Context, packID int return provider, nil } +func (r *ProvidersRepo) GetByID(ctx context.Context, id int64) (Provider, error) { + if id <= 0 { + return Provider{}, fmt.Errorf("id is required") + } + + var provider Provider + if err := r.db.QueryRowContext(ctx, `SELECT id, pack_id, provider_id, display_name, base_url, platform, account_type, default_models_json, smoke_test_model, group_template_json, channel_template_json, plan_template_json, import_options_json, manifest_json FROM providers WHERE id = ?`, id).Scan( + &provider.ID, + &provider.PackID, + &provider.ProviderID, + &provider.DisplayName, + &provider.BaseURL, + &provider.Platform, + &provider.AccountType, + &provider.DefaultModelsJSON, + &provider.SmokeTestModel, + &provider.GroupTemplateJSON, + &provider.ChannelTemplateJSON, + &provider.PlanTemplateJSON, + &provider.ImportOptionsJSON, + &provider.ManifestJSON, + ); err != nil { + return Provider{}, err + } + return provider, nil +} + func (r *ProvidersRepo) Create(ctx context.Context, provider Provider) (int64, error) { providerID := strings.TrimSpace(provider.ProviderID) displayName := strings.TrimSpace(provider.DisplayName) diff --git a/internal/store/sqlite/providers_repo_test.go b/internal/store/sqlite/providers_repo_test.go index 1f245d0a..857ea414 100644 --- a/internal/store/sqlite/providers_repo_test.go +++ b/internal/store/sqlite/providers_repo_test.go @@ -12,14 +12,14 @@ func TestProvidersRepoCreateAndGet(t *testing.T) { packID := createTestPack(t, store) providerID, err := store.Providers().Create(context.Background(), Provider{ - PackID: packID, - ProviderID: "deepseek", - DisplayName: "DeepSeek", - BaseURL: "https://api.deepseek.com", - Platform: "openai", - AccountType: "api", + PackID: packID, + ProviderID: "deepseek", + DisplayName: "DeepSeek", + BaseURL: "https://api.deepseek.com", + Platform: "openai", + AccountType: "apikey", SmokeTestModel: "deepseek-chat", - ManifestJSON: `{"models":["deepseek-chat"]}`, + ManifestJSON: `{"models":["deepseek-chat"]}`, }) if err != nil { t.Fatalf("Create() error = %v", err) diff --git a/internal/store/sqlite/reconcile_runs_repo.go b/internal/store/sqlite/reconcile_runs_repo.go index 0d40d077..69ff9195 100644 --- a/internal/store/sqlite/reconcile_runs_repo.go +++ b/internal/store/sqlite/reconcile_runs_repo.go @@ -8,6 +8,8 @@ import ( type ReconcileRun struct { ID int64 + BatchID int64 + HostID int64 ProviderID int64 Status string SummaryJSON string @@ -29,13 +31,17 @@ func (r *ReconcileRunsRepo) Create(ctx context.Context, run ReconcileRun) (int64 } switch { + case run.BatchID <= 0: + return 0, fmt.Errorf("batch_id is required") + case run.HostID <= 0: + return 0, fmt.Errorf("host_id is required") case run.ProviderID <= 0: return 0, fmt.Errorf("provider_id is required") case status == "": return 0, fmt.Errorf("status is required") } - result, err := r.db.ExecContext(ctx, `INSERT INTO reconcile_runs (provider_id, status, summary_json) VALUES (?, ?, ?)`, run.ProviderID, status, summaryJSON) + result, err := r.db.ExecContext(ctx, `INSERT INTO reconcile_runs (batch_id, host_id, provider_id, status, summary_json) VALUES (?, ?, ?, ?, ?)`, run.BatchID, run.HostID, run.ProviderID, status, summaryJSON) if err != nil { return 0, fmt.Errorf("insert reconcile run: %w", err) } @@ -47,12 +53,40 @@ func (r *ReconcileRunsRepo) Create(ctx context.Context, run ReconcileRun) (int64 return id, nil } -func (r *ReconcileRunsRepo) GetByProviderID(ctx context.Context, providerID int64) ([]ReconcileRun, error) { +func (r *ReconcileRunsRepo) GetByBatchID(ctx context.Context, batchID int64) ([]ReconcileRun, error) { + if batchID <= 0 { + return nil, fmt.Errorf("batch_id is required") + } + + rows, err := r.db.QueryContext(ctx, `SELECT id, batch_id, host_id, provider_id, status, summary_json FROM reconcile_runs WHERE batch_id = ? ORDER BY id DESC`, batchID) + if err != nil { + return nil, fmt.Errorf("query reconcile runs by batch_id: %w", err) + } + defer rows.Close() + + runs := make([]ReconcileRun, 0) + for rows.Next() { + var run ReconcileRun + if err := rows.Scan(&run.ID, &run.BatchID, &run.HostID, &run.ProviderID, &run.Status, &run.SummaryJSON); err != nil { + return nil, fmt.Errorf("scan reconcile run by batch_id: %w", err) + } + runs = append(runs, run) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate reconcile runs by batch_id: %w", err) + } + return runs, nil +} + +func (r *ReconcileRunsRepo) GetByProviderIDAndHostID(ctx context.Context, providerID, hostID int64) ([]ReconcileRun, error) { if providerID <= 0 { return nil, fmt.Errorf("provider_id is required") } + if hostID <= 0 { + return nil, fmt.Errorf("host_id is required") + } - rows, err := r.db.QueryContext(ctx, `SELECT id, provider_id, status, summary_json FROM reconcile_runs WHERE provider_id = ? ORDER BY id DESC`, providerID) + rows, err := r.db.QueryContext(ctx, `SELECT id, batch_id, host_id, provider_id, status, summary_json FROM reconcile_runs WHERE provider_id = ? AND host_id = ? ORDER BY id DESC`, providerID, hostID) if err != nil { return nil, fmt.Errorf("query reconcile runs: %w", err) } @@ -61,7 +95,7 @@ func (r *ReconcileRunsRepo) GetByProviderID(ctx context.Context, providerID int6 runs := make([]ReconcileRun, 0) for rows.Next() { var run ReconcileRun - if err := rows.Scan(&run.ID, &run.ProviderID, &run.Status, &run.SummaryJSON); err != nil { + if err := rows.Scan(&run.ID, &run.BatchID, &run.HostID, &run.ProviderID, &run.Status, &run.SummaryJSON); err != nil { return nil, fmt.Errorf("scan reconcile run: %w", err) } runs = append(runs, run) diff --git a/packs/openai-cn-pack/checksums.txt b/packs/openai-cn-pack/checksums.txt index 0ec82e60..888a34ab 100644 --- a/packs/openai-cn-pack/checksums.txt +++ b/packs/openai-cn-pack/checksums.txt @@ -1,2 +1,4 @@ db931e9a90f6c1040d285c65582c5dae4c85075e85ce6d87e59cd39a6441d6f1 pack.json -fc2259a85de73cd14ea3f0d6ffdf71be79296d50cf9cbee604633d36492fec49 providers/deepseek.json +d5425d14f0d9a9e0af756f9af878bc828ce1fd4e0f38f4fffa8ce7e78421c4e9 providers/deepseek.json +5dcc402daddacce6dcaceb1501020342f1b1121fbffe9097ede4d5aae072f84e providers/minimax.json +fa486a449407f38de8b180ff301568deccef5177ca0436158b1d5b0e6d9328b2 providers/openai-zhongzhuan.json diff --git a/packs/openai-cn-pack/providers/deepseek.json b/packs/openai-cn-pack/providers/deepseek.json index 1a2e58b0..4eb2a956 100644 --- a/packs/openai-cn-pack/providers/deepseek.json +++ b/packs/openai-cn-pack/providers/deepseek.json @@ -3,7 +3,7 @@ "display_name": "DeepSeek OpenAI Compatible", "base_url": "https://api.deepseek.com", "platform": "openai", - "account_type": "api", + "account_type": "apikey", "default_models": ["deepseek-chat", "deepseek-reasoner"], "smoke_test_model": "deepseek-chat", "group_template": { diff --git a/packs/openai-cn-pack/providers/minimax.json b/packs/openai-cn-pack/providers/minimax.json new file mode 100644 index 00000000..4a1129ba --- /dev/null +++ b/packs/openai-cn-pack/providers/minimax.json @@ -0,0 +1,31 @@ +{ + "provider_id": "minimax", + "display_name": "MiniMax OpenAI Compatible", + "base_url": "https://v2.aicodee.com/v1", + "platform": "openai", + "account_type": "apikey", + "default_models": ["MiniMax-M2.5-highspeed", "MiniMax-M2.7-highspeed"], + "smoke_test_model": "MiniMax-M2.7-highspeed", + "group_template": { + "name": "MiniMax 默认分组", + "rate_multiplier": 1.0 + }, + "channel_template": { + "name": "MiniMax 默认渠道", + "model_mapping": { + "MiniMax-M2.5-highspeed": "MiniMax-M2.5-highspeed", + "MiniMax-M2.7-highspeed": "MiniMax-M2.7-highspeed" + } + }, + "plan_template": { + "name": "MiniMax 默认套餐", + "price": 19.9, + "validity_days": 30, + "validity_unit": "day" + }, + "import": { + "supports_multi_key": true, + "supports_strict": true, + "supports_partial": true + } +} diff --git a/packs/openai-cn-pack/providers/openai-zhongzhuan.json b/packs/openai-cn-pack/providers/openai-zhongzhuan.json new file mode 100644 index 00000000..8a7d57ac --- /dev/null +++ b/packs/openai-cn-pack/providers/openai-zhongzhuan.json @@ -0,0 +1,31 @@ +{ + "provider_id": "openai-zhongzhuan", + "display_name": "OpenAI 中转兼容", + "base_url": "https://api.asxs.top/v1", + "platform": "openai", + "account_type": "apikey", + "default_models": ["gpt-5.4", "gpt-5.4-mini"], + "smoke_test_model": "gpt-5.4", + "group_template": { + "name": "OpenAI 中转默认分组", + "rate_multiplier": 1.0 + }, + "channel_template": { + "name": "OpenAI 中转默认渠道", + "model_mapping": { + "gpt-5.4": "gpt-5.4", + "gpt-5.4-mini": "gpt-5.4-mini" + } + }, + "plan_template": { + "name": "OpenAI 中转默认套餐", + "price": 19.9, + "validity_days": 30, + "validity_unit": "day" + }, + "import": { + "supports_multi_key": true, + "supports_strict": true, + "supports_partial": true + } +} diff --git a/scripts/build_local_image.sh b/scripts/build_local_image.sh new file mode 100755 index 00000000..56cbf99f --- /dev/null +++ b/scripts/build_local_image.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE_TAG="${IMAGE_TAG:-sub2api-cn-relay-manager:local}" +BINARY_PATH="${BINARY_PATH:-$ROOT_DIR/bin/sub2api-cn-relay-manager}" + +mkdir -p "$(dirname "$BINARY_PATH")" + +echo "[1/2] building linux binary -> $BINARY_PATH" +( + cd "$ROOT_DIR" + GOTOOLCHAIN=local CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags='-s -w' -o "$BINARY_PATH" ./cmd/server +) + +echo "[2/2] building OCI image -> $IMAGE_TAG" +( + cd "$ROOT_DIR" + docker build -f Dockerfile.local -t "$IMAGE_TAG" . +) + +echo "done: $IMAGE_TAG" diff --git a/scripts/real_host_acceptance.sh b/scripts/real_host_acceptance.sh new file mode 100755 index 00000000..7fce7e31 --- /dev/null +++ b/scripts/real_host_acceptance.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" +ARTIFACT_DIR="${ARTIFACT_DIR:-$ROOT_DIR/artifacts/real-host-acceptance/$TIMESTAMP}" +DRY_RUN="${DRY_RUN:-0}" +SKIP_ROLLBACK="${SKIP_ROLLBACK:-0}" + +require_var() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "missing required env: $name" >&2 + exit 1 + fi +} + +json_get() { + local key="$1" + python3 -c 'import json, sys +key = sys.argv[1] +data = json.load(sys.stdin) +value = data +for part in key.split("."): + if isinstance(value, dict): + value = value.get(part) + else: + value = None + break +if value is None: + sys.exit(2) +if isinstance(value, (dict, list)): + print(json.dumps(value, ensure_ascii=False)) +else: + print(value) +' "$key" +} + +save_json() { + local name="$1" + local payload="$2" + mkdir -p "$ARTIFACT_DIR" + printf '%s\n' "$payload" > "$ARTIFACT_DIR/$name.json" +} + +curl_json() { + local method="$1" + local path="$2" + local payload="${3:-}" + local url="${CRM_BASE_URL%/}$path" + if [[ "$DRY_RUN" == "1" ]]; then + echo "[dry-run] $method $url" >&2 + if [[ -n "$payload" ]]; then + printf '%s\n' "$payload" > /dev/stderr + fi + printf '{"dry_run":true,"method":"%s","url":"%s"}\n' "$method" "$url" + return 0 + fi + if [[ -n "$payload" ]]; then + curl -fsS -X "$method" \ + -H "Authorization: Bearer $CRM_ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + "$url" \ + -d "$payload" + else + curl -fsS -X "$method" \ + -H "Authorization: Bearer $CRM_ADMIN_TOKEN" \ + "$url" + fi +} + +build_host_auth_payload() { + python3 - <<'PY' +import json, os +host_type = os.environ['HOST_AUTH_TYPE'] +host_token = os.environ['HOST_AUTH_TOKEN'] +print(json.dumps({"type": host_type, "token": host_token}, ensure_ascii=False)) +PY +} + +build_host_credentials_payload() { + python3 - <<'PY' +import json, os +payload = { + "host_base_url": os.environ["HOST_BASE_URL"], + "pack_path": os.environ["PACK_PATH"], + "provider_id": os.environ["PROVIDER_ID"], +} +if os.environ.get("HOST_API_KEY"): + payload["host_api_key"] = os.environ["HOST_API_KEY"] +if os.environ.get("HOST_BEARER_TOKEN"): + payload["host_bearer_token"] = os.environ["HOST_BEARER_TOKEN"] +if os.environ.get("ACCESS_API_KEY"): + payload["access_api_key"] = os.environ["ACCESS_API_KEY"] +if os.environ.get("ACCESS_MODE"): + payload["access_mode"] = os.environ["ACCESS_MODE"] +if os.environ.get("MODE"): + payload["mode"] = os.environ["MODE"] +if os.environ.get("SUBSCRIPTION_DAYS"): + payload["subscription_days"] = int(os.environ["SUBSCRIPTION_DAYS"]) +if os.environ.get("SUBSCRIPTION_USERS"): + payload["subscription_users"] = [x.strip() for x in os.environ["SUBSCRIPTION_USERS"].split(',') if x.strip()] +if os.environ.get("KEYS"): + payload["keys"] = [x.strip() for x in os.environ["KEYS"].split(',') if x.strip()] +print(json.dumps(payload, ensure_ascii=False)) +PY +} + +require_var CRM_BASE_URL +require_var CRM_ADMIN_TOKEN +require_var HOST_NAME +require_var HOST_BASE_URL +require_var PACK_PATH +require_var PROVIDER_ID + +MODE="${MODE:-partial}" +ACCESS_MODE="${ACCESS_MODE:-self_service}" +SUBSCRIPTION_DAYS="${SUBSCRIPTION_DAYS:-30}" + +if [[ -n "${HOST_BEARER_TOKEN:-}" ]]; then + HOST_AUTH_TYPE="${HOST_AUTH_TYPE:-bearer}" + HOST_AUTH_TOKEN="${HOST_AUTH_TOKEN:-$HOST_BEARER_TOKEN}" +elif [[ -n "${HOST_API_KEY:-}" ]]; then + HOST_AUTH_TYPE="${HOST_AUTH_TYPE:-apikey}" + HOST_AUTH_TOKEN="${HOST_AUTH_TOKEN:-$HOST_API_KEY}" +else + echo "missing host credential: set HOST_API_KEY or HOST_BEARER_TOKEN" >&2 + exit 1 +fi + +export CRM_BASE_URL CRM_ADMIN_TOKEN HOST_NAME HOST_BASE_URL PACK_PATH PROVIDER_ID +export HOST_AUTH_TYPE HOST_AUTH_TOKEN MODE ACCESS_MODE SUBSCRIPTION_DAYS +export HOST_API_KEY HOST_BEARER_TOKEN ACCESS_API_KEY SUBSCRIPTION_USERS KEYS + +mkdir -p "$ARTIFACT_DIR" +echo "artifacts: $ARTIFACT_DIR" + +HOST_AUTH_JSON="$(build_host_auth_payload)" +export HOST_AUTH_JSON +CREATE_HOST_PAYLOAD="$(python3 - <<'PY' +import json, os +host_auth = json.loads(os.environ['HOST_AUTH_JSON']) +print(json.dumps({ + 'name': os.environ['HOST_NAME'], + 'base_url': os.environ['HOST_BASE_URL'], + 'auth': host_auth, +}, ensure_ascii=False)) +PY +)" + +if RESP_EXISTING_HOST="$(curl_json GET "/api/hosts/$HOST_NAME" 2>/dev/null)"; then + RESP_CREATE_HOST="$RESP_EXISTING_HOST" +else + RESP_CREATE_HOST="$(curl_json POST /api/hosts "$CREATE_HOST_PAYLOAD")" +fi +save_json 01-create-host "$RESP_CREATE_HOST" +HOST_ID="$(printf '%s' "$RESP_CREATE_HOST" | json_get host_id || true)" +HOST_ID="${HOST_ID:-$HOST_NAME}" + +echo "host_id=$HOST_ID" + +PROBE_PAYLOAD="$(python3 - <<'PY' +import json, os +print(json.dumps({'auth': json.loads(os.environ['HOST_AUTH_JSON'])}, ensure_ascii=False)) +PY +)" +RESP_PROBE="$(curl_json POST "/api/hosts/$HOST_ID/probe" "$PROBE_PAYLOAD")" +save_json 02-probe-host "$RESP_PROBE" + +INSTALL_PAYLOAD="$(python3 - <<'PY' +import json, os +payload = { + 'host_base_url': os.environ['HOST_BASE_URL'], + 'pack_path': os.environ['PACK_PATH'], +} +if os.environ.get('HOST_API_KEY'): + payload['host_api_key'] = os.environ['HOST_API_KEY'] +if os.environ.get('HOST_BEARER_TOKEN'): + payload['host_bearer_token'] = os.environ['HOST_BEARER_TOKEN'] +print(json.dumps(payload, ensure_ascii=False)) +PY +)" +RESP_INSTALL="$(curl_json POST /api/packs/install "$INSTALL_PAYLOAD")" +save_json 03-install-pack "$RESP_INSTALL" + +PREVIEW_PAYLOAD="$(python3 - <<'PY' +import json, os +payload = { + "host_base_url": os.environ["HOST_BASE_URL"], + "pack_path": os.environ["PACK_PATH"], + "provider_id": os.environ["PROVIDER_ID"], + "mode": os.environ.get("MODE", "partial"), +} +if os.environ.get("HOST_API_KEY"): + payload["host_api_key"] = os.environ["HOST_API_KEY"] +if os.environ.get("HOST_BEARER_TOKEN"): + payload["host_bearer_token"] = os.environ["HOST_BEARER_TOKEN"] +if os.environ.get("KEYS"): + payload["keys"] = [x.strip() for x in os.environ["KEYS"].split(',') if x.strip()] +print(json.dumps(payload, ensure_ascii=False)) +PY +)" +RESP_PREVIEW="$(curl_json POST "/api/providers/$PROVIDER_ID/preview-import" "$PREVIEW_PAYLOAD")" +save_json 04-preview-import "$RESP_PREVIEW" + +IMPORT_PAYLOAD="$(build_host_credentials_payload)" +RESP_IMPORT="$(curl_json POST "/api/providers/$PROVIDER_ID/import" "$IMPORT_PAYLOAD")" +save_json 05-import "$RESP_IMPORT" +BATCH_ID="$(printf '%s' "$RESP_IMPORT" | json_get batch_id || true)" + +echo "batch_id=${BATCH_ID:-unknown}" + +ACCESS_PREVIEW_PAYLOAD="$(python3 - <<'PY' +import json, os +payload = { + 'provider_id': os.environ['PROVIDER_ID'], + 'mode': os.environ.get('ACCESS_MODE', 'self_service'), +} +print(json.dumps(payload, ensure_ascii=False)) +PY +)" +RESP_ACCESS_PREVIEW="$(curl_json POST "/api/providers/$PROVIDER_ID/access/preview" "$ACCESS_PREVIEW_PAYLOAD")" +save_json 06-access-preview "$RESP_ACCESS_PREVIEW" + +RESP_ACCESS_STATUS="$(curl_json GET "/api/providers/$PROVIDER_ID/access/status")" +save_json 07-access-status "$RESP_ACCESS_STATUS" + +RESP_PROVIDER_STATUS="$(curl_json GET "/api/providers/$PROVIDER_ID/status")" +save_json 08-provider-status "$RESP_PROVIDER_STATUS" + +RECONCILE_PAYLOAD="$(python3 - <<'PY' +import json, os +payload = { + "host_base_url": os.environ["HOST_BASE_URL"], + "pack_path": os.environ["PACK_PATH"], + "provider_id": os.environ["PROVIDER_ID"], +} +if os.environ.get("HOST_API_KEY"): + payload["host_api_key"] = os.environ["HOST_API_KEY"] +if os.environ.get("HOST_BEARER_TOKEN"): + payload["host_bearer_token"] = os.environ["HOST_BEARER_TOKEN"] +if os.environ.get("ACCESS_API_KEY"): + payload["access_api_key"] = os.environ["ACCESS_API_KEY"] +print(json.dumps(payload, ensure_ascii=False)) +PY +)" +RESP_RECONCILE="$(curl_json POST "/api/providers/$PROVIDER_ID/reconcile" "$RECONCILE_PAYLOAD")" +save_json 09-reconcile "$RESP_RECONCILE" + +if [[ -n "$BATCH_ID" && "$DRY_RUN" != "1" ]]; then + RESP_BATCH_DETAIL="$(curl_json GET "/api/import-batches/$BATCH_ID")" + save_json 10-batch-detail "$RESP_BATCH_DETAIL" +fi + +if [[ "$SKIP_ROLLBACK" != "1" && -n "$BATCH_ID" ]]; then + ROLLBACK_PAYLOAD="$(python3 - <<'PY' +import json, os +print(json.dumps({'auth': json.loads(os.environ['HOST_AUTH_JSON'])}, ensure_ascii=False)) +PY +)" + RESP_ROLLBACK="$(curl_json POST "/api/import-batches/$BATCH_ID/rollback" "$ROLLBACK_PAYLOAD")" + save_json 11-rollback "$RESP_ROLLBACK" +fi + +echo "acceptance flow completed" diff --git a/tests/integration/host_stub_test.go b/tests/integration/host_stub_test.go index e2e4a5f5..a6d3d1f5 100644 --- a/tests/integration/host_stub_test.go +++ b/tests/integration/host_stub_test.go @@ -439,6 +439,10 @@ func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server accountID, action := parts[0], parts[1] switch action { case "test": + if r.Method == http.MethodGet { + writeJSON(t, w, http.StatusOK, map[string]any{"data": map[string]any{"supported": true}}) + return + } if r.Method != http.MethodPost { http.NotFound(w, r) return @@ -468,6 +472,12 @@ func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server if !mustStubAuth(t, w, r, cfg.requireAPIKey) { return } + if r.Method == http.MethodGet { + writeJSON(t, w, http.StatusOK, map[string]any{ + "data": map[string]any{"supported": true}, + }) + return + } if r.Method != http.MethodPost { http.NotFound(w, r) return diff --git a/tests/integration/store_init_test.go b/tests/integration/store_init_test.go index 67f683cc..05487f08 100644 --- a/tests/integration/store_init_test.go +++ b/tests/integration/store_init_test.go @@ -108,8 +108,8 @@ func TestStoreInitRecordsMigrationLedgerOnce(t *testing.T) { if err != nil { t.Fatalf("first sqlite.Open() error = %v", err) } - if got := countRows(t, store1.SQLDB(), "schema_migrations"); got != 3 { - t.Fatalf("schema_migrations row count after first open = %d, want 3", got) + if got := countRows(t, store1.SQLDB(), "schema_migrations"); got != 6 { + t.Fatalf("schema_migrations row count after first open = %d, want 6", got) } if err := store1.Close(); err != nil { t.Fatalf("first store.Close() error = %v", err) @@ -121,8 +121,8 @@ func TestStoreInitRecordsMigrationLedgerOnce(t *testing.T) { } defer closeTestStore(t, store2) - if got := countRows(t, store2.SQLDB(), "schema_migrations"); got != 3 { - t.Fatalf("schema_migrations row count after second open = %d, want 3", got) + if got := countRows(t, store2.SQLDB(), "schema_migrations"); got != 6 { + t.Fatalf("schema_migrations row count after second open = %d, want 6", got) } } @@ -140,8 +140,8 @@ func TestStoreInitBackfillsLedgerForCompletePreLedgerSchema(t *testing.T) { } defer closeTestStore(t, store) - if got := countRows(t, store.SQLDB(), "schema_migrations"); got != 3 { - t.Fatalf("schema_migrations row count after backfill = %d, want 3", got) + if got := countRows(t, store.SQLDB(), "schema_migrations"); got != 6 { + t.Fatalf("schema_migrations row count after backfill = %d, want 6", got) } } diff --git a/tests/integration/store_runtime_test.go b/tests/integration/store_runtime_test.go index 5e2a0537..edda75d9 100644 --- a/tests/integration/store_runtime_test.go +++ b/tests/integration/store_runtime_test.go @@ -87,6 +87,7 @@ func TestStoreRuntimePersistsOperationalRecords(t *testing.T) { if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{ BatchID: batchID, + HostID: hostID, ResourceType: "group", HostResourceID: "group-1", ResourceName: "deepseek-group", @@ -113,6 +114,8 @@ func TestStoreRuntimePersistsOperationalRecords(t *testing.T) { } if _, err := store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{ + BatchID: batchID, + HostID: hostID, ProviderID: providerID, Status: "drifted", SummaryJSON: `{"missing_resources":1}`,