Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 行为不变;不新增对外接口、不做生产发布、不需要数据库迁移。
40 changes: 40 additions & 0 deletions model/asset.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
127 changes: 123 additions & 4 deletions service/asset_binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions service/asset_binding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 26 additions & 1 deletion service/byteplus_real_person.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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"}`
Expand All @@ -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 == "" {
Expand Down
10 changes: 10 additions & 0 deletions service/byteplus_real_person_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading