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
@@ -0,0 +1,329 @@
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 JSONcategory 必须是 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 ""
}
}