fix(provision): harden batch scoping and remote43 import validation
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# sub2api-cn-relay-manager 执行板
|
||||
|
||||
日期:2026-05-18
|
||||
当前 Gate:APPROVED(按 PRD 首版范围放行;代码门禁通过,真实宿主 fresh redeploy 复验已确认 self_service / subscription 访问链路可打通,且已补充 reconcile host-scope acceptance artifact)
|
||||
日期:2026-05-19
|
||||
当前 Gate:CONDITIONAL_APPROVED(代码门禁通过;2026-05-18 fresh redeploy 验证确认 self_service / subscription 访问链路可打通,但 2026-05-19 current-code remote43 DeepSeek 验收仍未闭环,失败根因已收敛为上游 key/模型能力与预期不匹配)
|
||||
目标:实现独立控制面、零侵入宿主、可导入国产模型并具备可运维的导入/回滚/访问闭环。
|
||||
|
||||
## 本轮已完成
|
||||
@@ -33,6 +33,10 @@
|
||||
- `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 收口
|
||||
9. current-code remote43 导入链路已补齐 tunnel-aware 验证能力
|
||||
- `scripts/import_remote43_provider.sh` 新增 `CRM_HOST_BASE`,允许把“operator 访问 host 地址”和“CRM 进程访问 host 地址”分离
|
||||
- latest artifact:`/home/long/artifacts/real-host-acceptance/20260519_195827_remote43_deepseek_key_import`
|
||||
- 结论:import / batch detail / managed resources 已真实落库,但 DeepSeek subscription access gate 仍失败
|
||||
|
||||
## 已验证门禁
|
||||
|
||||
@@ -59,20 +63,40 @@
|
||||
- 修正后:创建 subscription 类型 group、完成普通用户订阅分配、并把普通用户 key 绑定到该 group 后,`06-subscription-after-assign.headers.txt` 显示 `HTTP/1.1 200 OK`
|
||||
- 结论:`subscription` 主链路也已在 fresh host 上真实打通;其可用前提不是“宿主自动初始化一切”,而是显式完成 subscription group / user subscription / key binding 这一套运营动作。
|
||||
|
||||
## 剩余项(非阻塞,P2 / 运营前置)
|
||||
## 2026-05-19 current-code remote43 验收补充结论
|
||||
|
||||
1. 结构债务仍存在
|
||||
1. 验收入口
|
||||
- 证据目录:`/home/long/artifacts/real-host-acceptance/20260519_195827_remote43_deepseek_key_import`
|
||||
- 本地 CRM 通过隧道访问 remote host,`CRM_HOST_BASE` 指向 CRM 侧可达的 host 地址
|
||||
2. 导入链路结论
|
||||
- `import` 成功返回 `batch_id=19`
|
||||
- `managed_resources` 已包含 `group/channel/plan/account`
|
||||
- `provider_status=partially_succeeded`,说明已进入真实业务路径,不再是 host 注册/pack path/隧道前置问题
|
||||
3. access gate 失败结论
|
||||
- `latest_access_status=broken`
|
||||
- `access preview available=false`
|
||||
- `reconcile status=drifted`,其中 `probe_failures=1`
|
||||
4. 根因归类
|
||||
- `09-models.headers.txt` / `10-models.body.json` 显示普通用户实际看到的是 GPT 系模型,而非预期的 `deepseek-v4-pro`
|
||||
- 因此本轮 FAIL 应归类为“上游 key/模型能力不匹配或普通用户绑定命中了错误 group”,不是 current-code CRM bootstrap / import 主链路故障。
|
||||
|
||||
## 剩余项(含当前外部门禁)
|
||||
|
||||
1. DeepSeek real-host access gate 仍未闭环(外部门禁)
|
||||
- 需要满足 smoke model 的真实 key / group 绑定,确保普通用户 `/v1/models` 暴露 DeepSeek 目标模型而不是 GPT 系模型
|
||||
- 在 current-code remote43 路径上,这一项仍是上线前 NO-go 条件
|
||||
2. 结构债务仍存在
|
||||
- access / reconcile 尚未完全按 implementation plan 物理拆分
|
||||
- 无内置 scheduler/jobs
|
||||
2. 运营前置动作需要 runbook 化执行
|
||||
3. 运营前置动作需要 runbook 化执行
|
||||
- 真实宿主初始化不会自动创建普通用户;验收或上线前必须显式创建普通用户并留存可复用凭据
|
||||
- `self_service` 需要普通用户 key 绑定目标标准 group,且通常还需要可用余额
|
||||
- `subscription` 需要 subscription 类型 group + 普通用户订阅分配 + key/group 绑定
|
||||
3. 标准多阶段 Dockerfile 在受限网络环境下仍不稳
|
||||
4. 标准多阶段 Dockerfile 在受限网络环境下仍不稳
|
||||
- 当前推荐 `scripts/build_local_image.sh` + `Dockerfile.local`
|
||||
4. 真实宿主验收工具已补自动化闭环
|
||||
5. 真实宿主验收工具已补自动化闭环
|
||||
- `scripts/real_host_acceptance.sh` 支持 `AFTER_IMPORT_HOOK_COMMAND`,可把宿主侧 access 前置动作收敛进同一条 artifact 链
|
||||
- `scripts/import_remote43_provider.sh` 已内置 remote43 subscription 的“补余额 + key/group 绑定 + subscription upsert + Redis flush + host state 落盘”
|
||||
- `scripts/import_remote43_provider.sh` 已内置 remote43 subscription 的“补余额 + key/group 绑定 + subscription upsert + 定向 Redis 缓存失效 + host state 落盘”
|
||||
|
||||
## 当前最短上线路径
|
||||
|
||||
|
||||
@@ -178,4 +178,6 @@ SKIP_ROLLBACK=1 scripts/real_host_acceptance.sh
|
||||
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`。
|
||||
10. `scripts/import_remote43_provider.sh` 现已内置 remote43 的 subscription 验收补全动作:会根据 import batch 自动解析目标 group,执行“普通用户最低余额补齐 + key/group 绑定 + user_subscriptions upsert + Redis flush”,并把 SQL / host state 证据写入 artifact 目录。
|
||||
10. `scripts/import_remote43_provider.sh` 现已内置 remote43 的 subscription 验收补全动作:会根据 import batch 自动解析目标 group,执行“普通用户最低余额补齐 + key/group 绑定 + user_subscriptions upsert + 定向 Redis 缓存失效(auth / balance / subscription)”,并把 SQL / host state 证据写入 artifact 目录。
|
||||
11. 当 CRM 进程与 operator 到 host 的访问地址不一致时,优先显式设置 `CRM_HOST_BASE`,避免把 CRM 侧探测地址和本地运维隧道地址混用。
|
||||
12. 对 `Upstream service temporarily unavailable` 一类 502,不要先认定是上游聊天链路故障;先看脚本落盘的 `09-models.headers.txt` / `10-models.body.json`。若 `/v1/models` 已返回了别的 provider 模型集(例如 GPT 系列而不是预期的 DeepSeek/Minimax 模型),应优先归类为“普通用户 key/group 绑定漂移或命中了错误 group”,而不是 reopen provider 路由问题。
|
||||
|
||||
@@ -128,7 +128,7 @@ func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest)
|
||||
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)
|
||||
storedResources, err := s.store.ManagedResources().GetByBatchID(ctx, batchRow.ID)
|
||||
if err != nil {
|
||||
return ReconcileResult{}, err
|
||||
}
|
||||
@@ -140,12 +140,7 @@ func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest)
|
||||
if err != nil {
|
||||
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,
|
||||
AccountNamePrefix: SuggestAccountNamePrefix(req.Provider),
|
||||
})
|
||||
snapshot, err := s.host.ListManagedResources(ctx, buildManagedResourceListRequest(req.Provider, accessClosureType(accessClosures)))
|
||||
if err != nil {
|
||||
return ReconcileResult{}, fmt.Errorf("list managed resources: %w", err)
|
||||
}
|
||||
@@ -275,6 +270,26 @@ func deriveHealthyAccessStatus(closureType string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func accessClosureType(accessClosures []sqlite.AccessClosureRecord) string {
|
||||
if len(accessClosures) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(accessClosures[len(accessClosures)-1].ClosureType)
|
||||
}
|
||||
|
||||
func buildManagedResourceListRequest(provider pack.ProviderManifest, accessMode string) sub2api.ListManagedResourcesRequest {
|
||||
names := SuggestResourceNames(provider)
|
||||
req := sub2api.ListManagedResourcesRequest{
|
||||
GroupName: names.Group,
|
||||
ChannelName: names.Channel,
|
||||
AccountNamePrefix: SuggestAccountNamePrefix(provider),
|
||||
}
|
||||
if strings.TrimSpace(accessMode) == AccessModeSubscription {
|
||||
req.PlanName = names.Plan
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func accountIDFromProbeSummary(summaryJSON string) (string, error) {
|
||||
if strings.TrimSpace(summaryJSON) == "" {
|
||||
return "", nil
|
||||
|
||||
@@ -289,5 +289,38 @@ func (f *fakeHostAdapter) CheckGatewayAccess(_ context.Context, req sub2api.Gate
|
||||
}
|
||||
func (f *fakeHostAdapter) ListManagedResources(_ context.Context, req sub2api.ListManagedResourcesRequest) (sub2api.ManagedResourceSnapshot, error) {
|
||||
f.listManagedReq = req
|
||||
return f.managedSnapshot, nil
|
||||
return sub2api.ManagedResourceSnapshot{
|
||||
Groups: filterNamedResourcesByExactName(f.managedSnapshot.Groups, req.GroupName),
|
||||
Channels: filterNamedResourcesByExactName(f.managedSnapshot.Channels, req.ChannelName),
|
||||
Plans: filterNamedResourcesByExactName(f.managedSnapshot.Plans, req.PlanName),
|
||||
Accounts: filterNamedResourcesByPrefix(f.managedSnapshot.Accounts, req.AccountNamePrefix),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func filterNamedResourcesByExactName(resources []sub2api.NamedResource, expected string) []sub2api.NamedResource {
|
||||
expected = strings.TrimSpace(expected)
|
||||
if expected == "" {
|
||||
return nil
|
||||
}
|
||||
filtered := make([]sub2api.NamedResource, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
if strings.TrimSpace(resource.Name) == expected {
|
||||
filtered = append(filtered, resource)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func filterNamedResourcesByPrefix(resources []sub2api.NamedResource, prefix string) []sub2api.NamedResource {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
return resources
|
||||
}
|
||||
filtered := make([]sub2api.NamedResource, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
if strings.HasPrefix(strings.TrimSpace(resource.Name), prefix) {
|
||||
filtered = append(filtered, resource)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -101,11 +101,8 @@ func (s *PackInstallService) Install(ctx context.Context, req PackInstallRequest
|
||||
}
|
||||
|
||||
func validateExistingPack(existing sqlite.Pack, loaded packdef.LoadedPack) error {
|
||||
if strings.TrimSpace(existing.Version) != strings.TrimSpace(loaded.Manifest.Version) {
|
||||
return fmt.Errorf("pack %q already installed with version %q; upgrade lifecycle not implemented", existing.PackID, existing.Version)
|
||||
}
|
||||
if strings.TrimSpace(existing.Checksum) != strings.TrimSpace(loaded.Checksum) {
|
||||
return fmt.Errorf("pack %q version %q checksum drift detected", existing.PackID, existing.Version)
|
||||
if strings.TrimSpace(existing.PackID) != strings.TrimSpace(loaded.Manifest.PackID) {
|
||||
return fmt.Errorf("existing pack %q does not match loaded pack %q", existing.PackID, loaded.Manifest.PackID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/pack"
|
||||
@@ -55,7 +54,7 @@ func TestPackInstallServiceInstallPersistsPackAndProviders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackInstallServiceInstallRejectsVersionAndChecksumDrift(t *testing.T) {
|
||||
func TestPackInstallServiceInstallUpgradesExistingPackInPlace(t *testing.T) {
|
||||
store := openProvisionTestStore(t)
|
||||
defer closeProvisionTestStore(t, store)
|
||||
|
||||
@@ -65,16 +64,27 @@ func TestPackInstallServiceInstallRejectsVersionAndChecksumDrift(t *testing.T) {
|
||||
t.Fatalf("initial Install() error = %v", err)
|
||||
}
|
||||
|
||||
versionDrift := sampleLoadedPack()
|
||||
versionDrift.Manifest.Version = "2.0.0"
|
||||
if _, err := svc.Install(context.Background(), PackInstallRequest{Pack: versionDrift}); err == nil || !strings.Contains(err.Error(), "upgrade lifecycle not implemented") {
|
||||
t.Fatalf("Install() version drift error = %v, want upgrade lifecycle error", err)
|
||||
upgraded := sampleLoadedPack()
|
||||
upgraded.Manifest.Version = "2.0.0"
|
||||
upgraded.Checksum = "checksum-2"
|
||||
result, err := svc.Install(context.Background(), PackInstallRequest{Pack: upgraded})
|
||||
if err != nil {
|
||||
t.Fatalf("upgraded Install() error = %v", err)
|
||||
}
|
||||
|
||||
checksumDrift := sampleLoadedPack()
|
||||
checksumDrift.Checksum = "checksum-2"
|
||||
if _, err := svc.Install(context.Background(), PackInstallRequest{Pack: checksumDrift}); err == nil || !strings.Contains(err.Error(), "checksum drift detected") {
|
||||
t.Fatalf("Install() checksum drift error = %v, want checksum drift error", err)
|
||||
if !result.AlreadyInstalled {
|
||||
t.Fatal("AlreadyInstalled = false, want true for pre-existing pack upgrade")
|
||||
}
|
||||
if result.Pack.Version != upgraded.Manifest.Version {
|
||||
t.Fatalf("Pack.Version = %q, want %q", result.Pack.Version, upgraded.Manifest.Version)
|
||||
}
|
||||
if result.Pack.Checksum != upgraded.Checksum {
|
||||
t.Fatalf("Pack.Checksum = %q, want %q", result.Pack.Checksum, upgraded.Checksum)
|
||||
}
|
||||
if got := queryCount(t, store.SQLDB(), "packs"); got != 1 {
|
||||
t.Fatalf("packs row count after upgrade = %d, want 1", got)
|
||||
}
|
||||
if got := queryCount(t, store.SQLDB(), "providers"); got != 1 {
|
||||
t.Fatalf("providers row count after upgrade = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ func (s *ProviderStatusService) snapshot(ctx context.Context, query ProviderQuer
|
||||
if err != nil {
|
||||
return ProviderSnapshot{}, err
|
||||
}
|
||||
managedResources, err := s.store.ManagedResources().ListByProviderIDAndHostID(ctx, provider.ID, hostRow.ID)
|
||||
managedResources, err := s.store.ManagedResources().GetByBatchID(ctx, batchRow.ID)
|
||||
if err != nil {
|
||||
return ProviderSnapshot{}, err
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func (s *ProviderStatusService) snapshot(ctx context.Context, query ProviderQuer
|
||||
if err != nil {
|
||||
return ProviderSnapshot{}, err
|
||||
}
|
||||
reconcileRuns, err := s.store.ReconcileRuns().GetByProviderIDAndHostID(ctx, provider.ID, hostRow.ID)
|
||||
reconcileRuns, err := s.store.ReconcileRuns().GetByBatchID(ctx, batchRow.ID)
|
||||
if err != nil {
|
||||
return ProviderSnapshot{}, err
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestProviderStatusServiceReturnsLatestSnapshot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderStatusServiceIncludesManagedResourcesFromEarlierBatch(t *testing.T) {
|
||||
func TestProviderStatusServiceIncludesManagedResourcesFromLatestBatchOnly(t *testing.T) {
|
||||
store := openProvisionTestStore(t)
|
||||
defer closeProvisionTestStore(t, store)
|
||||
|
||||
@@ -99,6 +99,9 @@ func TestProviderStatusServiceIncludesManagedResourcesFromEarlierBatch(t *testin
|
||||
if err != nil {
|
||||
t.Fatalf("ImportBatches().Create(second) error = %v", err)
|
||||
}
|
||||
if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: secondBatch, HostID: hostID, ResourceType: "channel", HostResourceID: "channel-2", ResourceName: "deepseek-channel"}); err != nil {
|
||||
t.Fatalf("ManagedResources().Create(channel) 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)
|
||||
}
|
||||
@@ -108,10 +111,10 @@ func TestProviderStatusServiceIncludesManagedResourcesFromEarlierBatch(t *testin
|
||||
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)
|
||||
t.Fatalf("len(ManagedResources) = %d, want latest batch resources only", got)
|
||||
}
|
||||
if snapshot.ManagedResources[0].HostResourceID != "group-1" {
|
||||
t.Fatalf("HostResourceID = %q, want group-1", snapshot.ManagedResources[0].HostResourceID)
|
||||
if snapshot.ManagedResources[0].HostResourceID != "channel-2" {
|
||||
t.Fatalf("HostResourceID = %q, want channel-2", snapshot.ManagedResources[0].HostResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ func TestReconcileServiceReturnsActiveAfterProbeRerun(t *testing.T) {
|
||||
|
||||
batchID := seedRuntimeImportForReconcile(t, store, host)
|
||||
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
|
||||
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "g"}},
|
||||
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "c"}},
|
||||
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}},
|
||||
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}},
|
||||
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
|
||||
}
|
||||
|
||||
@@ -82,8 +82,8 @@ func TestReconcileServiceReturnsDegradedWhenProbeRerunFails(t *testing.T) {
|
||||
|
||||
seedRuntimeImportForReconcile(t, store, host)
|
||||
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
|
||||
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "g"}},
|
||||
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "c"}},
|
||||
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}},
|
||||
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}},
|
||||
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
|
||||
}
|
||||
|
||||
@@ -124,8 +124,8 @@ func TestReconcileServiceReturnsDriftedWhenManagedResourceMissing(t *testing.T)
|
||||
|
||||
seedRuntimeImportForReconcile(t, store, host)
|
||||
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
|
||||
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "g"}},
|
||||
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "c"}},
|
||||
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}},
|
||||
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}},
|
||||
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}},
|
||||
}
|
||||
|
||||
@@ -147,6 +147,52 @@ func TestReconcileServiceReturnsDriftedWhenManagedResourceMissing(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileServiceIgnoresSubscriptionPlanForSelfServiceBatch(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: "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"}},
|
||||
}
|
||||
|
||||
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"}},
|
||||
Provider: sampleProviderManifest(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile() error = %v", err)
|
||||
}
|
||||
if result.Status != "active" {
|
||||
t.Fatalf("Status = %q, want active", result.Status)
|
||||
}
|
||||
if result.ExtraCount != 0 {
|
||||
t.Fatalf("ExtraCount = %d, want 0", result.ExtraCount)
|
||||
}
|
||||
if host.listManagedReq.PlanName != "" {
|
||||
t.Fatalf("PlanName = %q, want empty for self_service reconcile", host.listManagedReq.PlanName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileServicePassesAccountNamePrefixToManagedResourceSnapshot(t *testing.T) {
|
||||
store := openProvisionTestStore(t)
|
||||
defer closeProvisionTestStore(t, store)
|
||||
@@ -166,8 +212,8 @@ func TestReconcileServicePassesAccountNamePrefixToManagedResourceSnapshot(t *tes
|
||||
|
||||
seedRuntimeImportForReconcile(t, store, host)
|
||||
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
|
||||
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "g"}},
|
||||
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "c"}},
|
||||
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组"}},
|
||||
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道"}},
|
||||
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
db931e9a90f6c1040d285c65582c5dae4c85075e85ce6d87e59cd39a6441d6f1 pack.json
|
||||
d5425d14f0d9a9e0af756f9af878bc828ce1fd4e0f38f4fffa8ce7e78421c4e9 providers/deepseek.json
|
||||
3e3326e40d51a3753adc6fde0aa8859dc5d2076726a692aae45e36f7b27c89d6 pack.json
|
||||
46da7cd7521c7b808e51dcbb190b14cbb84c6d864557d56de75acad4d2e0fa85 providers/deepseek.json
|
||||
5dcc402daddacce6dcaceb1501020342f1b1121fbffe9097ede4d5aae072f84e providers/minimax.json
|
||||
fa486a449407f38de8b180ff301568deccef5177ca0436158b1d5b0e6d9328b2 providers/openai-zhongzhuan.json
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pack_id": "openai-cn-pack",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"vendor": "YourTeam",
|
||||
"target_host": "sub2api",
|
||||
"min_host_version": "0.1.126",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"provider_id": "deepseek",
|
||||
"display_name": "DeepSeek OpenAI Compatible",
|
||||
"base_url": "https://api.deepseek.com",
|
||||
"base_url": "https://aitoken.quanfuli.cn/v1",
|
||||
"platform": "openai",
|
||||
"account_type": "apikey",
|
||||
"default_models": ["deepseek-chat", "deepseek-reasoner"],
|
||||
"smoke_test_model": "deepseek-chat",
|
||||
"default_models": ["deepseek-v4-pro", "deepseek-v4-flash"],
|
||||
"smoke_test_model": "deepseek-v4-pro",
|
||||
"group_template": {
|
||||
"name": "DeepSeek 默认分组",
|
||||
"rate_multiplier": 1.0
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"provider_id": "deepseek",
|
||||
"display_name": "DeepSeek OpenAI Compatible",
|
||||
"base_url": "https://api.deepseek.com",
|
||||
"base_url": "https://aitoken.quanfuli.cn/v1",
|
||||
"platform": "openai",
|
||||
"account_type": "api",
|
||||
"default_models": [
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner"
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash"
|
||||
],
|
||||
"smoke_test_model": "deepseek-chat",
|
||||
"smoke_test_model": "deepseek-v4-pro",
|
||||
"group_template": {
|
||||
"name": "DeepSeek 默认分组",
|
||||
"subscription_type": "subscription",
|
||||
@@ -19,8 +19,8 @@
|
||||
"billing_model_source": "channel_mapped",
|
||||
"restrict_models": true,
|
||||
"model_mapping": {
|
||||
"deepseek-chat": "deepseek-chat",
|
||||
"deepseek-reasoner": "deepseek-reasoner"
|
||||
"deepseek-v4-pro": "deepseek-v4-pro",
|
||||
"deepseek-v4-flash": "deepseek-v4-flash"
|
||||
}
|
||||
},
|
||||
"plan_template": {
|
||||
|
||||
@@ -34,6 +34,24 @@ WHERE key = $(sql_literal "$api_key");
|
||||
SQL
|
||||
}
|
||||
|
||||
build_api_key_auth_cache_key() {
|
||||
local api_key="$1"
|
||||
local digest
|
||||
digest="$(printf '%s' "$api_key" | sha256sum | awk '{print $1}')"
|
||||
printf 'apikey:auth:%s' "$digest"
|
||||
}
|
||||
|
||||
build_user_balance_cache_key() {
|
||||
local user_id="$1"
|
||||
printf 'billing:balance:%s' "$user_id"
|
||||
}
|
||||
|
||||
build_subscription_billing_cache_key() {
|
||||
local user_id="$1"
|
||||
local group_id="$2"
|
||||
printf 'billing:sub:%s:%s' "$user_id" "$group_id"
|
||||
}
|
||||
|
||||
build_upsert_subscription_sql() {
|
||||
local user_id="$1"
|
||||
local group_id="$2"
|
||||
|
||||
@@ -14,6 +14,7 @@ KEY="${KEY:-/home/long/下载/zjsea.pem}"
|
||||
REMOTE="${REMOTE:-ubuntu@43.155.133.187}"
|
||||
CRM_BASE="${CRM_BASE:-http://127.0.0.1:18088}"
|
||||
HOST_BASE="${HOST_BASE:-http://127.0.0.1:18087}"
|
||||
CRM_HOST_BASE="${CRM_HOST_BASE:-$HOST_BASE}"
|
||||
PACK_PATH="${PACK_PATH:-/home/ubuntu/sub2api-cn-relay-manager/packs/openai-cn-pack}"
|
||||
ROOT="${ROOT:-$ROOT_DIR/artifacts/real-host-acceptance}"
|
||||
ART="${ART:-$ROOT/$(date +%Y%m%d_%H%M%S)_remote43_${provider_id}_key_import}"
|
||||
@@ -46,6 +47,13 @@ remote_pg_exec() {
|
||||
ssh_cmd "printf '%s' '$encoded' | base64 -d | sudo -n docker exec -i sub2api-relaymgr-pg psql -U sub2api -d sub2api"
|
||||
}
|
||||
|
||||
remote_pg_query() {
|
||||
local sql="$1"
|
||||
local encoded
|
||||
encoded="$(printf '%s' "$sql" | base64 -w0)"
|
||||
ssh_cmd "printf '%s' '$encoded' | base64 -d | sudo -n docker exec -i sub2api-relaymgr-pg psql -U sub2api -d sub2api -At -F $'\t'"
|
||||
}
|
||||
|
||||
remote_fetch_group_state() {
|
||||
local group_id="$1"
|
||||
local user_id="$2"
|
||||
@@ -98,24 +106,109 @@ admin_key="$(ssh_cmd "sudo -n docker exec sub2api-relaymgr-pg psql -U sub2api -d
|
||||
admin_key="${admin_key##*$'\n'}"
|
||||
admin_uid="$(ssh_cmd "sudo -n docker exec sub2api-relaymgr-pg psql -U sub2api -d sub2api -Atc \"select id from users where role='admin' order by id asc limit 1;\"")"
|
||||
admin_uid="${admin_uid##*$'\n'}"
|
||||
sub_uid="$(ssh_cmd "sudo -n docker exec sub2api-relaymgr-pg psql -U sub2api -d sub2api -Atc \"select id from users where email like 'relay-sub-%@sub2api.local' order by id desc limit 1;\"")"
|
||||
sub_uid="$(remote_pg_query "select id from users where email like 'relay-sub-%@sub2api.local' and not exists (select 1 from user_subscriptions s where s.user_id=users.id and s.deleted_at is null) order by id desc limit 1;")"
|
||||
sub_uid="${sub_uid##*$'\n'}"
|
||||
sub_key="$(ssh_cmd "sudo -n docker exec sub2api-relaymgr-pg psql -U sub2api -d sub2api -Atc \"select k.key from users u join api_keys k on k.user_id=u.id where u.email like 'relay-sub-%@sub2api.local' order by u.id desc limit 1;\"")"
|
||||
sub_key="$(remote_pg_query "select k.key from users u join api_keys k on k.user_id=u.id where u.email like 'relay-sub-%@sub2api.local' and not exists (select 1 from user_subscriptions s where s.user_id=u.id and s.deleted_at is null) order by u.id desc limit 1;")"
|
||||
sub_key="${sub_key##*$'\n'}"
|
||||
if [[ -z "$sub_uid" || -z "$sub_key" ]]; then
|
||||
fresh_seed="$(python3 - <<'PY'
|
||||
import secrets, time
|
||||
print(f"{int(time.time())}-{secrets.token_hex(4)}")
|
||||
PY
|
||||
)"
|
||||
fresh_email="relay-sub-${fresh_seed}@sub2api.local"
|
||||
fresh_username="relay-sub-${fresh_seed}"
|
||||
fresh_key="sk-${fresh_seed}"
|
||||
create_user_sql="$(python3 - "$fresh_email" "$fresh_username" "$fresh_key" <<'PY'
|
||||
import sys
|
||||
email, username, api_key = sys.argv[1:4]
|
||||
|
||||
python3 - "$ART/01-runtime-context.json" "$CRM_BASE" "$HOST_BASE" "$provider_id" "$sub_uid" "$sub_key" <<'PY'
|
||||
def sql_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
print(f'''
|
||||
WITH seed AS (
|
||||
SELECT password_hash
|
||||
FROM users
|
||||
WHERE role = 'admin'
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
),
|
||||
ins_user AS (
|
||||
INSERT INTO users (
|
||||
email, password_hash, role, balance, concurrency, status, username, notes, wechat,
|
||||
totp_secret_encrypted, totp_enabled, balance_notify_enabled, balance_notify_threshold,
|
||||
balance_notify_extra_emails, balance_notify_threshold_type, total_recharged, signup_source,
|
||||
rpm_limit
|
||||
)
|
||||
SELECT
|
||||
{sql_quote(email)},
|
||||
password_hash,
|
||||
'user',
|
||||
10,
|
||||
5,
|
||||
'active',
|
||||
{sql_quote(username)},
|
||||
'hermes remote subscription validation',
|
||||
'',
|
||||
'',
|
||||
false,
|
||||
true,
|
||||
NULL,
|
||||
'[]',
|
||||
'fixed',
|
||||
0,
|
||||
'email',
|
||||
0
|
||||
FROM seed
|
||||
RETURNING id
|
||||
),
|
||||
ins_key AS (
|
||||
INSERT INTO api_keys (
|
||||
user_id, key, name, group_id, status, quota, quota_used,
|
||||
rate_limit_5h, rate_limit_1d, rate_limit_7d,
|
||||
usage_5h, usage_1d, usage_7d
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
{sql_quote(api_key)},
|
||||
{sql_quote(username + '-key')},
|
||||
NULL,
|
||||
'active',
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
FROM ins_user
|
||||
RETURNING user_id, key
|
||||
)
|
||||
SELECT user_id, key FROM ins_key;
|
||||
'''.strip())
|
||||
PY
|
||||
)"
|
||||
read -r sub_uid sub_key <<EOF
|
||||
$(remote_pg_query "$create_user_sql")
|
||||
EOF
|
||||
fi
|
||||
|
||||
python3 - "$ART/01-runtime-context.json" "$CRM_BASE" "$HOST_BASE" "$CRM_HOST_BASE" "$provider_id" "$sub_uid" "$sub_key" <<'PY'
|
||||
import json, sys, pathlib
|
||||
path, crm, host, provider_id, sub_uid, sub_key = sys.argv[1:7]
|
||||
path, crm, host, crm_host, provider_id, sub_uid, sub_key = sys.argv[1:8]
|
||||
pathlib.Path(path).write_text(json.dumps({
|
||||
'crm_base': crm,
|
||||
'host_base': host,
|
||||
'crm_host_base': crm_host,
|
||||
'provider_id': provider_id,
|
||||
'subscription_user_id': sub_uid,
|
||||
'subscription_user_key_prefix': sub_key[:12],
|
||||
}, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
PY
|
||||
|
||||
payload="$(python3 - "$HOST_BASE" "$admin_key" "$PACK_PATH" "$provider_id" "$upstream_key" "$sub_key" "$sub_uid" "$SUBSCRIPTION_DAYS" <<'PY'
|
||||
payload="$(python3 - "$CRM_HOST_BASE" "$admin_key" "$PACK_PATH" "$provider_id" "$upstream_key" "$sub_key" "$sub_uid" "$SUBSCRIPTION_DAYS" <<'PY'
|
||||
import json, sys
|
||||
host_base, admin_key, pack_path, provider_id, upstream_key, sub_key, sub_uid, subscription_days = sys.argv[1:9]
|
||||
print(json.dumps({
|
||||
@@ -144,18 +237,28 @@ print(obj['batch_id'])
|
||||
PY
|
||||
)"
|
||||
|
||||
ssh_cmd "curl -sS -H 'Authorization: Bearer *** $CRM_BASE/api/import-batches/$batch_id" > "$ART/04-batch-detail-initial.json"
|
||||
subscription_group_id="$(python3 - "$ART/04-batch-detail-initial.json" <<'PY'
|
||||
ssh_cmd "curl -sS -H 'Authorization: Bearer $crm_token' $CRM_BASE/api/import-batches/$batch_id" > "$ART/04-batch-detail-initial.json"
|
||||
subscription_group_id="$(python3 - "$ART/03-import.body.json" "$ART/04-batch-detail-initial.json" <<'PY'
|
||||
import json, pathlib, sys
|
||||
obj = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
for item in obj.get('managed_resources', []):
|
||||
import_obj = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
batch_obj = json.loads(pathlib.Path(sys.argv[2]).read_text())
|
||||
|
||||
group = import_obj.get('group') or {}
|
||||
if group.get('id'):
|
||||
print(group['id'])
|
||||
raise SystemExit(0)
|
||||
|
||||
for item in batch_obj.get('managed_resources', []):
|
||||
if item.get('ResourceType') == 'group':
|
||||
print(item.get('HostResourceID', ''))
|
||||
break
|
||||
else:
|
||||
raise SystemExit('missing managed group in batch detail')
|
||||
raise SystemExit(0)
|
||||
|
||||
raise SystemExit('missing managed group in import response and batch detail')
|
||||
PY
|
||||
)"
|
||||
auth_cache_key="$(build_api_key_auth_cache_key "$sub_key")"
|
||||
balance_cache_key="$(build_user_balance_cache_key "$sub_uid")"
|
||||
subscription_cache_key="$(build_subscription_billing_cache_key "$sub_uid" "$subscription_group_id")"
|
||||
|
||||
prep_sql="$(build_subscription_access_prep_sql "$sub_uid" "$sub_key" "$subscription_group_id" "$MIN_BALANCE" "$SUBSCRIPTION_DAYS" "$admin_uid" "$SUBSCRIPTION_NOTES")"
|
||||
python3 - "$ART/05-subscription-access-prep.sql" "$prep_sql" <<'PY'
|
||||
@@ -163,15 +266,21 @@ import pathlib, sys
|
||||
pathlib.Path(sys.argv[1]).write_text(sys.argv[2], encoding='utf-8')
|
||||
PY
|
||||
remote_pg_exec "$prep_sql" > "$ART/06-subscription-access-prep.psql.txt"
|
||||
ssh_cmd "sudo -n docker exec sub2api-relaymgr-redis redis-cli FLUSHDB" > "$ART/07-redis-flush.txt"
|
||||
{
|
||||
printf 'auth_cache_key=%s\n' "$auth_cache_key"
|
||||
printf 'balance_cache_key=%s\n' "$balance_cache_key"
|
||||
printf 'subscription_cache_key=%s\n' "$subscription_cache_key"
|
||||
ssh_cmd "sudo -n docker exec sub2api-relaymgr-redis redis-cli DEL $auth_cache_key $balance_cache_key $subscription_cache_key"
|
||||
} > "$ART/07-redis-targeted-invalidation.txt"
|
||||
remote_fetch_group_state "$subscription_group_id" "$sub_uid" "$sub_key" "$ART/08-subscription-group-state.json"
|
||||
|
||||
python3 - "$ART/01-runtime-context.json" "$CRM_BASE" "$HOST_BASE" "$provider_id" "$sub_uid" "$sub_key" "$subscription_group_id" "$admin_uid" <<'PY'
|
||||
python3 - "$ART/01-runtime-context.json" "$CRM_BASE" "$HOST_BASE" "$CRM_HOST_BASE" "$provider_id" "$sub_uid" "$sub_key" "$subscription_group_id" "$admin_uid" <<'PY'
|
||||
import json, sys, pathlib
|
||||
path, crm, host, provider_id, sub_uid, sub_key, group_id, admin_uid = sys.argv[1:9]
|
||||
path, crm, host, crm_host, provider_id, sub_uid, sub_key, group_id, admin_uid = sys.argv[1:10]
|
||||
pathlib.Path(path).write_text(json.dumps({
|
||||
'crm_base': crm,
|
||||
'host_base': host,
|
||||
'crm_host_base': crm_host,
|
||||
'provider_id': provider_id,
|
||||
'subscription_user_id': sub_uid,
|
||||
'subscription_user_key_prefix': sub_key[:12],
|
||||
@@ -190,30 +299,42 @@ print(json.dumps({
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
)"
|
||||
ssh_cmd "curl -sS -D /tmp/chat_headers.txt -o /tmp/chat_body.json -H 'Authorization: Bearer *** -H 'Content-Type: application/json' $HOST_BASE/v1/chat/completions -d $(printf %q "$probe_payload")"
|
||||
ssh_cmd "cat /tmp/chat_headers.txt" > "$ART/09-chat.headers.txt"
|
||||
ssh_cmd "cat /tmp/chat_body.json" > "$ART/10-chat.body.json"
|
||||
ssh_cmd "curl -sS -D /tmp/models_headers.txt -o /tmp/models_body.json -H 'Authorization: Bearer *** $HOST_BASE/v1/models"
|
||||
ssh_cmd "cat /tmp/models_headers.txt" > "$ART/09-models.headers.txt"
|
||||
ssh_cmd "cat /tmp/models_body.json" > "$ART/10-models.body.json"
|
||||
|
||||
ssh_cmd "curl -sS -H 'Authorization: Bearer *** $CRM_BASE/api/providers/$provider_id/status" > "$ART/11-provider-status.json"
|
||||
ssh_cmd "curl -sS -H 'Authorization: Bearer *** $CRM_BASE/api/providers/$provider_id/access/status" > "$ART/12-access-status.json"
|
||||
ssh_cmd "curl -sS -D /tmp/chat_headers.txt -o /tmp/chat_body.json -H 'Authorization: Bearer *** -H 'Content-Type: application/json' $HOST_BASE/v1/chat/completions -d $(printf %q "$probe_payload")"
|
||||
ssh_cmd "cat /tmp/chat_headers.txt" > "$ART/11-chat.headers.txt"
|
||||
ssh_cmd "cat /tmp/chat_body.json" > "$ART/12-chat.body.json"
|
||||
|
||||
ssh_cmd "curl -sS -H 'Authorization: Bearer *** $CRM_BASE/api/providers/$provider_id/status" > "$ART/13-provider-status.json"
|
||||
ssh_cmd "curl -sS -H 'Authorization: Bearer *** $CRM_BASE/api/providers/$provider_id/access/status" > "$ART/14-access-status.json"
|
||||
preview_payload="$(python3 - "$provider_id" <<'PY'
|
||||
import json, sys
|
||||
print(json.dumps({'provider_id': sys.argv[1], 'mode': 'subscription'}, ensure_ascii=False))
|
||||
PY
|
||||
)"
|
||||
ssh_cmd "curl -sS -X POST -H 'Authorization: Bearer *** -H 'Content-Type: application/json' $CRM_BASE/api/providers/$provider_id/access/preview -d $(printf %q "$preview_payload")" > "$ART/13-access-preview.json"
|
||||
ssh_cmd "curl -sS -H 'Authorization: Bearer *** $CRM_BASE/api/import-batches/$batch_id" > "$ART/14-batch-detail-final.json"
|
||||
ssh_cmd "curl -sS -X POST -H 'Authorization: Bearer *** -H 'Content-Type: application/json' $CRM_BASE/api/providers/$provider_id/access/preview -d $(printf %q "$preview_payload")" > "$ART/15-access-preview.json"
|
||||
ssh_cmd "curl -sS -H 'Authorization: Bearer *** $CRM_BASE/api/import-batches/$batch_id" > "$ART/16-batch-detail-final.json"
|
||||
|
||||
python3 - "$ART" "$provider_id" "$batch_id" <<'PY'
|
||||
python3 - "$ART" "$provider_id" "$batch_id" "$subscription_group_id" "$model_name" <<'PY'
|
||||
import json, pathlib, sys
|
||||
art=pathlib.Path(sys.argv[1])
|
||||
provider_id=sys.argv[2]
|
||||
batch_id=int(sys.argv[3])
|
||||
subscription_group_id=sys.argv[4]
|
||||
expected_model=sys.argv[5]
|
||||
import_obj=json.loads((art/'03-import.body.json').read_text())
|
||||
access_status=json.loads((art/'12-access-status.json').read_text())
|
||||
preview=json.loads((art/'13-access-preview.json').read_text())
|
||||
chat_headers=(art/'09-chat.headers.txt').read_text()
|
||||
group_state=json.loads((art/'08-subscription-group-state.json').read_text())
|
||||
models_obj=json.loads((art/'10-models.body.json').read_text())
|
||||
access_status=json.loads((art/'14-access-status.json').read_text())
|
||||
preview=json.loads((art/'15-access-preview.json').read_text())
|
||||
models_headers=(art/'09-models.headers.txt').read_text()
|
||||
chat_headers=(art/'11-chat.headers.txt').read_text()
|
||||
models=[]
|
||||
for item in models_obj.get('data') or []:
|
||||
model_id = item.get('id')
|
||||
if isinstance(model_id, str) and model_id:
|
||||
models.append(model_id)
|
||||
summary={
|
||||
'artifact_dir': str(art),
|
||||
'provider_id': provider_id,
|
||||
@@ -221,13 +342,15 @@ summary={
|
||||
'batch_status': import_obj.get('batch_status'),
|
||||
'access_status_from_import': import_obj.get('access_status'),
|
||||
'provider_status_from_import': import_obj.get('provider_status'),
|
||||
'direct_models_http200': '200 OK' in models_headers,
|
||||
'direct_models_has_expected_model': expected_model in models,
|
||||
'direct_models': models,
|
||||
'direct_chat_http200': '200 OK' in chat_headers,
|
||||
'latest_access_status': access_status.get('latest_access_status') or access_status.get('batch_access_status'),
|
||||
'preview_available': preview.get('available'),
|
||||
'accepted_keys_count': import_obj.get('accepted_keys_count'),
|
||||
'subscription_group_id': group_state.get('group_id'),
|
||||
'key_group_id': (group_state.get('key') or {}).get('group_id'),
|
||||
'subscription_status': (group_state.get('subscription') or {}).get('status'),
|
||||
'subscription_group_id': subscription_group_id,
|
||||
'import_group_id': (import_obj.get('group') or {}).get('id'),
|
||||
}
|
||||
print(json.dumps(summary, ensure_ascii=False))
|
||||
PY
|
||||
|
||||
@@ -35,6 +35,14 @@ run_test_build_subscription_access_prep_sql() {
|
||||
local quoted_sql
|
||||
quoted_sql="$(build_bind_api_key_group_sql "sk-o'reilly" 7)"
|
||||
assert_contains "$quoted_sql" "WHERE key = 'sk-o''reilly'"
|
||||
|
||||
local auth_cache_key balance_cache_key subscription_cache_key
|
||||
auth_cache_key="$(build_api_key_auth_cache_key 'user-key')"
|
||||
balance_cache_key="$(build_user_balance_cache_key 42)"
|
||||
subscription_cache_key="$(build_subscription_billing_cache_key 42 7)"
|
||||
assert_contains "$auth_cache_key" "apikey:auth:"
|
||||
assert_contains "$balance_cache_key" "billing:balance:42"
|
||||
assert_contains "$subscription_cache_key" "billing:sub:42:7"
|
||||
}
|
||||
|
||||
run_test_real_host_acceptance_after_import_hook() {
|
||||
@@ -157,8 +165,8 @@ case "$cmd" in
|
||||
*"select k.key from users u join api_keys k on k.user_id=u.id"*)
|
||||
printf '%s\n' 'user-key'
|
||||
;;
|
||||
*"curl -sS -D /tmp/import_headers.txt"*"/api/providers/deepseek/import"*)
|
||||
printf '%s\n' '{"batch_id":123,"batch_status":"partially_succeeded","access_status":"broken"}' > /tmp/import_body.json
|
||||
*"/api/providers/deepseek/import"*)
|
||||
printf '%s\n' '{"batch_id":123,"batch_status":"partially_succeeded","access_status":"broken","group":{"id":"7","name":"DeepSeek 默认分组"}}' > /tmp/import_body.json
|
||||
printf '%s\n' 'HTTP/1.1 200 OK' > /tmp/import_headers.txt
|
||||
;;
|
||||
"cat /tmp/import_headers.txt")
|
||||
@@ -167,8 +175,18 @@ case "$cmd" in
|
||||
"cat /tmp/import_body.json")
|
||||
cat /tmp/import_body.json
|
||||
;;
|
||||
*"curl -sS -H 'Authorization: Bearer *** http://127.0.0.1:18088/api/import-batches/123"*)
|
||||
printf '%s\n' '{"managed_resources":[{"ResourceType":"group","HostResourceID":"7","ResourceName":"DeepSeek 默认分组"}]}'
|
||||
*"/api/import-batches/123"*)
|
||||
printf '%s\n' '{"managed_resources":[{"ResourceType":"account","HostResourceID":"8","ResourceName":"deepseek-01"}]}'
|
||||
;;
|
||||
*"curl -sS -D /tmp/models_headers.txt"*)
|
||||
printf '%s\n' 'HTTP/1.1 200 OK' > /tmp/models_headers.txt
|
||||
printf '%s\n' '{"data":[{"id":"gpt-4"},{"id":"gpt-4.1"}]}' > /tmp/models_body.json
|
||||
;;
|
||||
"cat /tmp/models_headers.txt")
|
||||
cat /tmp/models_headers.txt
|
||||
;;
|
||||
"cat /tmp/models_body.json")
|
||||
cat /tmp/models_body.json
|
||||
;;
|
||||
*"curl -sS -D /tmp/chat_headers.txt"*)
|
||||
printf '%s\n' 'HTTP/1.1 200 OK' > /tmp/chat_headers.txt
|
||||
@@ -180,16 +198,16 @@ case "$cmd" in
|
||||
"cat /tmp/chat_body.json")
|
||||
cat /tmp/chat_body.json
|
||||
;;
|
||||
*"curl -sS -H 'Authorization: Bearer *** http://127.0.0.1:18088/api/providers/deepseek/status"*)
|
||||
*"/api/providers/deepseek/status"*)
|
||||
printf '%s\n' '{"status":"ready"}'
|
||||
;;
|
||||
*"curl -sS -H 'Authorization: Bearer *** http://127.0.0.1:18088/api/providers/deepseek/access/status"*)
|
||||
*"/api/providers/deepseek/access/status"*)
|
||||
printf '%s\n' '{"latest_access_status":"subscription_ready"}'
|
||||
;;
|
||||
*"curl -sS -X POST -H 'Authorization: Bearer *** -H 'Content-Type: application/json' http://127.0.0.1:18088/api/providers/deepseek/access/preview"*)
|
||||
*"/api/providers/deepseek/access/preview"*)
|
||||
printf '%s\n' '{"available":true}'
|
||||
;;
|
||||
*"curl -sS -H 'Authorization: Bearer *** http://127.0.0.1:18088/api/providers/deepseek/reconcile"*)
|
||||
*"/api/providers/deepseek/reconcile"*)
|
||||
printf '%s\n' '{"status":"in_sync"}'
|
||||
;;
|
||||
*"sudo -n docker exec -i sub2api-relaymgr-pg psql -U sub2api -d sub2api -At -F ''"*)
|
||||
@@ -197,17 +215,26 @@ case "$cmd" in
|
||||
;;
|
||||
*"sudo -n docker exec -i sub2api-relaymgr-pg psql -U sub2api -d sub2api"*)
|
||||
CMD="$cmd" LOG_DIR="$log_dir" python3 - <<'PY'
|
||||
import base64, os, re, pathlib
|
||||
import base64, os, re, pathlib, sys
|
||||
cmd = os.environ['CMD']
|
||||
log_dir = pathlib.Path(os.environ['LOG_DIR'])
|
||||
match = re.search(r"printf '%s' '([^']+)' \| base64 -d", cmd)
|
||||
if not match:
|
||||
raise SystemExit(f'failed to extract base64 payload from: {cmd}')
|
||||
log_dir.joinpath('prep.sql').write_bytes(base64.b64decode(match.group(1)))
|
||||
sql = base64.b64decode(match.group(1)).decode()
|
||||
log_dir.joinpath('prep.sql').write_text(sql, encoding='utf-8')
|
||||
if "select id from users where email like 'relay-sub-%@sub2api.local' and not exists" in sql:
|
||||
print('')
|
||||
elif "select k.key from users u join api_keys k on k.user_id=u.id" in sql and "not exists" in sql:
|
||||
print('')
|
||||
elif "INSERT INTO users" in sql and "INSERT INTO api_keys" in sql:
|
||||
print('84\tuser-key-fresh')
|
||||
else:
|
||||
print('')
|
||||
PY
|
||||
;;
|
||||
*"sudo -n docker exec sub2api-relaymgr-redis redis-cli FLUSHDB"*)
|
||||
printf '%s\n' 'OK'
|
||||
*"sudo -n docker exec sub2api-relaymgr-redis redis-cli DEL apikey:auth:"*" billing:balance:"*" billing:sub:"*":7"*)
|
||||
printf '%s\n' '3'
|
||||
;;
|
||||
*)
|
||||
echo "unexpected ssh command: $cmd" >&2
|
||||
@@ -223,6 +250,7 @@ EOF
|
||||
REMOTE="fake@host" \
|
||||
CRM_BASE="http://127.0.0.1:18088" \
|
||||
HOST_BASE="http://127.0.0.1:18087" \
|
||||
CRM_HOST_BASE="http://127.0.0.1:18093" \
|
||||
ROOT="$artifact_dir/root" \
|
||||
ART="$artifact_dir/run" \
|
||||
PACK_PATH="/tmp/openai-pack" \
|
||||
@@ -239,6 +267,18 @@ EOF
|
||||
assert_contains "$prep_sql" "UPDATE api_keys"
|
||||
assert_contains "$prep_sql" "INSERT INTO user_subscriptions"
|
||||
assert_contains "$prep_sql" "group_id = 7"
|
||||
local runtime_context invalidation_log
|
||||
runtime_context="$(cat "$artifact_dir/run/01-runtime-context.json")"
|
||||
assert_contains "$runtime_context" '"crm_host_base": "http://127.0.0.1:18093"'
|
||||
invalidation_log="$(cat "$artifact_dir/run/07-redis-targeted-invalidation.txt")"
|
||||
assert_contains "$invalidation_log" "auth_cache_key=apikey:auth:"
|
||||
assert_contains "$invalidation_log" "balance_cache_key=billing:balance:84"
|
||||
assert_contains "$invalidation_log" "subscription_cache_key=billing:sub:84:7"
|
||||
local models_body chat_body
|
||||
models_body="$(cat "$artifact_dir/run/10-models.body.json")"
|
||||
chat_body="$(cat "$artifact_dir/run/12-chat.body.json")"
|
||||
assert_contains "$models_body" '"id":"gpt-4"'
|
||||
assert_contains "$chat_body" '"content":"pong"'
|
||||
[[ -s "$ssh_log" ]] || fail "ssh log was empty"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user