package httpapi import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "strings" "time" "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/runner" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) const seedancePortraitAssetCategory = "seedance_portrait_asset" // getSeedancePortraitAssetCapability godoc // @Summary 查询 Seedance 真人资产能力 // @Description 返回当前网关是否已配置可创建、同步和引用的火山 Seedance 真人资产平台。 // @Tags portrait-assets // @Produce json // @Security BearerAuth // @Success 200 {object} runner.PortraitAssetCapability // @Router /api/v1/resource/material/seedance-portrait-assets/capability [get] func (s *Server) getSeedancePortraitAssetCapability(w http.ResponseWriter, r *http.Request) { capability, err := s.runner.PortraitAssetCapability(r.Context()) if err != nil { s.logger.Error("get portrait asset capability failed", "error", err) writeError(w, http.StatusInternalServerError, "get portrait asset capability failed") return } writeJSON(w, http.StatusOK, capability) } // listSeedancePortraitAssets godoc // @Summary 列出 Seedance 真人资产 // @Description 返回当前用户的真人资产;兼容 server-main material 列表响应字段。 // @Tags portrait-assets // @Produce json // @Security BearerAuth // @Success 200 {object} map[string]any // @Router /api/v1/resource/material/user/materials [get] func (s *Server) listSeedancePortraitAssets(w http.ResponseWriter, r *http.Request) { user, ok := auth.UserFromContext(r.Context()) if !ok || user == nil { writeError(w, http.StatusUnauthorized, "unauthorized") return } if category := strings.TrimSpace(r.URL.Query().Get("category")); category != seedancePortraitAssetCategory { writeError(w, http.StatusNotFound, "material category not found") return } items, err := s.store.ListPortraitAssets(r.Context(), user, store.PortraitAssetListFilter{ Keyword: r.URL.Query().Get("keyword"), SourceType: firstNonEmptyQuery(r, "fileType", "sourceType"), Page: portraitAssetQueryInt(r, "pageNumber", "page"), PageSize: portraitAssetQueryInt(r, "pageSize"), }) if err != nil { s.logger.Error("list portrait assets failed", "error", err) writeError(w, http.StatusInternalServerError, "list portrait assets failed") return } responseItems := make([]any, 0, len(items.Items)) for _, item := range items.Items { responseItems = append(responseItems, s.portraitAssetResponse(r, item)) } writeJSON(w, http.StatusOK, map[string]any{ "data": responseItems, "total": items.Total, "page": items.Page, "pageSize": items.PageSize, }) } // createSeedancePortraitAsset godoc // @Summary 上传并创建 Seedance 真人资产 // @Description 文件先写入网关文件存储;仅在 private_avatar_eligible=true 时登记到火山 Assets。创建后会立即触发一次状态同步。 // @Tags portrait-assets // @Accept multipart/form-data // @Produce json // @Security BearerAuth // @Param file formData file true "真人资产源文件(图片、视频或音频)" // @Param data formData string true "material JSON,category 必须是 seedance_portrait_asset" // @Success 200 {object} map[string]any // @Router /api/v1/resource/material [post] func (s *Server) createSeedancePortraitAsset(w http.ResponseWriter, r *http.Request) { user, ok := auth.UserFromContext(r.Context()) if !ok || user == nil { writeError(w, http.StatusUnauthorized, "unauthorized") return } r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes) if err := r.ParseMultipartForm(multipartTaskMemoryBytes); err != nil { writeError(w, http.StatusBadRequest, "invalid multipart form-data body") return } if r.MultipartForm != nil { defer r.MultipartForm.RemoveAll() } var data map[string]any if err := json.Unmarshal([]byte(strings.TrimSpace(r.FormValue("data"))), &data); err != nil || data == nil { writeError(w, http.StatusBadRequest, "data must be a JSON object") return } if strings.TrimSpace(portraitAssetString(data["category"])) != seedancePortraitAssetCategory { writeError(w, http.StatusBadRequest, "category must be seedance_portrait_asset") return } privateEligible, _ := data["private_avatar_eligible"].(bool) if !privateEligible { writeError(w, http.StatusBadRequest, "private_avatar_eligible must be true after the user confirms authorization", "portrait_asset_authorization_required") return } file, header, err := r.FormFile("file") if err != nil { writeError(w, http.StatusBadRequest, "file is required") return } defer file.Close() payload, err := io.ReadAll(file) if err != nil { writeError(w, http.StatusBadRequest, "read portrait asset file failed") return } contentType := strings.TrimSpace(header.Header.Get("Content-Type")) if contentType == "" && len(payload) > 0 { contentType = http.DetectContentType(payload) } sourceType := strings.ToLower(strings.TrimSpace(firstNonEmpty(portraitAssetString(data["fileType"]), portraitAssetString(data["sourceType"])))) if !portraitAssetSourceMatchesContentType(sourceType, contentType) { writeError(w, http.StatusBadRequest, "fileType must be image, video, or audio and match the uploaded file", "portrait_asset_unsupported_type") return } upload, err := s.runner.UploadFile(r.Context(), runner.FileUploadPayload{ Bytes: payload, ContentType: contentType, FileName: header.Filename, Source: "seedance-portrait-asset", Scene: store.FileStorageSceneUpload, }) if err != nil { s.logger.Error("upload portrait asset failed", "error", err) writeError(w, http.StatusBadGateway, err.Error(), clients.ErrorCode(err)) return } url := strings.TrimSpace(portraitAssetString(upload["url"])) if url == "" { writeError(w, http.StatusBadGateway, "portrait asset upload returned no URL", "portrait_asset_source_url_required") return } digest := sha256.Sum256(payload) asset, reused, err := s.runner.CreatePortraitAsset(r.Context(), user, runner.PortraitAssetCreateInput{ Name: strings.TrimSpace(portraitAssetString(data["name"])), Description: strings.TrimSpace(portraitAssetString(data["description"])), SourceType: sourceType, URL: url, Preview: firstNonEmpty(portraitAssetString(data["preview"]), url), MimeType: contentType, ByteSize: int64(len(payload)), SourceSHA256: hex.EncodeToString(digest[:]), PrivateAvatarEligible: privateEligible, Metadata: map[string]any{ "tags": data["tags"], "materialGroupId": data["material_group_id"], "uploadedFileName": header.Filename, "uploadAssetStorage": upload["assetStorage"], }, }) if err != nil { writePortraitAssetError(w, err) return } _, _ = s.runner.SyncPortraitAssets(r.Context(), user, []string{asset.ID}) asset, _, err = s.refreshPortraitAssetForResponse(r, user, asset.ID, asset) if err != nil { s.logger.Error("refresh portrait asset after create failed", "error", err) writeError(w, http.StatusInternalServerError, "refresh portrait asset failed") return } response := map[string]any{"asset": s.portraitAssetResponse(r, asset)} if reused { response["dedupe"] = map[string]any{"reused": true, "code": "PORTRAIT_ASSET_REUSED", "reason": "same_source", "message": "已复用相同源文件的真人资产,并触发状态刷新。"} } writeJSON(w, http.StatusOK, response) } // syncSeedancePortraitAssets godoc // @Summary 同步 Seedance 真人资产状态 // @Description 调用火山 CreateAsset/GetAsset;多次调用可把 Processing 状态刷新为 Active 或 Failed。 // @Tags portrait-assets // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} runner.PortraitAssetSyncResponse // @Router /api/v1/resource/material/seedance-portrait-assets/sync [post] func (s *Server) syncSeedancePortraitAssets(w http.ResponseWriter, r *http.Request) { user, ok := auth.UserFromContext(r.Context()) if !ok || user == nil { writeError(w, http.StatusUnauthorized, "unauthorized") return } var request struct { IDs []string `json:"ids"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { writeError(w, http.StatusBadRequest, "invalid json body") return } if len(request.IDs) == 0 { writeJSON(w, http.StatusOK, runner.PortraitAssetSyncResponse{SyncedIDs: []string{}, Skipped: []runner.PortraitAssetIssue{}, Failed: []runner.PortraitAssetIssue{}, Assets: []store.PortraitAsset{}}) return } response, err := s.runner.SyncPortraitAssets(r.Context(), user, request.IDs) if err != nil { s.logger.Error("sync portrait assets failed", "error", err) writePortraitAssetError(w, err) return } assets := make([]any, 0, len(response.Assets)) for _, asset := range response.Assets { assets = append(assets, s.portraitAssetResponse(r, asset)) } writeJSON(w, http.StatusOK, map[string]any{ "requested": response.Requested, "accepted": response.Accepted, "syncedIds": response.SyncedIDs, "skipped": response.Skipped, "failed": response.Failed, "assets": assets, }) } func (s *Server) refreshPortraitAssetForResponse(r *http.Request, user *auth.User, assetID string, fallback store.PortraitAsset) (store.PortraitAsset, bool, error) { asset, found, err := s.store.FindPortraitAssetForUser(r.Context(), user, assetID) if err != nil || !found { return fallback, found, err } return asset, true, nil } func (s *Server) portraitAssetResponse(r *http.Request, asset store.PortraitAsset) map[string]any { active, total, lastError, updatedAt, err := s.store.PortraitAssetBindingSummary(r.Context(), asset.ID) if err != nil { active, total, lastError, updatedAt = 0, 0, asset.LastError, asset.UpdatedAt.UTC().Format(time.RFC3339Nano) } summaryStatus := asset.Status if summaryStatus == "not_synced" && total == 0 { summaryStatus = "not_synced" } response := map[string]any{ "id": asset.ID, "name": asset.Name, "description": asset.Description, "url": asset.URL, "preview": firstNonEmpty(asset.Preview, asset.URL), "type": "personal", "fileType": asset.SourceType, "sourceType": asset.SourceType, "size": asset.ByteSize, "privateAvatarEligible": asset.PrivateAvatarEligible, "createdAt": asset.CreatedAt.UTC().Format(time.RFC3339Nano), "updatedAt": asset.UpdatedAt.UTC().Format(time.RFC3339Nano), "seedanceAssetSummary": map[string]any{ "eligible": asset.PrivateAvatarEligible, "status": summaryStatus, "provider": "volces", "activePlatformCount": active, "totalPlatformCount": total, "sourceType": asset.SourceType, "updatedAt": updatedAt, }, } if lastError != "" { response["seedanceAssetSummary"].(map[string]any)["lastError"] = lastError } if asset.SourceType == "image" { response["thumbnail"] = firstNonEmpty(asset.Preview, asset.URL) } return response } func writePortraitAssetError(w http.ResponseWriter, err error) { status := http.StatusInternalServerError if clientErr := clients.ErrorCode(err); clientErr != "client_error" { switch clientErr { case "portrait_asset_not_found": status = http.StatusNotFound case "portrait_asset_processing": status = http.StatusServiceUnavailable case "portrait_asset_authorization_required", "portrait_asset_unsupported_type", "portrait_asset_source_url_required", "portrait_asset_id_required", "portrait_asset_unsupported_model", "portrait_asset_audio_only": status = http.StatusBadRequest } writeError(w, status, err.Error(), clientErr) return } writeError(w, status, err.Error()) } func portraitAssetSourceMatchesContentType(sourceType string, contentType string) bool { contentType = strings.ToLower(strings.TrimSpace(contentType)) switch sourceType { case "image": return strings.HasPrefix(contentType, "image/") case "video": return strings.HasPrefix(contentType, "video/") case "audio": return strings.HasPrefix(contentType, "audio/") default: return false } } func portraitAssetQueryInt(r *http.Request, keys ...string) int { for _, key := range keys { value := strings.TrimSpace(r.URL.Query().Get(key)) if value == "" { continue } var parsed int if _, err := fmt.Sscan(value, &parsed); err == nil { return parsed } } return 0 } func firstNonEmptyQuery(r *http.Request, keys ...string) string { for _, key := range keys { if value := strings.TrimSpace(r.URL.Query().Get(key)); value != "" { return value } } return "" } func portraitAssetString(value any) string { switch typed := value.(type) { case string: return typed case fmt.Stringer: return typed.String() default: return "" } }