feat(seedance): 接入真人资产管理与引用

This commit is contained in:
2026-07-22 00:25:22 +08:00
parent 5a71643099
commit ddd68cfebd
9 changed files with 1627 additions and 1 deletions
+528
View File
@@ -0,0 +1,528 @@
package runner
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
var portraitAssetPlaceholderPattern = regexp.MustCompile(`(?i)<<<[[:space:]]*portrait[_-]?asset_([0-9]+)[[:space:]]*>>>|@portrait_asset([0-9]+)|@人像资产([0-9]+)`)
type PortraitAssetCapability struct {
Enabled bool `json:"enabled"`
CanUse bool `json:"canUse"`
CanCreate bool `json:"canCreate"`
CanUseAsPortraitAsset bool `json:"canUseAsPortraitAsset"`
CanUseAsPlainMaterial bool `json:"canUseAsPlainMaterial"`
AvailablePlatformIDs []string `json:"availablePlatformIds"`
CreationPlatformIDs []string `json:"creationPlatformIds"`
CanReferenceTencentAsset bool `json:"canReferenceTencentAssetUri"`
Reason string `json:"reason,omitempty"`
}
type PortraitAssetCreateInput struct {
Name string
Description string
SourceType string
URL string
Preview string
MimeType string
ByteSize int64
SourceSHA256 string
PrivateAvatarEligible bool
Metadata map[string]any
}
type PortraitAssetSyncResponse struct {
Requested int `json:"requested"`
Accepted int `json:"accepted"`
SyncedIDs []string `json:"syncedIds"`
Skipped []PortraitAssetIssue `json:"skipped"`
Failed []PortraitAssetIssue `json:"failed"`
Assets []store.PortraitAsset `json:"assets"`
}
type PortraitAssetIssue struct {
ID string `json:"id"`
Reason string `json:"reason"`
}
type portraitAssetPlatformSettings struct {
ProjectName string
AssetGroupID string
Credentials clients.VolcesAssetCredentials
}
func (s *Service) PortraitAssetCapability(ctx context.Context) (PortraitAssetCapability, error) {
platforms, err := s.store.ListPortraitAssetPlatforms(ctx)
if err != nil {
return PortraitAssetCapability{}, err
}
ids := make([]string, 0, len(platforms))
for _, platform := range platforms {
if _, ok := portraitAssetSettings(platform); ok {
ids = append(ids, platform.PlatformID)
}
}
capability := PortraitAssetCapability{
Enabled: len(ids) > 0,
CanUse: len(ids) > 0,
CanCreate: len(ids) > 0,
CanUseAsPortraitAsset: len(ids) > 0,
CanUseAsPlainMaterial: true,
AvailablePlatformIDs: ids,
CreationPlatformIDs: ids,
}
if len(ids) == 0 {
capability.Reason = "未配置可用的火山 Seedance 人像资产平台;请在 Volces 平台 config.seedancePrivateAsset 中配置 enabled、accessKey、secretKey、projectName、assetGroupId。"
}
return capability, nil
}
func (s *Service) CreatePortraitAsset(ctx context.Context, user *auth.User, input PortraitAssetCreateInput) (store.PortraitAsset, bool, error) {
if s.store == nil {
return store.PortraitAsset{}, false, fmt.Errorf("portrait asset store is unavailable")
}
if !validPortraitAssetSourceType(input.SourceType) {
return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_unsupported_type", Message: "source type must be image, video, or audio", StatusCode: http.StatusBadRequest, Retryable: false}
}
if strings.TrimSpace(input.URL) == "" {
return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_source_url_required", Message: "portrait asset source URL is required", StatusCode: http.StatusBadRequest, Retryable: false}
}
if !input.PrivateAvatarEligible {
return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_authorization_required", Message: "private_avatar_eligible must be true after the user confirms authorization", StatusCode: http.StatusBadRequest, Retryable: false}
}
if existing, found, err := s.store.FindPortraitAssetBySourceHash(ctx, user, input.SourceSHA256); err != nil {
return store.PortraitAsset{}, false, err
} else if found {
return existing, true, nil
}
gatewayUserID, userID := portraitAssetUserKeys(user)
if user == nil || userID == "" {
return store.PortraitAsset{}, false, store.ErrLocalUserRequired
}
asset, err := s.store.CreatePortraitAsset(ctx, store.PortraitAssetInput{
GatewayUserID: gatewayUserID,
UserID: userID,
GatewayTenantID: strings.TrimSpace(user.GatewayTenantID),
TenantID: strings.TrimSpace(user.TenantID),
TenantKey: strings.TrimSpace(user.TenantKey),
Name: strings.TrimSpace(input.Name),
Description: strings.TrimSpace(input.Description),
SourceType: strings.ToLower(strings.TrimSpace(input.SourceType)),
URL: strings.TrimSpace(input.URL),
Preview: firstNonEmptyString(strings.TrimSpace(input.Preview), strings.TrimSpace(input.URL)),
MimeType: strings.TrimSpace(input.MimeType),
ByteSize: input.ByteSize,
SourceSHA256: strings.TrimSpace(input.SourceSHA256),
PrivateAvatarEligible: input.PrivateAvatarEligible,
Metadata: input.Metadata,
})
return asset, false, err
}
func (s *Service) SyncPortraitAssets(ctx context.Context, user *auth.User, ids []string) (PortraitAssetSyncResponse, error) {
response := PortraitAssetSyncResponse{
Requested: len(ids), SyncedIDs: make([]string, 0), Skipped: make([]PortraitAssetIssue, 0), Failed: make([]PortraitAssetIssue, 0), Assets: make([]store.PortraitAsset, 0),
}
platforms, err := s.store.ListPortraitAssetPlatforms(ctx)
if err != nil {
return response, err
}
configured := make([]store.PortraitAssetPlatform, 0, len(platforms))
for _, platform := range platforms {
if _, ok := portraitAssetSettings(platform); ok {
configured = append(configured, platform)
}
}
seen := map[string]bool{}
for _, value := range ids {
assetID := strings.TrimSpace(value)
if assetID == "" || seen[assetID] {
continue
}
seen[assetID] = true
asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID)
if err != nil {
return response, err
}
if !found {
response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: assetID, Reason: "portrait asset not found"})
continue
}
if !asset.PrivateAvatarEligible {
response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: asset.ID, Reason: "portrait asset authorization is required"})
continue
}
if len(configured) == 0 {
_ = s.store.UpdatePortraitAssetStatus(ctx, asset.ID, "not_configured", "no configured Volces portrait asset platform")
asset.Status = "not_configured"
asset.LastError = "no configured Volces portrait asset platform"
response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: asset.ID, Reason: asset.LastError})
response.Assets = append(response.Assets, asset)
continue
}
response.Accepted++
assetFailed := false
for _, platform := range configured {
if err := s.syncPortraitAssetToPlatform(ctx, asset, platform); err != nil {
assetFailed = true
response.Failed = append(response.Failed, PortraitAssetIssue{ID: asset.ID, Reason: platform.PlatformID + ": " + err.Error()})
}
}
updated, _, err := s.refreshPortraitAssetStatus(ctx, user, asset.ID)
if err != nil {
return response, err
}
response.Assets = append(response.Assets, updated)
if !assetFailed {
response.SyncedIDs = append(response.SyncedIDs, updated.ID)
}
}
return response, nil
}
func (s *Service) syncPortraitAssetToPlatform(ctx context.Context, asset store.PortraitAsset, platform store.PortraitAssetPlatform) error {
settings, ok := portraitAssetSettings(platform)
if !ok {
return &clients.ClientError{Code: "portrait_asset_not_configured", Message: "platform portrait asset configuration is incomplete", Retryable: false}
}
binding, found, err := s.store.GetPortraitAssetBinding(ctx, asset.ID, platform.PlatformID)
if err != nil {
return err
}
if !found {
binding = store.PortraitAssetBinding{AssetID: asset.ID, PlatformID: platform.PlatformID, ProjectName: settings.ProjectName, AssetGroupID: settings.AssetGroupID, Status: "pending"}
}
if !portraitAssetHasPublicURL(asset.URL) {
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, &clients.ClientError{
Code: "portrait_asset_public_url_required",
Message: "portrait asset URL must be an absolute http(s) URL reachable by Volces",
StatusCode: http.StatusBadRequest,
Retryable: false,
})
}
client := clients.VolcesAssetClient{HTTPClient: s.portraitAssetHTTPClient()}
remoteID := strings.TrimSpace(binding.RemoteAssetID)
if remoteID == "" {
created, _, createErr := client.CreateAsset(ctx, settings.Credentials, map[string]any{
"GroupId": settings.AssetGroupID, "URL": asset.URL, "Name": asset.Name,
"AssetType": volcesPortraitAssetType(asset.SourceType), "ProjectName": settings.ProjectName,
})
if createErr != nil {
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, createErr)
}
remoteID = strings.TrimSpace(created.ID)
if remoteID == "" {
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, &clients.ClientError{Code: "invalid_response", Message: "volces CreateAsset returned no asset id", Retryable: false})
}
binding.RemoteAssetID = remoteID
}
remote, _, getErr := client.GetAsset(ctx, settings.Credentials, map[string]any{"Id": remoteID, "ProjectName": settings.ProjectName})
if getErr != nil {
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, getErr)
}
binding.ProjectName = settings.ProjectName
binding.AssetGroupID = settings.AssetGroupID
binding.RemoteAssetID = firstNonEmptyString(remote.ID, remoteID)
binding.RemoteAssetURI = "asset://" + binding.RemoteAssetID
binding.Status = portraitAssetBindingStatus(remote.Status)
binding.LastErrorCode = strings.TrimSpace(stringFromMap(remote.Error, "Code"))
binding.LastErrorMessage = strings.TrimSpace(stringFromMap(remote.Error, "Message"))
if binding.Status == "failed" && binding.LastErrorMessage == "" {
binding.LastErrorMessage = "volces portrait asset processing failed"
}
_, err = s.store.UpsertPortraitAssetBinding(ctx, binding)
return err
}
func (s *Service) recordPortraitAssetBindingFailure(ctx context.Context, binding store.PortraitAssetBinding, settings portraitAssetPlatformSettings, cause error) error {
binding.ProjectName = settings.ProjectName
binding.AssetGroupID = settings.AssetGroupID
binding.Status = "failed"
binding.LastErrorCode = clients.ErrorCode(cause)
binding.LastErrorMessage = cause.Error()
_, err := s.store.UpsertPortraitAssetBinding(ctx, binding)
if err != nil {
return err
}
return cause
}
func (s *Service) refreshPortraitAssetStatus(ctx context.Context, user *auth.User, assetID string) (store.PortraitAsset, bool, error) {
asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID)
if err != nil || !found {
return asset, found, err
}
active, total, lastError, _, err := s.store.PortraitAssetBindingSummary(ctx, asset.ID)
if err != nil {
return asset, true, err
}
status := "not_synced"
if total == 0 {
status = "not_synced"
} else if active > 0 {
status = "active"
if active < total {
status = "partial"
}
} else if lastError != "" {
status = "failed"
} else {
status = "pending"
}
if err := s.store.UpdatePortraitAssetStatus(ctx, asset.ID, status, lastError); err != nil {
return asset, true, err
}
asset.Status = status
asset.LastError = lastError
return asset, true, nil
}
func (s *Service) compilePortraitAssetReferences(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate) (map[string]any, error) {
entries := portraitAssetList(body["portrait_asset_list"])
if len(entries) == 0 {
return body, nil
}
if kind != "videos.generations" || !isVolcesPortraitAssetCandidate(candidate) {
return nil, &clients.ClientError{Code: "portrait_asset_unsupported_model", Message: "portrait assets require a configured Volces Seedance omni video model", StatusCode: http.StatusBadRequest, Retryable: false}
}
if !candidateSupportsPortraitAssets(candidate) {
return nil, &clients.ClientError{Code: "portrait_asset_unsupported_model", Message: "selected model does not enable supports_portrait_asset_reference", StatusCode: http.StatusBadRequest, Retryable: false}
}
out := cloneMap(body)
content := contentItems(out["content"])
labels := make([]string, len(entries))
nonAudioAssets := 0
for index, entry := range entries {
assetID := firstNonEmptyString(stringFromMap(entry, "id"), stringFromMap(entry, "easyai_portrait_asset_id"))
if assetID == "" {
return nil, &clients.ClientError{Code: "portrait_asset_id_required", Message: fmt.Sprintf("portrait_asset_list[%d].id is required", index), StatusCode: http.StatusBadRequest, Retryable: false}
}
asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID)
if err != nil {
return nil, err
}
if !found {
return nil, &clients.ClientError{Code: "portrait_asset_not_found", Message: "portrait asset not found", StatusCode: http.StatusNotFound, Retryable: false}
}
if !asset.PrivateAvatarEligible {
return nil, &clients.ClientError{Code: "portrait_asset_authorization_required", Message: "portrait asset authorization is required", StatusCode: http.StatusBadRequest, Retryable: false}
}
binding, bound, err := s.store.GetPortraitAssetBinding(ctx, asset.ID, candidate.PlatformID)
if err != nil {
return nil, err
}
if !bound || binding.Status != "active" || strings.TrimSpace(binding.RemoteAssetURI) == "" {
return nil, &clients.ClientError{Code: "portrait_asset_processing", Message: "portrait asset is not active for the selected Volces platform; sync it and retry", StatusCode: http.StatusServiceUnavailable, Retryable: true}
}
labels[index] = firstNonEmptyString(strings.TrimSpace(stringFromMap(entry, "name")), asset.Name, "portrait asset "+fmt.Sprint(index+1))
if asset.SourceType != "audio" {
nonAudioAssets++
}
content = append(content, portraitAssetContent(asset.SourceType, binding.RemoteAssetURI))
}
if nonAudioAssets == 0 {
return nil, &clients.ClientError{Code: "portrait_asset_audio_only", Message: "portrait_asset_list cannot contain audio-only assets", StatusCode: http.StatusBadRequest, Retryable: false}
}
for index := range content {
if strings.ToLower(strings.TrimSpace(stringFromAny(content[index]["type"]))) != "text" {
continue
}
content[index]["text"] = replacePortraitAssetPlaceholders(stringFromAny(content[index]["text"]), labels)
}
out["content"] = mapsToAnySlice(content)
delete(out, "portrait_asset_list")
return out, nil
}
func (s *Service) portraitAssetHTTPClient() *http.Client {
if s.httpClients != nil && s.httpClients.none != nil {
return s.httpClients.none
}
return http.DefaultClient
}
func portraitAssetSettings(platform store.PortraitAssetPlatform) (portraitAssetPlatformSettings, bool) {
config := portraitAssetNestedConfig(platform.Config)
accessKey := firstNonEmptyString(portraitAssetValue(config, "accessKey", "access_key"), portraitAssetValue(platform.Credentials, "accessKey", "access_key"))
secretKey := firstNonEmptyString(portraitAssetValue(config, "secretKey", "secret_key"), portraitAssetValue(platform.Credentials, "secretKey", "secret_key"))
projectName := firstNonEmptyString(portraitAssetValue(config, "projectName", "project_name"), "default")
assetGroupID := portraitAssetValue(config, "assetGroupId", "asset_group_id")
endpoint := firstNonEmptyString(portraitAssetValue(config, "assetEndpoint", "asset_endpoint", "volcesAssetEndpoint", "volces_asset_endpoint"), clientsVolcesAssetDefaultEndpoint())
if accessKey == "" || secretKey == "" || projectName == "" || assetGroupID == "" {
return portraitAssetPlatformSettings{}, false
}
if enabled, present := portraitAssetBool(config, "enabled"); present && !enabled {
return portraitAssetPlatformSettings{}, false
}
return portraitAssetPlatformSettings{ProjectName: projectName, AssetGroupID: assetGroupID, Credentials: clients.VolcesAssetCredentials{AccessKey: accessKey, SecretKey: secretKey, Endpoint: endpoint}}, true
}
func portraitAssetNestedConfig(config map[string]any) map[string]any {
for _, key := range []string{"seedancePrivateAsset", "seedance_private_asset", "portraitAsset", "portrait_asset"} {
if nested, ok := config[key].(map[string]any); ok {
return nested
}
}
return config
}
func portraitAssetValue(values map[string]any, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(stringFromAny(values[key])); value != "" {
return value
}
}
return ""
}
func portraitAssetBool(values map[string]any, key string) (bool, bool) {
value, ok := values[key]
if !ok {
return false, false
}
switch typed := value.(type) {
case bool:
return typed, true
case string:
return strings.EqualFold(strings.TrimSpace(typed), "true"), true
default:
return false, false
}
}
func validPortraitAssetSourceType(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "image", "video", "audio":
return true
default:
return false
}
}
func portraitAssetHasPublicURL(value string) bool {
parsed, err := url.Parse(strings.TrimSpace(value))
if err != nil || parsed.Host == "" {
return false
}
return strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https")
}
func volcesPortraitAssetType(sourceType string) string {
switch strings.ToLower(strings.TrimSpace(sourceType)) {
case "video":
return "Video"
case "audio":
return "Audio"
default:
return "Image"
}
}
func portraitAssetBindingStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "active", "succeeded", "success":
return "active"
case "failed", "error":
return "failed"
default:
return "processing"
}
}
func portraitAssetList(value any) []map[string]any {
switch typed := value.(type) {
case []any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
if object, ok := item.(map[string]any); ok {
out = append(out, object)
}
}
return out
case []map[string]any:
return typed
default:
return nil
}
}
func portraitAssetContent(sourceType string, assetURI string) map[string]any {
switch strings.ToLower(strings.TrimSpace(sourceType)) {
case "video":
return map[string]any{"type": "video_url", "role": "reference_video", "video_url": map[string]any{"url": assetURI}}
case "audio":
return map[string]any{"type": "audio_url", "role": "reference_audio", "audio_url": map[string]any{"url": assetURI}}
default:
return map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": assetURI}}
}
}
func replacePortraitAssetPlaceholders(value string, labels []string) string {
return portraitAssetPlaceholderPattern.ReplaceAllStringFunc(value, func(match string) string {
parts := portraitAssetPlaceholderPattern.FindStringSubmatch(match)
for index := 1; index < len(parts); index++ {
if parts[index] == "" {
continue
}
position := int(parts[index][0] - '0')
if len(parts[index]) > 1 {
position = 0
for _, r := range parts[index] {
position = position*10 + int(r-'0')
}
}
if position > 0 && position <= len(labels) && strings.TrimSpace(labels[position-1]) != "" {
return labels[position-1]
}
}
return match
})
}
func isVolcesPortraitAssetCandidate(candidate store.RuntimeModelCandidate) bool {
provider := strings.ToLower(strings.TrimSpace(candidate.Provider))
return provider == "volces" || provider == "volces-openai"
}
func candidateSupportsPortraitAssets(candidate store.RuntimeModelCandidate) bool {
capabilities := effectiveModelCapability(candidate)
for _, key := range []string{candidate.ModelType, "omni_video", "omni", "video_generate"} {
if capability, ok := capabilities[key].(map[string]any); ok {
if enabled, present := portraitAssetBool(capability, "supports_portrait_asset_reference"); present {
return enabled
}
}
}
return false
}
func portraitAssetUserKeys(user *auth.User) (string, string) {
if user == nil {
return "", ""
}
gatewayUserID := strings.TrimSpace(user.GatewayUserID)
if gatewayUserID == "" && user.Source == "gateway" {
gatewayUserID = strings.TrimSpace(user.ID)
}
return gatewayUserID, strings.TrimSpace(user.ID)
}
func portraitAssetSHA256(payload []byte) string {
digest := sha256.Sum256(payload)
return hex.EncodeToString(digest[:])
}
func clientsVolcesAssetDefaultEndpoint() string { return "https://ark.cn-beijing.volcengineapi.com" }
@@ -0,0 +1,50 @@
package runner
import (
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestReplacePortraitAssetPlaceholders(t *testing.T) {
got := replacePortraitAssetPlaceholders("让 <<<portrait_asset_1>>> 和 @portrait_asset2、@人像资产3 出镜", []string{"Alice", "Bob", "Carol"})
want := "让 Alice 和 Bob、Carol 出镜"
if got != want {
t.Fatalf("placeholder replacement = %q, want %q", got, want)
}
}
func TestPortraitAssetContentUsesAssetURI(t *testing.T) {
item := portraitAssetContent("video", "asset://volces-video-1")
video, _ := item["video_url"].(map[string]any)
if item["type"] != "video_url" || item["role"] != "reference_video" || video["url"] != "asset://volces-video-1" {
t.Fatalf("unexpected portrait asset content: %+v", item)
}
}
func TestPortraitAssetSettingsRequireConfiguredVolcesAssetGroup(t *testing.T) {
settings, ok := portraitAssetSettings(store.PortraitAssetPlatform{Config: map[string]any{
"seedancePrivateAsset": map[string]any{
"enabled": true, "accessKey": "ak", "secretKey": "sk", "projectName": "project", "assetGroupId": "group",
},
}})
if !ok || settings.ProjectName != "project" || settings.AssetGroupID != "group" || settings.Credentials.AccessKey != "ak" {
t.Fatalf("unexpected configured portrait asset settings: %+v ok=%v", settings, ok)
}
if _, ok := portraitAssetSettings(store.PortraitAssetPlatform{Config: map[string]any{"seedancePrivateAsset": map[string]any{"enabled": true, "accessKey": "ak"}}}); ok {
t.Fatal("incomplete platform config must not enable portrait assets")
}
}
func TestPortraitAssetHasPublicURL(t *testing.T) {
for _, value := range []string{"https://assets.example.com/portrait.png", "http://assets.example.com/portrait.mp4"} {
if !portraitAssetHasPublicURL(value) {
t.Fatalf("expected public URL: %q", value)
}
}
for _, value := range []string{"/uploads/portrait.png", "file:///tmp/portrait.png", "asset://portrait-id"} {
if portraitAssetHasPublicURL(value) {
t.Fatalf("expected non-public URL: %q", value)
}
}
}
+13 -1
View File
@@ -916,7 +916,19 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
return clients.Response{}, fmt.Errorf("prepare http client: %w", err)
}
client := s.clientFor(candidate, simulated)
providerBody, err := s.hydrateProviderRequestAssets(ctx, body, candidate)
providerBody, err := s.compilePortraitAssetReferences(ctx, user, task.Kind, body, candidate)
if err != nil {
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
AttemptID: attemptID,
Status: "failed",
Retryable: false,
Metrics: mergeMetrics(baseAttemptMetrics, map[string]any{"error": err.Error(), "retryable": false, "trace": []any{failureTraceEntry(err, false)}}),
ErrorCode: clients.ErrorCode(err),
ErrorMessage: err.Error(),
})
return clients.Response{}, err
}
providerBody, err = s.hydrateProviderRequestAssets(ctx, providerBody, candidate)
if err != nil {
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
AttemptID: attemptID,