From 768974a6d3fbd8a020e6732f4f668cb6f7cbc8a5 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:12:10 +0800 Subject: [PATCH 1/2] Revalidate stale Seedance asset bindings before reuse Prevent channel 156 from forwarding upstream asset identifiers that no longer exist, while retaining retryable provider failures for recovery. Constraint: Existing active bindings and per-channel credential scopes must remain reusable when the upstream asset is still active. Rejected: Blindly trust cached active bindings | stale upstream IDs caused Seedance requests to fail and could not recover automatically. Confidence: high Scope-risk: narrow Directive: Keep provider-specific revalidation limited to Seedance proxy bindings and preserve CAS guards for concurrent requests. Tested: go test -count=1 ./service -run 'TestSeedanceProxyMaterializeSetRematerializesStaleActiveBinding|TestSeedanceProxyAssetBindingReusesActiveBindingAcrossSeedanceModelsOnSameKey|TestAssetBinding'; go test -count=1 ./model -run 'AssetBinding|Asset'; go test -count=1 ./relay/channel/task/modelapiseedance; go vet ./service ./model ./relay/channel/task/modelapiseedance; git diff --cached --check Not-tested: Full ./service and ./model suites time out on pre-existing SQLite parallel-test failures. --- model/asset.go | 40 +++++++++++ service/asset_binding.go | 127 ++++++++++++++++++++++++++++++++-- service/asset_binding_test.go | 78 +++++++++++++++++++++ 3 files changed, 241 insertions(+), 4 deletions(-) diff --git a/model/asset.go b/model/asset.go index ba0b4109e..7f3551981 100644 --- a/model/asset.go +++ b/model/asset.go @@ -200,6 +200,16 @@ type AssetBindingProcessingRefresh struct { Now int64 } +type AssetBindingActiveRefresh struct { + AssetID int64 + ChannelID int + BindingScope string + UpstreamAssetID string + Status string + ErrorCode string + Now int64 +} + type ExpiredAssetUploadCleanupCandidate struct { Asset Asset Upload AssetUpload @@ -566,6 +576,36 @@ func RefreshProcessingAssetBindingCAS(refresh AssetBindingProcessingRefresh) (bo return result.RowsAffected == 1, nil } +func RefreshActiveAssetBindingCAS(refresh AssetBindingActiveRefresh) (bool, error) { + if refresh.UpstreamAssetID == "" { + return false, nil + } + status := refresh.Status + if status == "" { + status = AssetStatusFailed + } + updates := map[string]any{ + "status": status, + "lease_owner": "", + "lease_expires_at": int64(0), + "updated_at": refresh.Now, + } + if refresh.ErrorCode != "" { + updates["error_code"] = refresh.ErrorCode + } else { + updates["error_code"] = "" + } + result := DB.Model(&AssetBinding{}). + Where("asset_id = ? AND channel_id = ? AND binding_scope = ?", refresh.AssetID, refresh.ChannelID, refresh.BindingScope). + Where("status = ?", AssetStatusActive). + Where("upstream_asset_id = ?", refresh.UpstreamAssetID). + Updates(updates) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected == 1, nil +} + func CreateAssetBindingIfAbsent(assetID int64, channelID int, now int64) (*AssetBinding, bool, error) { return CreateAssetBindingForScopeIfAbsent(assetID, channelID, "", now) } diff --git a/service/asset_binding.go b/service/asset_binding.go index 54aff0423..40c8e364f 100644 --- a/service/asset_binding.go +++ b/service/asset_binding.go @@ -352,7 +352,26 @@ func MaterializeAssetBindingsForChannel(ctx context.Context, userID int, set Ass for _, reference := range set.references { asset := set.assets[reference.PublicID] if binding, ok := activeAssetReferenceBindingForScope(asset.Bindings, channel.Id, bindingScope); ok { - rewriteMap["asset://"+reference.PublicID] = assetBindingRewriteURI(binding.UpstreamAssetID) + if !seedanceProxyActiveBindingRequiresRevalidation(channel) { + rewriteMap["asset://"+reference.PublicID] = assetBindingRewriteURI(binding.UpstreamAssetID) + continue + } + result, err := MaterializeAssetBinding(ctx, AssetBindingRequest{ + UserID: userID, + PublicID: reference.PublicID, + Channel: channel, + LeaseOwner: assetBindingLeaseOwner(), + PollLimit: assetBindingDefaultPollLimit, + PollDelay: assetBindingDefaultPollDelay, + LeaseTTL: assetBindingDefaultLeaseTTL, + ExpectedType: reference.ExpectedAssetType, + Model: materializeOptions.Model, + APIKey: materializeOptions.APIKey, + }) + if err != nil { + return nil, err + } + rewriteMap[result.PublicURI] = result.RewriteURI continue } if legacyRealPersonAssetCanUseChannel(asset, channel) { @@ -443,7 +462,13 @@ func MaterializeAssetBinding(ctx context.Context, request AssetBindingRequest) ( return AssetBindingResult{}, sanitizeAssetBindingError(existingErr) } if activeAssetBinding(existing) { - return assetBindingResult(asset.PublicId, *existing), nil + result, reusable, err := revalidateSeedanceProxyActiveAssetBinding(ctx, asset, request.Channel, bindingScope, request.Model, request.APIKey, existing) + if err != nil { + return AssetBindingResult{}, err + } + if reusable { + return result, nil + } } if processingAssetBinding(existing) { result, handled, err := handleProcessingAssetBinding(ctx, asset, request.Channel, bindingScope, request.Model, request.APIKey, existing.UpstreamAssetId, pollLimit, pollDelay) @@ -473,7 +498,13 @@ func MaterializeAssetBinding(ctx context.Context, request AssetBindingRequest) ( if binding.BindingScope != bindingScope { return AssetBindingResult{}, ErrAssetBindingUnavailable } - return assetBindingResult(asset.PublicId, *binding), nil + result, reusable, err := revalidateSeedanceProxyActiveAssetBinding(ctx, asset, request.Channel, bindingScope, request.Model, request.APIKey, binding) + if err != nil { + return AssetBindingResult{}, err + } + if reusable { + return result, nil + } } if processingAssetBinding(binding) { result, handled, err := handleProcessingAssetBinding(ctx, asset, request.Channel, bindingScope, request.Model, request.APIKey, binding.UpstreamAssetId, pollLimit, pollDelay) @@ -503,7 +534,13 @@ func MaterializeAssetBinding(ctx context.Context, request AssetBindingRequest) ( if loaded.BindingScope != bindingScope { return AssetBindingResult{}, ErrAssetBindingUnavailable } - return assetBindingResult(asset.PublicId, *loaded), nil + result, reusable, err := revalidateSeedanceProxyActiveAssetBinding(ctx, asset, request.Channel, bindingScope, request.Model, request.APIKey, loaded) + if err != nil { + return AssetBindingResult{}, err + } + if reusable { + return result, nil + } } if processingAssetBinding(loaded) { result, handled, err := handleProcessingAssetBinding(ctx, asset, request.Channel, bindingScope, request.Model, request.APIKey, loaded.UpstreamAssetId, pollLimit, pollDelay) @@ -962,6 +999,88 @@ func activeAssetReferenceBindingForScope(bindings []assetReferenceBinding, chann return assetReferenceBinding{}, false } +func seedanceProxyActiveBindingRequiresRevalidation(channel *model.Channel) bool { + config, explicit, err := assetMaterializationConfigForChannel(channel) + return err == nil && explicit && config.Provider == assetMaterializationProviderSeedanceProxy +} + +func revalidateSeedanceProxyActiveAssetBinding(ctx context.Context, asset *model.Asset, channel *model.Channel, bindingScope string, modelName string, apiKey string, binding *model.AssetBinding) (AssetBindingResult, bool, error) { + if !seedanceProxyActiveBindingRequiresRevalidation(channel) || !activeAssetBinding(binding) { + if binding == nil { + return AssetBindingResult{}, false, nil + } + return assetBindingResult(asset.PublicId, *binding), true, nil + } + materializer, err := assetMaterializerForChannel(channel) + if err != nil || materializer == nil { + return AssetBindingResult{}, false, ErrAssetBindingUnavailable + } + result, err := materializer.GetAsset(ctx, AssetMaterializeInput{ + UserID: asset.UserId, + Asset: *asset, + Channel: channel, + Model: modelName, + APIKey: apiKey, + IdempotencyKey: assetBindingIdempotencyKey(asset.SHA256, asset.Id, channel.Id, bindingScope), + }, binding.UpstreamAssetId) + if err != nil { + if IsRetryableAssetMaterializeError(err) { + return AssetBindingResult{}, false, ErrAssetBindingInitializing + } + if _, markErr := model.RefreshActiveAssetBindingCAS(model.AssetBindingActiveRefresh{ + AssetID: asset.Id, + ChannelID: channel.Id, + BindingScope: bindingScope, + UpstreamAssetID: binding.UpstreamAssetId, + Status: model.AssetStatusFailed, + ErrorCode: AssetMaterializeErrorClass(err), + Now: assetBindingNow().Unix(), + }); markErr != nil { + return AssetBindingResult{}, false, sanitizeAssetBindingError(markErr) + } + return AssetBindingResult{}, false, nil + } + status := strings.TrimSpace(result.Status) + observedAssetID := strings.TrimSpace(result.UpstreamAssetID) + if status == model.AssetStatusActive && observedAssetID == strings.TrimSpace(binding.UpstreamAssetId) { + return assetBindingResult(asset.PublicId, *binding), true, nil + } + if status == model.AssetStatusProcessing && (observedAssetID == "" || observedAssetID == strings.TrimSpace(binding.UpstreamAssetId)) { + updated, updateErr := model.RefreshActiveAssetBindingCAS(model.AssetBindingActiveRefresh{ + AssetID: asset.Id, + ChannelID: channel.Id, + BindingScope: bindingScope, + UpstreamAssetID: binding.UpstreamAssetId, + Status: model.AssetStatusProcessing, + ErrorCode: AssetMaterializeErrorProcessing, + Now: assetBindingNow().Unix(), + }) + if updateErr != nil { + return AssetBindingResult{}, false, sanitizeAssetBindingError(updateErr) + } + if !updated { + return AssetBindingResult{}, false, ErrAssetBindingInitializing + } + return AssetBindingResult{}, false, ErrAssetBindingInitializing + } + updated, updateErr := model.RefreshActiveAssetBindingCAS(model.AssetBindingActiveRefresh{ + AssetID: asset.Id, + ChannelID: channel.Id, + BindingScope: bindingScope, + UpstreamAssetID: binding.UpstreamAssetId, + Status: model.AssetStatusFailed, + ErrorCode: AssetMaterializeErrorDefinitive, + Now: assetBindingNow().Unix(), + }) + if updateErr != nil { + return AssetBindingResult{}, false, sanitizeAssetBindingError(updateErr) + } + if !updated { + return AssetBindingResult{}, false, ErrAssetBindingInitializing + } + return AssetBindingResult{}, false, nil +} + func refreshProcessingAssetBinding(ctx context.Context, asset *model.Asset, channel *model.Channel, bindingScope string, modelName string, apiKey string, upstreamAssetID string, pollLimit int, pollDelay time.Duration) (AssetBindingResult, error) { if strings.TrimSpace(upstreamAssetID) == "" { return AssetBindingResult{}, ErrAssetBindingUnavailable diff --git a/service/asset_binding_test.go b/service/asset_binding_test.go index 5ab149016..76b469404 100644 --- a/service/asset_binding_test.go +++ b/service/asset_binding_test.go @@ -356,6 +356,84 @@ func TestSeedanceProxyAssetBindingReusesActiveBindingAcrossSeedanceModelsOnSameK require.Equal(t, "upstream-seedance-shared", bindings[0].UpstreamAssetId) } +func TestSeedanceProxyMaterializeSetRematerializesStaleActiveBinding(t *testing.T) { + newAssetServiceTestDB(t) + store := installAssetServiceTestDeps(t) + asset := insertMaterializeAsset(t, "ast_seedance_stale_active_binding") + channel := &model.Channel{ + Id: 156, + Type: constant.ChannelTypeBytePlus, + Key: "seedance-key", + Status: common.ChannelStatusEnabled, + OtherSettings: `{"asset_materialization":{"provider":"seedance_proxy","gateway_base_url":"https://asset-gateway.example.invalid/v1","group_id":"grp_shared_aigc"}}`, + } + options := AssetMaterializeOptions{Model: "seedance-2.0", APIKey: "seedance-key"} + bindingScope, err := assetBindingScopeForChannel(channel, options) + require.NoError(t, err) + require.NoError(t, model.DB.Create(&model.AssetBinding{ + AssetId: asset.Id, + ChannelId: channel.Id, + BindingScope: bindingScope, + Status: model.AssetStatusActive, + UpstreamGroupId: "grp_shared_aigc", + UpstreamAssetId: "upstream-stale", + CreatedAt: 100, + UpdatedAt: 100, + }).Error) + + materializer := &recordingAssetMaterializer{ + createStatus: model.AssetStatusActive, + createGroupID: "grp_shared_aigc", + createAssetID: "upstream-recreated", + getErr: &AssetMaterializeFailure{Class: AssetMaterializeErrorDefinitive, HTTPStatus: http.StatusNotFound}, + } + descriptor := assetMaterializationProviderDescriptors[assetMaterializationProviderSeedanceProxy] + assetMaterializationProviderDescriptors[assetMaterializationProviderSeedanceProxy] = assetMaterializationProviderDescriptor{ + MaterializerFactory: func(assetMaterializationChannelConfig) AssetMaterializer { return materializer }, + BindingScope: descriptor.BindingScope, + ValidateConfig: descriptor.ValidateConfig, + CredentialScoped: descriptor.CredentialScoped, + } + t.Cleanup(func() { + assetMaterializationProviderDescriptors[assetMaterializationProviderSeedanceProxy] = descriptor + }) + + set := AssetReferenceSet{ + references: []assetReference{{PublicID: asset.PublicId, ExpectedAssetType: "Image"}}, + assets: map[string]assetReferenceAsset{ + asset.PublicId: { + ID: asset.Id, + PublicID: asset.PublicId, + AssetType: "Image", + Status: model.AssetStatusActive, + SourceStatus: model.AssetSourceStatusAvailable, + StorageBackend: defaultAssetStorageBackend, + StorageBucket: asset.StorageBucket, + ObjectKey: asset.ObjectKey, + SourceExpiresAt: asset.SourceExpiresAt, + Bindings: []assetReferenceBinding{{ + ChannelID: channel.Id, + BindingScope: bindingScope, + Status: model.AssetStatusActive, + UpstreamAssetID: "upstream-stale", + }}, + }, + }, + } + + rewriteMap, err := MaterializeAssetBindingsForChannel(context.Background(), asset.UserId, set, channel, options) + + require.NoError(t, err) + require.Equal(t, "asset://upstream-recreated", rewriteMap["asset://"+asset.PublicId]) + require.Equal(t, int64(1), atomic.LoadInt64(&materializer.getCalls)) + require.Equal(t, int64(1), atomic.LoadInt64(&materializer.createCalls)) + require.Len(t, store.signed, 1) + var binding model.AssetBinding + require.NoError(t, model.DB.First(&binding, "asset_id = ? AND channel_id = ? AND binding_scope = ?", asset.Id, channel.Id, bindingScope).Error) + require.Equal(t, model.AssetStatusActive, binding.Status) + require.Equal(t, "upstream-recreated", binding.UpstreamAssetId) +} + func TestAssetBindingBoundedPollingReturnsSanitizedInitializingError(t *testing.T) { newAssetServiceTestDB(t) installAssetServiceTestDeps(t) From 6a74cdff799f8596a4987ad30defc3fb387d4074 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:55:05 +0800 Subject: [PATCH 2/2] Route channel 156 real-person verification through Seedance Gateway Keep the existing /v1/real-persons contract while binding channel 156 to the stateful Gateway workflow and its verified liveness-face group. Terminal Gateway verification states now settle local sessions instead of being retried forever. Constraint: Preserve native BytePlus and TokenSpace providers, encrypted verification state, and the existing public API surface. Rejected: Reusing the ordinary materialization group or native BytePlus callback/storage flow | those scopes do not represent Gateway-owned verified person groups. Confidence: high Scope-risk: moderate Directive: Keep seedance_proxy real-person routing pinned to exactly one enabled channel key and never log upstream credentials or signed URLs. Tested: go test ./service -run 'Test.*RealPerson|TestSeedanceProxyRealPerson|TestSeedanceProxyVerificationJob' -count=1; go test ./model -run 'TestBytePlusRealPerson|TestBytePlusVisualValidation|TestBytePlusAsset' -count=1; go test ./relay/channel/task/modelapiseedance -count=1; go test ./service -run 'TestSeedanceProxyMaterializeSetRematerializesStaleActiveBinding|TestSeedanceProxyAssetBindingReusesActiveBindingAcrossSeedanceModelsOnSameKey|TestAssetBinding' -count=1; compile-only go tests for service/model/controller/router; git diff --cached --check Not-tested: Full ./service and ./model suites remain affected by pre-existing parallel SQLite test contention; go vet ./... is blocked by the existing missing web/classic/dist embed directory. --- ...channel-156-seedance-real-person-design.md | 24 ++ service/byteplus_real_person.go | 27 +- service/byteplus_real_person_jobs.go | 10 + service/real_person_provider.go | 17 + service/real_person_provider_test.go | 39 ++ service/seedance_proxy_real_person.go | 333 ++++++++++++++++++ service/seedance_proxy_real_person_test.go | 228 ++++++++++++ 7 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-08-25-channel-156-seedance-real-person-design.md create mode 100644 service/seedance_proxy_real_person.go create mode 100644 service/seedance_proxy_real_person_test.go diff --git a/docs/superpowers/specs/2026-08-25-channel-156-seedance-real-person-design.md b/docs/superpowers/specs/2026-08-25-channel-156-seedance-real-person-design.md new file mode 100644 index 000000000..81d8282a1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-channel-156-seedance-real-person-design.md @@ -0,0 +1,24 @@ +# Channel 156 Seedance Gateway 真人认证接入设计 + +## 目标 + +让平台现有 `/v1/real-persons` 真人认证接口能够在显式配置 `provider=seedance_proxy` 的渠道(包括渠道 156)上工作。平台负责用户隔离、幂等、敏感字段加密、状态机和后台轮询;provider 只负责调用 Seedance Gateway 的真人认证与真人素材接口。 + +## 上游映射 + +- 创建认证:`POST {gateway}/api/seedance/face-verifications`,请求体只传可选 `return_url`;平台不向上游传递客户回调、项目名或 BytePlus AK/SK。 +- 查询认证:`GET {gateway}/api/seedance/face-verifications/{verification_id}`。平台把 `verification_id` 加密保存到现有验证 token 字段,认证完成时将 `group_id` 写入现有真人档案。 +- 创建素材:继续使用 `POST {gateway}/api/seedance/proxy/assets`,但 `GroupId` 使用认证返回的人像组,而不是渠道普通素材配置组。 +- 查询素材:继续使用 `GET {gateway}/api/seedance/proxy/assets/{asset_id}`;列表和删除复用现有 seedance 素材协议(真人接口当前核心状态机只依赖创建、状态、删除,列表沿用 provider 现有能力)。 + +## 状态与错误 + +Gateway `waiting_user`、`callback_received`、`resolving` 映射为可重试的 pending;`verified` 返回 `group_id`;`failed`、`expired` 映射为确定性上游错误并终止本地会话。网络、超时和 5xx 保持可重试,不泄漏 API key、认证 ID、H5 签名或素材 URL。 + +## 凭据与路由 + +provider 从渠道启用 key 中选择唯一 key;多 key 渠道若启用 key 不唯一则拒绝真人认证,避免后续轮询切换到不同上游账号。`seedance_proxy` 只通过显式渠道配置进入真人 provider,不进入无指定渠道的自动候选。Gateway 地址使用现有渠道 `gateway_base_url`,必须是安全 HTTPS 地址;普通素材的配置组仍不参与真人认证组选择。 + +## 测试与边界 + +先增加 provider 选择、HTTP 请求、状态映射和 156 指定渠道回归测试,再实现代码。保留原生 BytePlus 与 TokenSpace 行为不变;不新增对外接口、不做生产发布、不需要数据库迁移。 diff --git a/service/byteplus_real_person.go b/service/byteplus_real_person.go index fa9697856..76a349e74 100644 --- a/service/byteplus_real_person.go +++ b/service/byteplus_real_person.go @@ -271,6 +271,20 @@ func SyncBytePlusRealPersonVerification(ctx context.Context, userID int, profile } result, err := binding.Provider.GetVisualValidateResult(ctx, bytedToken) if err != nil { + if terminalStatus := seedanceProxyVerificationTerminalStatus(err); terminalStatus != "" { + changed, transitionErr := finishSeedanceProxyVerificationTerminal(profile.Id, claimed.Id, terminalStatus, bytePlusAssetNow()) + if transitionErr != nil { + return realPersonError(types.ErrorCodeRealPersonStorageError, http.StatusInternalServerError) + } + if changed { + reloaded, reloadErr := model.GetBytePlusRealPersonProfileByIDForUser(userID, profile.Id) + if reloadErr != nil { + return realPersonError(types.ErrorCodeRealPersonStorageError, http.StatusInternalServerError) + } + *profile = *reloaded + } + return nil + } _, _ = model.RetryBytePlusVisualValidationSession(claimed.Id, claimed.LeaseUpdatedTime, bytePlusAssetNow()+bytePlusAssetDeleteRetryDelaySecs, bytePlusAssetNow()) return nil } @@ -753,7 +767,11 @@ func responseFromBytePlusRealPerson(profile *model.BytePlusRealPersonProfile, ve func finishUnknownOrDefinitiveVerificationFailure(record *model.APIIdempotencyRecord, profile *model.BytePlusRealPersonProfile, session *model.BytePlusVisualValidationSession, err error) (*dto.BytePlusRealPersonResponse, *types.NewAPIError) { if isRealPersonDefinitiveResponse(err) { - _, _ = model.FailBytePlusRealPersonSession(profile.Id, session.Id, "verification_upstream_error", bytePlusAssetNow()) + if terminalStatus := seedanceProxyVerificationTerminalStatus(err); terminalStatus == "expired" { + _, _ = model.ExpireBytePlusRealPersonSession(profile.Id, session.Id, bytePlusAssetNow()) + } else { + _, _ = model.FailBytePlusRealPersonSession(profile.Id, session.Id, "verification_upstream_error", bytePlusAssetNow()) + } payload, marshalErr := marshalAPIIdempotencyResponsePayload(storedRealPersonErrorPayload{ErrorCode: string(types.ErrorCodeVerificationUpstreamError)}) if marshalErr != nil { payload = `{"error_code":"verification_upstream_error"}` @@ -765,6 +783,13 @@ func finishUnknownOrDefinitiveVerificationFailure(record *model.APIIdempotencyRe return nil, realPersonError(types.ErrorCodeIdempotencyOutcomeUnknown, http.StatusBadGateway) } +func finishSeedanceProxyVerificationTerminal(profileID, sessionID int64, status string, now int64) (bool, error) { + if strings.EqualFold(strings.TrimSpace(status), "expired") { + return model.ExpireBytePlusRealPersonSession(profileID, sessionID, now) + } + return model.FailBytePlusRealPersonSession(profileID, sessionID, "verification_failed", now) +} + func apiErrorFromStoredRealPersonPayload(payload string, status int) *types.NewAPIError { var stored storedRealPersonErrorPayload if err := common.Unmarshal([]byte(payload), &stored); err != nil || stored.ErrorCode == "" { diff --git a/service/byteplus_real_person_jobs.go b/service/byteplus_real_person_jobs.go index 31686d2bf..b9a8e27e1 100644 --- a/service/byteplus_real_person_jobs.go +++ b/service/byteplus_real_person_jobs.go @@ -196,6 +196,16 @@ func runBytePlusRealPersonVerificationStatusJobs(ctx context.Context, now, stale upstream, err := binding.Provider.GetVisualValidateResult(callCtx, bytedToken) cancel() if err != nil { + if terminalStatus := seedanceProxyVerificationTerminalStatus(err); terminalStatus != "" { + changed, transitionErr := finishSeedanceProxyVerificationTerminal(profile.Id, session.Id, terminalStatus, now) + if transitionErr != nil && !errors.Is(transitionErr, model.ErrAPIIdempotencyCASLost) { + warnBytePlusRealPersonJobRow("verification_status") + firstErr = firstNonNil(firstErr, transitionErr) + } else if changed { + processed++ + } + continue + } warnBytePlusRealPersonJobRow("verification_status") firstErr = firstNonNil(firstErr, retryBytePlusVerificationStatus(session, now)) continue diff --git a/service/real_person_provider.go b/service/real_person_provider.go index 31ef10641..e22caa51b 100644 --- a/service/real_person_provider.go +++ b/service/real_person_provider.go @@ -90,6 +90,23 @@ func realPersonProviderForChannel(channel *model.Channel) (*realPersonProviderBi return nil, err } if explicit { + if config.Provider == assetMaterializationProviderSeedanceProxy { + if !bytePlusAssetChannelIsUsable(channel) { + return nil, errors.New("real person channel unavailable") + } + keys := enabledAssetMaterializeKeys(channel) + if len(keys) != 1 || strings.TrimSpace(keys[0].key) == "" { + return nil, errors.New("seedance proxy real person provider requires exactly one enabled key") + } + return &realPersonProviderBinding{ + Channel: channel, + Provider: seedanceProxyRealPersonProvider{ + channel: channel, + apiKey: strings.TrimSpace(keys[0].key), + gatewayBaseURL: config.GatewayBaseURL, + }, + }, nil + } if config.Provider != assetMaterializationProviderTokenSpaceMaterial { return nil, errors.New("real person provider unavailable") } diff --git a/service/real_person_provider_test.go b/service/real_person_provider_test.go index 8d97d0e94..68758d6a6 100644 --- a/service/real_person_provider_test.go +++ b/service/real_person_provider_test.go @@ -50,6 +50,45 @@ func TestRealPersonProviderForChannelSelectsExplicitTokenSpaceWithOneEnabledKey( require.Nil(t, binding.StorageCredentials) } +func TestRealPersonProviderForChannelSelectsSeedanceProxyWithOneEnabledKey(t *testing.T) { + channel := channelWithAssetMaterializationSettings(t, constant.ChannelTypeBytePlus, dto.AssetMaterializationSettings{ + Provider: assetMaterializationProviderSeedanceProxy, + GatewayBaseURL: "https://gateway.example.invalid/v1", + GroupID: "group-ordinary-material", + }) + channel.Id = 156 + channel.Key = "seedance-key" + channel.Status = common.ChannelStatusEnabled + + binding, err := realPersonProviderForChannel(channel) + + require.NoError(t, err) + require.Same(t, channel, binding.Channel) + require.IsType(t, seedanceProxyRealPersonProvider{}, binding.Provider) + require.False(t, binding.Provider.RequiresCallback()) + require.Equal(t, int64(300), binding.Provider.VerificationTTLSeconds()) + require.Nil(t, binding.StorageCredentials) +} + +func TestRealPersonProviderForChannelRejectsSeedanceProxyWithMultipleEnabledKeys(t *testing.T) { + channel := channelWithAssetMaterializationSettings(t, constant.ChannelTypeBytePlus, dto.AssetMaterializationSettings{ + Provider: assetMaterializationProviderSeedanceProxy, + GatewayBaseURL: "https://gateway.example.invalid/v1", + GroupID: "group-ordinary-material", + }) + channel.Key = "key-one\nkey-two" + channel.Status = common.ChannelStatusEnabled + channel.ChannelInfo.IsMultiKey = true + channel.ChannelInfo.MultiKeyStatusList = map[int]int{ + 0: common.ChannelStatusEnabled, + 1: common.ChannelStatusEnabled, + } + + _, err := realPersonProviderForChannel(channel) + + require.Error(t, err) +} + func TestTokenSpaceRealPersonChannelIsUsableRequiresDoubaoVideo(t *testing.T) { settings := dto.AssetMaterializationSettings{ Provider: assetMaterializationProviderTokenSpaceMaterial, diff --git a/service/seedance_proxy_real_person.go b/service/seedance_proxy_real_person.go new file mode 100644 index 000000000..eddd70a03 --- /dev/null +++ b/service/seedance_proxy_real_person.go @@ -0,0 +1,333 @@ +package service + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" +) + +const ( + seedanceProxyFaceVerificationPath = "/api/seedance/face-verifications" + seedanceProxyRealPersonResponseMax = techMobiAssetResponseMaxSize +) + +type seedanceProxyRealPersonProvider struct { + channel *model.Channel + apiKey string + gatewayBaseURL string +} + +type seedanceProxyVerificationResponse struct { + VerificationID string `json:"verification_id"` + Status string `json:"status"` + H5URL string `json:"h5_url"` + GroupID string `json:"group_id"` + ExpiresAt int64 `json:"expires_at"` +} + +type seedanceProxyRealPersonAssetListResponse struct { + Items []BytePlusListedAsset `json:"Items"` + TotalCount int `json:"TotalCount"` +} + +type seedanceProxyVerificationTerminalError struct { + status string + failure *AssetMaterializeFailure +} + +func (e *seedanceProxyVerificationTerminalError) Error() string { + if e == nil || e.failure == nil { + return "seedance verification reached terminal status" + } + return e.failure.Error() +} + +func (e *seedanceProxyVerificationTerminalError) Unwrap() error { + if e == nil { + return nil + } + return e.failure +} + +func seedanceProxyVerificationTerminalStatus(err error) string { + var terminal *seedanceProxyVerificationTerminalError + if errors.As(err, &terminal) && terminal != nil { + return strings.TrimSpace(terminal.status) + } + return "" +} + +func (seedanceProxyRealPersonProvider) RequiresCallback() bool { + return false +} + +func (seedanceProxyRealPersonProvider) VerificationTTLSeconds() int64 { + return tokenSpaceRealPersonSessionTTLSeconds +} + +func (p seedanceProxyRealPersonProvider) CreateVisualValidateSession(ctx context.Context, _ string) (BytePlusVisualValidationSession, error) { + body, err := p.doJSON(ctx, http.MethodPost, seedanceProxyFaceVerificationPath, []byte("{}"), nil) + if err != nil { + return BytePlusVisualValidationSession{}, err + } + var response seedanceProxyVerificationResponse + if err := common.Unmarshal(body, &response); err != nil { + return BytePlusVisualValidationSession{}, seedanceProxyRealPersonProtocolFailure(http.StatusOK, err) + } + status := strings.ToLower(strings.TrimSpace(response.Status)) + if status == "failed" || status == "expired" { + return BytePlusVisualValidationSession{}, seedanceProxyVerificationTerminalFailure(status, http.StatusOK) + } + verificationID := strings.TrimSpace(response.VerificationID) + h5URL := strings.TrimSpace(response.H5URL) + if verificationID == "" || h5URL == "" { + return BytePlusVisualValidationSession{}, seedanceProxyRealPersonProtocolFailure(http.StatusOK, errors.New("seedance verification result missing")) + } + return BytePlusVisualValidationSession{BytedToken: verificationID, H5Link: h5URL}, nil +} + +func (p seedanceProxyRealPersonProvider) GetVisualValidateResult(ctx context.Context, verificationID string) (BytePlusVisualValidationResult, error) { + verificationID = strings.TrimSpace(verificationID) + if verificationID == "" { + return BytePlusVisualValidationResult{}, seedanceProxyRealPersonProtocolFailure(0, errors.New("seedance verification id missing")) + } + body, err := p.doJSON(ctx, http.MethodGet, seedanceProxyFaceVerificationPath+"/"+url.PathEscape(verificationID), nil, nil) + if err != nil { + return BytePlusVisualValidationResult{}, err + } + var response seedanceProxyVerificationResponse + if err := common.Unmarshal(body, &response); err != nil { + return BytePlusVisualValidationResult{}, seedanceProxyRealPersonProtocolFailure(http.StatusOK, err) + } + if observedID := strings.TrimSpace(response.VerificationID); observedID != "" && observedID != verificationID { + return BytePlusVisualValidationResult{}, seedanceProxyRealPersonProtocolFailure(http.StatusOK, errors.New("seedance verification id mismatch")) + } + status := strings.ToLower(strings.TrimSpace(response.Status)) + switch status { + case "verified": + groupID := strings.TrimSpace(response.GroupID) + if groupID == "" { + return BytePlusVisualValidationResult{}, seedanceProxyRealPersonProtocolFailure(http.StatusOK, errors.New("seedance verification group missing")) + } + return BytePlusVisualValidationResult{GroupID: groupID}, nil + case "failed", "expired": + return BytePlusVisualValidationResult{}, seedanceProxyVerificationTerminalFailure(status, http.StatusOK) + case "waiting_user", "callback_received", "resolving", "": + return BytePlusVisualValidationResult{}, seedanceProxyRealPersonProtocolFailure(http.StatusOK, errors.New("seedance verification still pending")) + default: + return BytePlusVisualValidationResult{}, seedanceProxyRealPersonProtocolFailure(http.StatusOK, errors.New("seedance verification status invalid")) + } +} + +func (p seedanceProxyRealPersonProvider) CreateAsset(ctx context.Context, request BytePlusCreateAssetRequest) (string, string, error) { + request.GroupID = strings.TrimSpace(request.GroupID) + request.URL = strings.TrimSpace(request.URL) + assetType, err := seedanceProxyAssetNormalizeType(request.AssetType) + if err != nil || request.GroupID == "" || request.URL == "" { + if err == nil { + err = errors.New("seedance real person asset input missing") + } + return "", "", seedanceProxyRealPersonProtocolFailure(0, err) + } + payload, err := common.Marshal(seedanceProxyAssetCreateRequest{ + GroupID: request.GroupID, + URL: request.URL, + AssetType: assetType, + Name: strings.TrimSpace(request.Name), + }) + if err != nil { + return "", "", seedanceProxyRealPersonProtocolFailure(0, err) + } + body, err := p.doJSON(ctx, http.MethodPost, seedanceProxyAssetUploadPath, payload, nil) + if err != nil { + return "", "", err + } + var response seedanceProxyAssetResponse + if err := common.Unmarshal(body, &response); err != nil { + return "", "", seedanceProxyRealPersonProtocolFailure(http.StatusOK, err) + } + if upstreamGroupID := strings.TrimSpace(response.Result.GroupID); upstreamGroupID != "" && upstreamGroupID != request.GroupID { + return "", "", seedanceProxyRealPersonProtocolFailure(http.StatusOK, errors.New("seedance real person asset group mismatch")) + } + assetID := strings.TrimSpace(response.Result.ID) + if assetID == "" { + return "", "", seedanceProxyRealPersonProtocolFailure(http.StatusOK, errors.New("seedance real person asset id missing")) + } + return assetID, "", nil +} + +func (p seedanceProxyRealPersonProvider) GetAsset(ctx context.Context, upstreamAssetID string) (BytePlusAssetStatus, error) { + upstreamAssetID = strings.TrimSpace(upstreamAssetID) + if upstreamAssetID == "" { + return BytePlusAssetStatus{}, seedanceProxyRealPersonProtocolFailure(0, errors.New("seedance real person asset id missing")) + } + body, err := p.doJSON(ctx, http.MethodGet, seedanceProxyAssetUploadPath+"/"+url.PathEscape(upstreamAssetID), nil, nil) + if err != nil { + return BytePlusAssetStatus{}, err + } + var response seedanceProxyAssetResponse + if err := common.Unmarshal(body, &response); err != nil { + return BytePlusAssetStatus{}, seedanceProxyRealPersonProtocolFailure(http.StatusOK, err) + } + observedID := strings.TrimSpace(response.Result.ID) + if observedID != "" && observedID != upstreamAssetID { + return BytePlusAssetStatus{}, seedanceProxyRealPersonProtocolFailure(http.StatusOK, errors.New("seedance real person asset id mismatch")) + } + status, ok := seedanceProxyAssetNormalizeStatus(response.Result.Status, false) + if !ok { + return BytePlusAssetStatus{}, seedanceProxyRealPersonProtocolFailure(http.StatusOK, errors.New("seedance real person asset status invalid")) + } + return BytePlusAssetStatus{ + UpstreamAssetID: upstreamAssetID, + Status: status, + ErrorMessage: strings.TrimSpace(response.Result.Error.Message), + }, nil +} + +func (p seedanceProxyRealPersonProvider) ListAssets(ctx context.Context, request BytePlusListAssetsRequest) (BytePlusListAssetsResult, error) { + allowedGroups := make(map[string]bool, len(request.GroupIDs)) + for _, groupID := range request.GroupIDs { + if groupID = strings.TrimSpace(groupID); groupID != "" { + allowedGroups[groupID] = true + } + } + if len(allowedGroups) == 0 { + return BytePlusListAssetsResult{}, seedanceProxyRealPersonProtocolFailure(0, errors.New("seedance real person group missing")) + } + query := url.Values{} + query.Set("GroupType", "LivenessFace") + if request.PageNumber > 0 { + query.Set("PageNumber", strconv.Itoa(request.PageNumber)) + } + if request.PageSize > 0 { + query.Set("PageSize", strconv.Itoa(request.PageSize)) + } + if name := strings.TrimSpace(request.Name); name != "" { + query.Set("Name", name) + } + for _, status := range request.Statuses { + if status = strings.TrimSpace(status); status != "" { + query.Add("Statuses", status) + } + } + body, err := p.doJSON(ctx, http.MethodGet, seedanceProxyAssetUploadPath, nil, query) + if err != nil { + return BytePlusListAssetsResult{}, err + } + var response struct { + Result seedanceProxyRealPersonAssetListResponse `json:"Result"` + } + if err := common.Unmarshal(body, &response); err != nil { + return BytePlusListAssetsResult{}, seedanceProxyRealPersonProtocolFailure(http.StatusOK, err) + } + items := make([]BytePlusListedAsset, 0, len(response.Result.Items)) + for _, item := range response.Result.Items { + if groupID := strings.TrimSpace(item.GroupID); groupID == "" || !allowedGroups[groupID] { + return BytePlusListAssetsResult{}, seedanceProxyRealPersonProtocolFailure(http.StatusOK, errors.New("seedance real person asset group mismatch")) + } + items = append(items, item) + } + return BytePlusListAssetsResult{Items: items, TotalCount: response.Result.TotalCount}, nil +} + +func (p seedanceProxyRealPersonProvider) DeleteAsset(ctx context.Context, upstreamAssetID string) (string, error) { + upstreamAssetID = strings.TrimSpace(upstreamAssetID) + if upstreamAssetID == "" { + return "", seedanceProxyRealPersonProtocolFailure(0, errors.New("seedance real person asset id missing")) + } + _, err := p.doJSON(ctx, http.MethodDelete, seedanceProxyAssetUploadPath+"/"+url.PathEscape(upstreamAssetID), nil, nil) + if err != nil { + return "", err + } + return "", nil +} + +func (p seedanceProxyRealPersonProvider) doJSON(ctx context.Context, method, path string, payload []byte, query url.Values) ([]byte, error) { + requestURL := strings.TrimRight(p.gatewayBaseURL, "/") + path + if len(query) > 0 { + requestURL += "?" + query.Encode() + } + var body io.Reader + if payload != nil { + body = bytes.NewReader(payload) + } + req, err := http.NewRequestWithContext(ctx, method, requestURL, body) + if err != nil { + return nil, seedanceProxyRealPersonProtocolFailure(0, err) + } + req.Header.Set("Accept", "application/json") + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + if key := strings.TrimSpace(p.apiKey); key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } + client, err := seedanceProxyAssetHTTPClientFactory(p.channel) + if err != nil || client == nil { + if err == nil { + err = errors.New("seedance gateway http client unavailable") + } + return nil, seedanceProxyRealPersonProtocolFailure(0, err) + } + response, err := client.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || isNetTimeout(err) { + return nil, newAssetMaterializeFailure(AssetMaterializeErrorTimeout, 0, "", 0, "", err) + } + return nil, newAssetMaterializeFailure(AssetMaterializeErrorProcessing, 0, "", 0, "", err) + } + defer response.Body.Close() + responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, seedanceProxyRealPersonResponseMax+1)) + if readErr != nil || len(responseBody) > seedanceProxyRealPersonResponseMax { + return nil, seedanceProxyRealPersonProtocolFailure(response.StatusCode, readErr) + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, seedanceProxyRealPersonHTTPFailure(response, responseBody) + } + return responseBody, nil +} + +func seedanceProxyVerificationTerminalFailure(status string, httpStatus int) error { + status = strings.ToLower(strings.TrimSpace(status)) + failure := newAssetMaterializeFailure(AssetMaterializeErrorDefinitive, httpStatus, "", 0, "", errors.New("seedance verification terminal status")) + return &seedanceProxyVerificationTerminalError{status: status, failure: failure} +} + +func seedanceProxyRealPersonProtocolFailure(status int, cause error) error { + if cause == nil { + cause = errors.New("seedance gateway response invalid") + } + return newAssetMaterializeFailure(AssetMaterializeErrorProcessing, status, "", 0, "", cause) +} + +func seedanceProxyRealPersonHTTPFailure(response *http.Response, body []byte) error { + status := 0 + var headers http.Header + if response != nil { + status = response.StatusCode + headers = response.Header + } + var envelope struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + LegacyError struct { + Code string `json:"Code"` + } `json:"Error"` + } + _ = common.Unmarshal(body, &envelope) + code := strings.TrimSpace(envelope.Error.Code) + if code == "" { + code = strings.TrimSpace(envelope.LegacyError.Code) + } + return newAssetMaterializeFailure(assetMaterializeClassForHTTPStatus(status, code), status, code, parseAssetMaterializeRetryAfter(headers.Get("Retry-After"), time.Now()), "", nil) +} diff --git a/service/seedance_proxy_real_person_test.go b/service/seedance_proxy_real_person_test.go new file mode 100644 index 000000000..bbd9ef12a --- /dev/null +++ b/service/seedance_proxy_real_person_test.go @@ -0,0 +1,228 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/stretchr/testify/require" +) + +func TestSeedanceProxyRealPersonVerificationUsesRESTGatewayContract(t *testing.T) { + var createBody map[string]any + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer seedance-key", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1"+seedanceProxyFaceVerificationPath: + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + require.NoError(t, common.DecodeJson(r.Body, &createBody)) + _, _ = io.WriteString(w, `{"verification_id":"fv_test_123","status":"waiting_user","h5_url":"https://gateway.example.invalid/verify/fv_test_123","expires_at":1783740000}`) + case r.Method == http.MethodGet && r.URL.Path == "/v1"+seedanceProxyFaceVerificationPath+"/fv_test_123": + _, _ = io.WriteString(w, `{"verification_id":"fv_test_123","status":"verified","group_id":"group-real-person","expires_at":1783740000}`) + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + installSeedanceProxyRealPersonHTTPClientFactory(t, server.Client()) + binding := seedanceProxyRealPersonTestBinding(t, server.URL+"/v1", "seedance-key") + + session, err := binding.Provider.CreateVisualValidateSession(context.Background(), "https://customer.example/return") + require.NoError(t, err) + require.Equal(t, "fv_test_123", session.BytedToken) + require.Equal(t, "https://gateway.example.invalid/verify/fv_test_123", session.H5Link) + require.Empty(t, session.CallbackURL) + require.Empty(t, createBody) + + result, err := binding.Provider.GetVisualValidateResult(context.Background(), session.BytedToken) + require.NoError(t, err) + require.Equal(t, "group-real-person", result.GroupID) +} + +func TestSeedanceProxyRealPersonAssetsUseAuthenticatedGroupAndRESTPaths(t *testing.T) { + var seenCreate seedanceProxyAssetCreateRequest + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer seedance-key", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == seedanceProxyAssetUploadPath: + require.NoError(t, common.DecodeJson(r.Body, &seenCreate)) + _, _ = io.WriteString(w, `{"Result":{"Id":"asset-real-person","GroupId":"group-real-person","Status":"Processing"}}`) + case r.Method == http.MethodGet && r.URL.Path == seedanceProxyAssetUploadPath: + require.Equal(t, "LivenessFace", r.URL.Query().Get("GroupType")) + require.Equal(t, "1", r.URL.Query().Get("PageNumber")) + require.Equal(t, "25", r.URL.Query().Get("PageSize")) + _, _ = io.WriteString(w, `{"Result":{"Items":[{"Id":"asset-real-person","GroupId":"group-real-person","AssetType":"Image","Status":"Active"}],"TotalCount":1}}`) + case r.Method == http.MethodGet && r.URL.Path == seedanceProxyAssetUploadPath+"/asset-real-person": + _, _ = io.WriteString(w, `{"Result":{"Id":"asset-real-person","GroupId":"group-real-person","Status":"Active"}}`) + case r.Method == http.MethodDelete && r.URL.Path == seedanceProxyAssetUploadPath+"/asset-real-person": + _, _ = io.WriteString(w, `{"Result":{}}`) + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.RequestURI()) + } + })) + defer server.Close() + installSeedanceProxyRealPersonHTTPClientFactory(t, server.Client()) + binding := seedanceProxyRealPersonTestBinding(t, server.URL, "seedance-key") + + assetID, _, err := binding.Provider.CreateAsset(context.Background(), BytePlusCreateAssetRequest{ + GroupID: "group-real-person", URL: "https://source.example/face.png", AssetType: "Image", Name: "Face reference", + }) + require.NoError(t, err) + require.Equal(t, "asset-real-person", assetID) + require.Equal(t, "group-real-person", seenCreate.GroupID) + + status, err := binding.Provider.GetAsset(context.Background(), assetID) + require.NoError(t, err) + require.Equal(t, model.BytePlusAssetStatusActive, status.Status) + + assets, err := binding.Provider.ListAssets(context.Background(), BytePlusListAssetsRequest{GroupIDs: []string{"group-real-person"}, PageNumber: 1, PageSize: 25}) + require.NoError(t, err) + require.Equal(t, 1, assets.TotalCount) + require.Len(t, assets.Items, 1) + + _, err = binding.Provider.DeleteAsset(context.Background(), assetID) + require.NoError(t, err) +} + +func TestSeedanceProxyRealPersonCreateAssetRejectsMismatchedGroup(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + _, _ = io.WriteString(w, `{"Result":{"Id":"asset-cross-group","GroupId":"group-other","Status":"Processing"}}`) + })) + defer server.Close() + installSeedanceProxyRealPersonHTTPClientFactory(t, server.Client()) + binding := seedanceProxyRealPersonTestBinding(t, server.URL, "seedance-key") + + _, _, err := binding.Provider.CreateAsset(context.Background(), BytePlusCreateAssetRequest{ + GroupID: "group-real-person", URL: "https://source.example/face.png", AssetType: "Image", + }) + + require.Error(t, err) + require.Equal(t, AssetMaterializeErrorProcessing, AssetMaterializeErrorClass(err)) +} + +func TestSeedanceProxyRealPersonListAssetsRejectsEmptyOrMismatchedGroups(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"Result":{"Items":[{"Id":"asset-cross-group","GroupId":"group-other","Status":"Active"}],"TotalCount":1}}`) + })) + defer server.Close() + installSeedanceProxyRealPersonHTTPClientFactory(t, server.Client()) + binding := seedanceProxyRealPersonTestBinding(t, server.URL, "seedance-key") + + _, err := binding.Provider.ListAssets(context.Background(), BytePlusListAssetsRequest{GroupIDs: []string{""}}) + require.Error(t, err) + require.Equal(t, AssetMaterializeErrorProcessing, AssetMaterializeErrorClass(err)) + + _, err = binding.Provider.ListAssets(context.Background(), BytePlusListAssetsRequest{GroupIDs: []string{"group-real-person"}}) + require.Error(t, err) + require.Equal(t, AssetMaterializeErrorProcessing, AssetMaterializeErrorClass(err)) +} + +func TestSeedanceProxyRealPersonVerificationPendingIsRetryable(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + _, _ = io.WriteString(w, `{"verification_id":"fv_pending","status":"resolving","expires_at":1783740000}`) + })) + defer server.Close() + installSeedanceProxyRealPersonHTTPClientFactory(t, server.Client()) + binding := seedanceProxyRealPersonTestBinding(t, server.URL, "seedance-key") + + _, err := binding.Provider.GetVisualValidateResult(context.Background(), "fv_pending") + + require.Error(t, err) + require.True(t, IsRetryableAssetMaterializeError(err)) + require.Equal(t, AssetMaterializeErrorProcessing, AssetMaterializeErrorClass(err)) +} + +func TestSeedanceProxyRealPersonVerificationTerminalStatusesAreDefinitive(t *testing.T) { + for _, status := range []string{"failed", "expired"} { + t.Run(status, func(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"verification_id":"fv_terminal","status":"`+status+`","expires_at":1783740000}`) + })) + defer server.Close() + installSeedanceProxyRealPersonHTTPClientFactory(t, server.Client()) + binding := seedanceProxyRealPersonTestBinding(t, server.URL, "seedance-key") + + _, err := binding.Provider.GetVisualValidateResult(context.Background(), "fv_terminal") + + require.Error(t, err) + require.False(t, IsRetryableAssetMaterializeError(err)) + require.Equal(t, AssetMaterializeErrorDefinitive, AssetMaterializeErrorClass(err)) + require.Equal(t, status, seedanceProxyVerificationTerminalStatus(err)) + }) + } +} + +func TestSeedanceProxyVerificationJobFinalizesGatewayTerminalStatuses(t *testing.T) { + for _, status := range []string{"failed", "expired"} { + t.Run(status, func(t *testing.T) { + newBytePlusRealPersonJobsFixtureWithoutRows(t) + insertBytePlusAssetChannel(t, 156, "default", common.ChannelStatusEnabled, "seedance-key") + settings := dto.AssetMaterializationSettings{ + Provider: assetMaterializationProviderSeedanceProxy, + GatewayBaseURL: "https://gateway.example.invalid", + GroupID: "group-ordinary-material", + } + settingsJSON, err := common.Marshal(dto.ChannelOtherSettings{AssetMaterialization: &settings}) + require.NoError(t, err) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + _, _ = io.WriteString(w, `{"verification_id":"fv_terminal","status":"`+status+`"}`) + })) + defer server.Close() + settings.GatewayBaseURL = server.URL + settingsJSON, err = common.Marshal(dto.ChannelOtherSettings{AssetMaterialization: &settings}) + require.NoError(t, err) + require.NoError(t, model.DB.Model(&model.Channel{}).Where("id = ?", 156).Update("settings", string(settingsJSON)).Error) + installSeedanceProxyRealPersonHTTPClientFactory(t, server.Client()) + + profile, session := seedJobVerificationSession(t, "seedance_"+status, model.BytePlusVisualValidationSessionStatusPending, "fv_terminal", 2300) + require.NoError(t, model.DB.Model(&profile).Update("channel_id", 156).Error) + + processed, err := runBytePlusRealPersonVerificationStatusJobs(context.Background(), 2000, 1950, 10) + + require.NoError(t, err) + require.Equal(t, 1, processed) + require.NoError(t, model.DB.First(&profile, profile.Id).Error) + require.NoError(t, model.DB.First(&session, session.Id).Error) + if status == "expired" { + require.Equal(t, model.BytePlusRealPersonProfileStatusExpired, profile.Status) + require.Equal(t, model.BytePlusVisualValidationSessionStatusExpired, session.Status) + } else { + require.Equal(t, model.BytePlusRealPersonProfileStatusFailed, profile.Status) + require.Equal(t, model.BytePlusVisualValidationSessionStatusFailed, session.Status) + } + }) + } +} + +func seedanceProxyRealPersonTestBinding(t *testing.T, gatewayURL, apiKey string) *realPersonProviderBinding { + t.Helper() + channel := channelWithAssetMaterializationSettings(t, constant.ChannelTypeBytePlus, dto.AssetMaterializationSettings{ + Provider: assetMaterializationProviderSeedanceProxy, + GatewayBaseURL: gatewayURL, + GroupID: "group-ordinary-material", + }) + channel.Id = 156 + channel.Status = common.ChannelStatusEnabled + channel.Key = apiKey + binding, err := realPersonProviderForChannel(channel) + require.NoError(t, err) + return binding +} + +func installSeedanceProxyRealPersonHTTPClientFactory(t *testing.T, client *http.Client) { + t.Helper() + originalFactory := seedanceProxyAssetHTTPClientFactory + seedanceProxyAssetHTTPClientFactory = func(*model.Channel) (*http.Client, error) { return client, nil } + t.Cleanup(func() { seedanceProxyAssetHTTPClientFactory = originalFactory }) +}