330 lines
12 KiB
Go
330 lines
12 KiB
Go
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 ""
|
||
}
|
||
}
|