880 lines
37 KiB
Go
880 lines
37 KiB
Go
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"time"
|
||
|
||
"infinite-canvas/backend/internal/model"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type CreateProjectCharacterRequest struct {
|
||
Name string `json:"name"`
|
||
Definition map[string]any `json:"definition"`
|
||
}
|
||
|
||
type CreateCharacterRequest struct {
|
||
Name string `json:"name"`
|
||
Definition map[string]any `json:"definition"`
|
||
ImageResourceID string `json:"imageResourceId"`
|
||
AudioResourceID string `json:"audioResourceId"`
|
||
VoiceName string `json:"voiceName"`
|
||
Instructions string `json:"instructions"`
|
||
}
|
||
|
||
type CharacterAssetSummary struct {
|
||
Asset ProjectAssetSummary `json:"asset"`
|
||
Character CharacterCardSummary `json:"character"`
|
||
}
|
||
|
||
type UpdateProjectCharacterRequest struct {
|
||
Name string `json:"name"`
|
||
Definition map[string]any `json:"definition"`
|
||
}
|
||
|
||
type CharacterRepresentationInput struct {
|
||
Role string `json:"role"`
|
||
ResourceID string `json:"resourceId"`
|
||
Metadata any `json:"metadata"`
|
||
}
|
||
|
||
type ReplaceCharacterRepresentationsRequest struct {
|
||
Representations []CharacterRepresentationInput `json:"representations"`
|
||
}
|
||
|
||
type BindCharacterVoiceRequest struct {
|
||
VoiceProfileID string `json:"voiceProfileId"`
|
||
SampleResourceID string `json:"sampleResourceId"`
|
||
VoiceName string `json:"voiceName"`
|
||
Instructions string `json:"instructions"`
|
||
}
|
||
|
||
type CharacterRepresentationSummary struct {
|
||
ID string `json:"id"`
|
||
ResourceID string `json:"resourceId"`
|
||
MediaType string `json:"mediaType"`
|
||
Role string `json:"role"`
|
||
}
|
||
|
||
type VoiceProfileSummary struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Provider string `json:"provider"`
|
||
VoiceKey string `json:"voiceKey"`
|
||
Language string `json:"language"`
|
||
Timbre string `json:"timbre"`
|
||
SampleResourceID string `json:"sampleResourceId,omitempty"`
|
||
CompatibleModels []string `json:"compatibleModels"`
|
||
Status string `json:"status"`
|
||
}
|
||
|
||
type CharacterVoiceSummary struct {
|
||
Profile VoiceProfileSummary `json:"profile"`
|
||
Instructions string `json:"instructions"`
|
||
}
|
||
|
||
type characterTurnaroundTaskInput struct {
|
||
Metadata struct {
|
||
Operation string `json:"operation"`
|
||
CharacterAssetID string `json:"characterAssetId"`
|
||
} `json:"metadata"`
|
||
}
|
||
|
||
type characterTurnaroundTaskResult struct {
|
||
Images []struct {
|
||
ResourceID string `json:"resourceId"`
|
||
} `json:"images"`
|
||
}
|
||
|
||
type CharacterCardSummary struct {
|
||
VersionID string `json:"versionId"`
|
||
Version int `json:"version"`
|
||
Definition map[string]any `json:"definition"`
|
||
Representations []CharacterRepresentationSummary `json:"representations"`
|
||
Voice *CharacterVoiceSummary `json:"voice,omitempty"`
|
||
VisualStatus string `json:"visualStatus"`
|
||
VoiceStatus string `json:"voiceStatus"`
|
||
}
|
||
|
||
type ProjectCharacterDetail struct {
|
||
Asset ProjectAssetSummary `json:"asset"`
|
||
Character CharacterCardSummary `json:"character"`
|
||
}
|
||
|
||
var builtinVoiceNames = []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer", "verse", "marin", "cedar"}
|
||
|
||
func (s *Service) ListVoiceProfiles(userID string) ([]VoiceProfileSummary, error) {
|
||
now := time.Now()
|
||
profiles := make([]model.VoiceProfile, 0, len(builtinVoiceNames))
|
||
for _, key := range builtinVoiceNames {
|
||
profiles = append(profiles, model.VoiceProfile{
|
||
ID: newID(), UserID: userID, Name: strings.ToUpper(key[:1]) + key[1:], Provider: "openai_compatible", VoiceKey: key,
|
||
Language: "多语言", CompatibleModelsJSON: "[]", Status: "active", CreatedAt: now, UpdatedAt: now,
|
||
})
|
||
}
|
||
if err := s.repo.EnsureVoiceProfiles(profiles); err != nil {
|
||
return nil, err
|
||
}
|
||
stored, err := s.repo.VoiceProfiles(userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result := make([]VoiceProfileSummary, 0, len(stored))
|
||
for _, profile := range stored {
|
||
result = append(result, voiceProfileSummary(profile))
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
type CharacterListPage struct {
|
||
Characters []CharacterAssetSummary `json:"characters"`
|
||
Page int `json:"page"`
|
||
PageSize int `json:"pageSize"`
|
||
Total int64 `json:"total"`
|
||
HasMore bool `json:"hasMore"`
|
||
}
|
||
|
||
func (s *Service) ListCharacters(userID string, query string, page int, pageSize int, ids []string) (CharacterListPage, error) {
|
||
var assets []model.Asset
|
||
var total int64
|
||
var err error
|
||
if len(ids) > 0 {
|
||
assets, err = s.repo.UserCharacterAssetsByIDs(userID, ids)
|
||
total = int64(len(assets))
|
||
page, pageSize = 1, len(assets)
|
||
} else {
|
||
assets, total, err = s.repo.UserCharacterAssetsPage(userID, query, page, pageSize)
|
||
pageSize = normalizeCharacterPageSize(pageSize)
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
}
|
||
if err != nil {
|
||
return CharacterListPage{}, err
|
||
}
|
||
result := make([]CharacterAssetSummary, 0, len(assets))
|
||
for index := range assets {
|
||
detail, detailErr := s.characterAssetDetail(userID, &assets[index])
|
||
if detailErr != nil {
|
||
return CharacterListPage{}, detailErr
|
||
}
|
||
result = append(result, detail)
|
||
}
|
||
if pageSize < 1 {
|
||
pageSize = len(result)
|
||
}
|
||
return CharacterListPage{Characters: result, Page: page, PageSize: pageSize, Total: total, HasMore: int64(page*pageSize) < total}, nil
|
||
}
|
||
|
||
func normalizeCharacterPageSize(pageSize int) int {
|
||
if pageSize < 1 || pageSize > 48 {
|
||
return 12
|
||
}
|
||
return pageSize
|
||
}
|
||
|
||
func (s *Service) Character(userID string, assetID string) (CharacterAssetSummary, error) {
|
||
asset, err := s.repo.UserCharacterAsset(userID, strings.TrimSpace(assetID))
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
return s.characterAssetDetail(userID, asset)
|
||
}
|
||
|
||
func (s *Service) CreateCharacter(userID string, req CreateCharacterRequest) (CharacterAssetSummary, error) {
|
||
name := strings.TrimSpace(req.Name)
|
||
if name == "" {
|
||
return CharacterAssetSummary{}, BadAuthRequest("角色名称不能为空")
|
||
}
|
||
definition, err := normalizedCharacterDefinition(req.Definition)
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
now := time.Now()
|
||
assetID, versionID := newID(), newID()
|
||
payload, err := characterAssetPayload(assetID, versionID, name, definition, now, now)
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
asset := model.Asset{ID: assetID, UserID: userID, Kind: "entity", Category: model.AssetCategoryCharacter, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: versionID, Title: name, PayloadJSON: payload, CreatedAt: now, UpdatedAt: now}
|
||
version := model.AssetVersion{ID: versionID, AssetID: assetID, Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: string(definition), CreatedAt: now, UpdatedAt: now}
|
||
representations, err := s.characterRepresentationsForResources(userID, newID(), req.ImageResourceID, now)
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
voice, err := s.characterVoiceForResource(userID, req.AudioResourceID, req.VoiceName, req.Instructions, now)
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
// 形象与声音绑定挂在首个版本上;漏设版本 ID 会让新卡读回来显示"形象缺失 / 声音未绑定"。
|
||
for index := range representations {
|
||
representations[index].AssetVersionID = versionID
|
||
}
|
||
if voice != nil {
|
||
voice.AssetVersionID = versionID
|
||
}
|
||
if err := s.repo.CreateUserCharacter(&asset, &version, representations, voice); err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
return s.characterAssetDetail(userID, &asset)
|
||
}
|
||
|
||
func (s *Service) UpdateCharacter(userID string, assetID string, req UpdateProjectCharacterRequest) (CharacterAssetSummary, error) {
|
||
asset, err := s.repo.UserCharacterAsset(userID, strings.TrimSpace(assetID))
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
name := strings.TrimSpace(req.Name)
|
||
if name == "" {
|
||
return CharacterAssetSummary{}, BadAuthRequest("角色名称不能为空")
|
||
}
|
||
definition, err := normalizedCharacterDefinition(req.Definition)
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
if _, err := s.createNextCharacterVersion("", asset, name, string(definition), nil, nil, false); err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
return s.Character(userID, asset.ID)
|
||
}
|
||
|
||
func (s *Service) ReplaceCharacterRepresentations(userID string, assetID string, req ReplaceCharacterRepresentationsRequest) (CharacterAssetSummary, error) {
|
||
asset, err := s.repo.UserCharacterAsset(userID, strings.TrimSpace(assetID))
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
representations, err := s.characterRepresentationsForInputs(userID, req.Representations, newID(), time.Now())
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
if _, err := s.createNextCharacterVersion("", asset, asset.Title, "", representations, nil, false); err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
return s.Character(userID, asset.ID)
|
||
}
|
||
|
||
func (s *Service) BindCharacterVoice(userID string, assetID string, req BindCharacterVoiceRequest) (CharacterAssetSummary, error) {
|
||
asset, err := s.repo.UserCharacterAsset(userID, strings.TrimSpace(assetID))
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
voice, err := s.characterVoiceForResource(userID, req.SampleResourceID, req.VoiceName, req.Instructions, time.Now())
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
if voice == nil {
|
||
voice, err = s.characterVoiceForProfile(userID, req.VoiceProfileID, req.Instructions, time.Now())
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
}
|
||
if _, err := s.createNextCharacterVersion("", asset, asset.Title, "", nil, voice, false); err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
return s.Character(userID, asset.ID)
|
||
}
|
||
|
||
func (s *Service) UnbindCharacterVoice(userID string, assetID string) (CharacterAssetSummary, error) {
|
||
asset, err := s.repo.UserCharacterAsset(userID, strings.TrimSpace(assetID))
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
if _, err := s.createNextCharacterVersion("", asset, asset.Title, "", nil, nil, true); err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
return s.Character(userID, asset.ID)
|
||
}
|
||
func (s *Service) CreateProjectCharacter(userID string, projectID string, req CreateProjectCharacterRequest) (ProjectCharacterDetail, error) {
|
||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
name := strings.TrimSpace(req.Name)
|
||
if name == "" {
|
||
return ProjectCharacterDetail{}, BadAuthRequest("角色名称不能为空")
|
||
}
|
||
definition, err := normalizedCharacterDefinition(req.Definition)
|
||
if err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
now := time.Now()
|
||
assetID := newID()
|
||
versionID := newID()
|
||
payload, err := characterAssetPayload(assetID, versionID, name, definition, now, now)
|
||
if err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
asset := model.Asset{ID: assetID, UserID: userID, Kind: "entity", Category: model.AssetCategoryCharacter, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: versionID, Title: name, PayloadJSON: payload, CreatedAt: now, UpdatedAt: now}
|
||
version := model.AssetVersion{ID: versionID, AssetID: assetID, Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: string(definition), CreatedAt: now, UpdatedAt: now}
|
||
folderID, err := s.resolveProjectAssetFolderID(projectID, nil)
|
||
if err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
position, err := s.repo.NextProjectAssetPosition(projectID, folderID)
|
||
if err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, FolderID: folderID, Position: position, CreatedAt: now}
|
||
if err := s.repo.CreateProjectCharacter(projectID, &asset, &version, &link); err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
return s.projectCharacterDetail(userID, projectID, &asset)
|
||
}
|
||
|
||
func (s *Service) ProjectCharacter(userID string, projectID string, assetID string) (ProjectCharacterDetail, error) {
|
||
asset, err := s.repo.ProjectCharacterAsset(userID, projectID, strings.TrimSpace(assetID))
|
||
if err != nil || asset == nil {
|
||
if err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
return ProjectCharacterDetail{}, BadAuthRequest("角色资产不可用")
|
||
}
|
||
return s.projectCharacterDetail(userID, projectID, asset)
|
||
}
|
||
|
||
func (s *Service) UpdateProjectCharacter(userID string, projectID string, assetID string, req UpdateProjectCharacterRequest) (ProjectCharacterDetail, error) {
|
||
asset, err := s.repo.ProjectCharacterAsset(userID, projectID, strings.TrimSpace(assetID))
|
||
if err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
name := strings.TrimSpace(req.Name)
|
||
if name == "" {
|
||
return ProjectCharacterDetail{}, BadAuthRequest("角色名称不能为空")
|
||
}
|
||
definition, err := normalizedCharacterDefinition(req.Definition)
|
||
if err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
if _, err := s.createNextCharacterVersion(projectID, asset, name, string(definition), nil, nil, false); err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
return s.ProjectCharacter(userID, projectID, asset.ID)
|
||
}
|
||
|
||
func (s *Service) ReplaceProjectCharacterRepresentations(userID string, projectID string, assetID string, req ReplaceCharacterRepresentationsRequest) (ProjectCharacterDetail, error) {
|
||
asset, err := s.repo.ProjectCharacterAsset(userID, projectID, strings.TrimSpace(assetID))
|
||
if err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
if len(req.Representations) == 0 || len(req.Representations) > 8 {
|
||
return ProjectCharacterDetail{}, BadAuthRequest("角色形象数量必须在 1 到 8 之间")
|
||
}
|
||
roles := make(map[string]struct{}, len(req.Representations))
|
||
representations := make([]model.AssetRepresentation, 0, len(req.Representations))
|
||
operationID := newID()
|
||
for _, input := range req.Representations {
|
||
role := strings.TrimSpace(input.Role)
|
||
if !validCharacterRepresentationRole(role) {
|
||
return ProjectCharacterDetail{}, BadAuthRequest("不支持的角色形象视角")
|
||
}
|
||
if _, exists := roles[role]; exists {
|
||
return ProjectCharacterDetail{}, BadAuthRequest("同一角色形象视角不能重复")
|
||
}
|
||
roles[role] = struct{}{}
|
||
resourceID := strings.TrimSpace(input.ResourceID)
|
||
resource, resourceErr := s.repo.ResourceForUser(userID, resourceID)
|
||
if resourceErr != nil || resource.Kind != "image" || resource.Status != model.ResourceStatusReady {
|
||
return ProjectCharacterDetail{}, BadAuthRequest("角色形象资源不可用")
|
||
}
|
||
metadata, marshalErr := json.Marshal(input.Metadata)
|
||
if marshalErr != nil {
|
||
return ProjectCharacterDetail{}, BadAuthRequest("角色形象元数据格式无效")
|
||
}
|
||
representations = append(representations, model.AssetRepresentation{ID: newID(), TaskID: operationID, ResourceID: resourceID, MediaType: "image", Role: role, MetadataJSON: string(metadata), CreatedAt: time.Now()})
|
||
}
|
||
if _, err := s.createNextCharacterVersion(projectID, asset, asset.Title, "", representations, nil, false); err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
return s.ProjectCharacter(userID, projectID, asset.ID)
|
||
}
|
||
|
||
// 三视图生成是强校验写路径:任务只有在资源已绑定到新角色版本后才能对外显示成功。
|
||
func (s *Service) finalizeCharacterTurnaroundTask(task model.Task, result map[string]interface{}) (bool, error) {
|
||
if !strings.Contains(task.InputJSON, "character_turnaround") {
|
||
return false, nil
|
||
}
|
||
decrypted, err := s.decryptTaskInputJSON(task.InputJSON)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
var input characterTurnaroundTaskInput
|
||
if err := json.Unmarshal([]byte(decrypted), &input); err != nil {
|
||
return false, err
|
||
}
|
||
if input.Metadata.Operation != "character_turnaround" {
|
||
return false, nil
|
||
}
|
||
projectID := strings.TrimSpace(task.ProjectID)
|
||
assetID := strings.TrimSpace(input.Metadata.CharacterAssetID)
|
||
if projectID == "" || assetID == "" {
|
||
return false, errors.New("三视图任务缺少项目或角色标识")
|
||
}
|
||
encodedResult, err := json.Marshal(result)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
var output characterTurnaroundTaskResult
|
||
if err := json.Unmarshal(encodedResult, &output); err != nil {
|
||
return false, err
|
||
}
|
||
if len(output.Images) == 0 || strings.TrimSpace(output.Images[0].ResourceID) == "" {
|
||
return false, errors.New("三视图任务没有返回已持久化的图片资源")
|
||
}
|
||
|
||
s.characterTaskMu.Lock()
|
||
defer s.characterTaskMu.Unlock()
|
||
bound, err := s.characterTurnaroundTaskBound(task.ID)
|
||
if err != nil || bound {
|
||
return false, err
|
||
}
|
||
asset, err := s.repo.ProjectCharacterAsset(task.UserID, projectID, assetID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
resourceID := strings.TrimSpace(output.Images[0].ResourceID)
|
||
resource, err := s.repo.ResourceForUser(task.UserID, resourceID)
|
||
if err != nil || resource.Kind != "image" || resource.Status != model.ResourceStatusReady {
|
||
return false, BadAuthRequest("三视图任务生成的图片资源不可用")
|
||
}
|
||
sheetMetadata, err := json.Marshal(map[string]any{"prompt": task.Prompt, "source": "character_turnaround"})
|
||
if err != nil {
|
||
return false, fmt.Errorf("序列化三视图表现元数据失败:%w", err)
|
||
}
|
||
primaryMetadata, err := json.Marshal(map[string]any{"source": "turnaround_sheet"})
|
||
if err != nil {
|
||
return false, fmt.Errorf("序列化角色主表现元数据失败:%w", err)
|
||
}
|
||
now := time.Now()
|
||
representations := []model.AssetRepresentation{
|
||
{ID: newID(), TaskID: task.ID, ResourceID: resourceID, MediaType: "image", Role: "turnaround_sheet", MetadataJSON: string(sheetMetadata), CreatedAt: now},
|
||
{ID: newID(), TaskID: task.ID, ResourceID: resourceID, MediaType: "image", Role: "primary", MetadataJSON: string(primaryMetadata), CreatedAt: now},
|
||
}
|
||
if _, err := s.createNextCharacterVersion(projectID, asset, asset.Title, "", representations, nil, false); err != nil {
|
||
// 多实例同时收尾时,唯一任务视角约束只允许一个事务成功;其余实例按已绑定处理。
|
||
if bound, checkErr := s.characterTurnaroundTaskBound(task.ID); checkErr == nil && bound {
|
||
return false, nil
|
||
}
|
||
return false, err
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
func (s *Service) characterTurnaroundTaskBound(taskID string) (bool, error) {
|
||
representations, err := s.repo.AssetRepresentationsForTask(taskID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
roles := make(map[string]bool, len(representations))
|
||
for _, representation := range representations {
|
||
roles[representation.Role] = true
|
||
}
|
||
if len(representations) > 0 && (!roles["turnaround_sheet"] || !roles["primary"]) {
|
||
return false, fmt.Errorf("三视图任务 %s 的角色表现绑定不完整", taskID)
|
||
}
|
||
return roles["turnaround_sheet"] && roles["primary"], nil
|
||
}
|
||
|
||
// 项目页读取时只修复旧版前端刷新造成的断点;单个异常任务记录告警,不阻断项目展示。
|
||
func (s *Service) reconcileCharacterTurnaroundTasks(userID string, projectID string) bool {
|
||
tasks, err := s.repo.UnboundCharacterTurnaroundTasks(userID, projectID)
|
||
if err != nil {
|
||
log.Printf("reconcile character turnaround tasks failed: user=%s project=%s error=%v", userID, projectID, err)
|
||
return false
|
||
}
|
||
recovered := false
|
||
for _, task := range tasks {
|
||
var result map[string]interface{}
|
||
if err := json.Unmarshal([]byte(task.ResultJSON), &result); err != nil {
|
||
log.Printf("restore character turnaround result failed: user=%s project=%s task=%s error=%v", userID, projectID, task.ID, err)
|
||
continue
|
||
}
|
||
applied, err := s.finalizeCharacterTurnaroundTask(task, result)
|
||
if err != nil {
|
||
log.Printf("restore character turnaround task failed: user=%s project=%s task=%s error=%v", userID, projectID, task.ID, err)
|
||
continue
|
||
}
|
||
if applied {
|
||
recovered = true
|
||
_ = s.log(userID, task.ID, "info", "已将历史三视图任务恢复到角色卡", "")
|
||
}
|
||
}
|
||
return recovered
|
||
}
|
||
|
||
func (s *Service) BindProjectCharacterVoice(userID string, projectID string, assetID string, req BindCharacterVoiceRequest) (ProjectCharacterDetail, error) {
|
||
asset, err := s.repo.ProjectCharacterAsset(userID, projectID, strings.TrimSpace(assetID))
|
||
if err != nil || asset == nil {
|
||
if err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
return ProjectCharacterDetail{}, BadAuthRequest("角色资产不可用")
|
||
}
|
||
var profile *model.VoiceProfile
|
||
sampleResourceID := strings.TrimSpace(req.SampleResourceID)
|
||
if sampleResourceID != "" {
|
||
resource, resourceErr := s.repo.ResourceForUser(userID, sampleResourceID)
|
||
if resourceErr != nil || resource == nil || resource.Kind != "audio" || resource.Status != model.ResourceStatusReady || !isSupportedVoiceSampleMimeType(resource.MimeType) {
|
||
return ProjectCharacterDetail{}, BadAuthRequest("请选择已上传完成的支持格式音频:MP3、WAV、M4A/AAC、FLAC、OGG/Opus 或 WebM")
|
||
}
|
||
var profileErr error
|
||
profile, profileErr = s.repo.VoiceProfileBySampleResource(userID, sampleResourceID)
|
||
if profileErr == nil {
|
||
if err := s.renameSampleVoiceProfile(userID, profile, req.VoiceName); err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
}
|
||
if profileErr != nil {
|
||
if !errors.Is(profileErr, gorm.ErrRecordNotFound) {
|
||
return ProjectCharacterDetail{}, profileErr
|
||
}
|
||
voiceName := strings.TrimSpace(req.VoiceName)
|
||
if voiceName == "" {
|
||
voiceName = "上传声音 · " + sampleResourceID[:min(8, len(sampleResourceID))]
|
||
}
|
||
profile = &model.VoiceProfile{ID: newID(), UserID: userID, Name: voiceName, Provider: "user_upload", VoiceKey: "sample:" + sampleResourceID, Language: "按样本使用", Timbre: "用户上传样本", SampleResourceID: sampleResourceID, CompatibleModelsJSON: "[]", Status: "active", CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||
if err := s.repo.CreateVoiceProfile(profile); err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
}
|
||
} else {
|
||
profile, err = s.repo.VoiceProfileForUser(userID, strings.TrimSpace(req.VoiceProfileID))
|
||
if err != nil || profile == nil || profile.Status != "active" {
|
||
return ProjectCharacterDetail{}, BadAuthRequest("选择的声音素材不可用")
|
||
}
|
||
}
|
||
if profile == nil || strings.TrimSpace(profile.ID) == "" {
|
||
return ProjectCharacterDetail{}, BadAuthRequest("声音素材不可用,请重新选择")
|
||
}
|
||
binding := &model.CharacterVoiceBinding{ID: newID(), VoiceProfileID: profile.ID, Instructions: strings.TrimSpace(req.Instructions), CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||
if _, err := s.createNextCharacterVersion(projectID, asset, asset.Title, "", nil, binding, false); err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
return s.ProjectCharacter(userID, projectID, asset.ID)
|
||
}
|
||
|
||
func isSupportedVoiceSampleMimeType(value string) bool {
|
||
mime := strings.ToLower(strings.TrimSpace(strings.SplitN(value, ";", 2)[0]))
|
||
switch mime {
|
||
case "audio/mpeg", "audio/mp3", "audio/x-mpeg",
|
||
"audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave", "audio/x-pn-wav",
|
||
"audio/mp4", "audio/x-m4a", "audio/m4a", "audio/aac", "audio/aacp",
|
||
"audio/flac", "audio/x-flac",
|
||
"audio/ogg", "application/ogg", "audio/opus", "audio/webm":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (s *Service) UnbindProjectCharacterVoice(userID string, projectID string, assetID string) (ProjectCharacterDetail, error) {
|
||
asset, err := s.repo.ProjectCharacterAsset(userID, projectID, strings.TrimSpace(assetID))
|
||
if err != nil || asset == nil {
|
||
if err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
return ProjectCharacterDetail{}, BadAuthRequest("角色资产不可用")
|
||
}
|
||
if _, err := s.createNextCharacterVersion(projectID, asset, asset.Title, "", nil, nil, true); err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
return s.ProjectCharacter(userID, projectID, asset.ID)
|
||
}
|
||
|
||
func (s *Service) createNextCharacterVersion(projectID string, asset *model.Asset, name string, definitionJSON string, replacementRepresentations []model.AssetRepresentation, replacementVoice *model.CharacterVoiceBinding, dropVoice bool) (model.AssetVersion, error) {
|
||
nextAsset, next, representations, voice, err := s.prepareNextCharacterVersion(asset, name, definitionJSON, replacementRepresentations, replacementVoice, dropVoice)
|
||
if err != nil {
|
||
return model.AssetVersion{}, err
|
||
}
|
||
if err := s.repo.SaveCharacterVersion(projectID, &nextAsset, &next, representations, voice); err != nil {
|
||
return model.AssetVersion{}, err
|
||
}
|
||
*asset = nextAsset
|
||
return next, nil
|
||
}
|
||
|
||
func (s *Service) prepareNextCharacterVersion(asset *model.Asset, name string, definitionJSON string, replacementRepresentations []model.AssetRepresentation, replacementVoice *model.CharacterVoiceBinding, dropVoice bool) (model.Asset, model.AssetVersion, []model.AssetRepresentation, *model.CharacterVoiceBinding, error) {
|
||
if asset == nil {
|
||
return model.Asset{}, model.AssetVersion{}, nil, nil, BadAuthRequest("角色资产不可用")
|
||
}
|
||
current, err := s.repo.AssetVersion(asset.PrimaryVersionID)
|
||
if err != nil || current == nil {
|
||
if err != nil {
|
||
return model.Asset{}, model.AssetVersion{}, nil, nil, err
|
||
}
|
||
return model.Asset{}, model.AssetVersion{}, nil, nil, BadAuthRequest("角色当前版本不可用")
|
||
}
|
||
versions, err := s.repo.AssetVersions(asset.ID)
|
||
if err != nil {
|
||
return model.Asset{}, model.AssetVersion{}, nil, nil, err
|
||
}
|
||
nextNumber := 1
|
||
if len(versions) > 0 {
|
||
nextNumber = versions[0].Version + 1
|
||
}
|
||
if strings.TrimSpace(definitionJSON) == "" {
|
||
definitionJSON = current.DefinitionJSON
|
||
}
|
||
now := time.Now()
|
||
next := model.AssetVersion{ID: newID(), AssetID: asset.ID, Version: nextNumber, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: definitionJSON, Prompt: current.Prompt, Note: current.Note, CreatedAt: now, UpdatedAt: now}
|
||
representations := replacementRepresentations
|
||
if representations == nil {
|
||
currentRepresentations, representationErr := s.repo.AssetRepresentations(current.ID)
|
||
if representationErr != nil {
|
||
return model.Asset{}, model.AssetVersion{}, nil, nil, representationErr
|
||
}
|
||
operationID := newID()
|
||
representations = make([]model.AssetRepresentation, 0, len(currentRepresentations))
|
||
for _, representation := range currentRepresentations {
|
||
representations = append(representations, model.AssetRepresentation{ID: newID(), TaskID: operationID, AssetVersionID: next.ID, ResourceID: representation.ResourceID, MediaType: representation.MediaType, Role: representation.Role, MetadataJSON: representation.MetadataJSON, CreatedAt: now})
|
||
}
|
||
} else {
|
||
for index := range representations {
|
||
representations[index].AssetVersionID = next.ID
|
||
}
|
||
}
|
||
voice := replacementVoice
|
||
if voice == nil && !dropVoice {
|
||
currentVoice, voiceErr := s.repo.CharacterVoiceBinding(current.ID)
|
||
if voiceErr == nil && currentVoice != nil {
|
||
voice = &model.CharacterVoiceBinding{ID: newID(), AssetVersionID: next.ID, VoiceProfileID: currentVoice.VoiceProfileID, Instructions: currentVoice.Instructions, CreatedAt: now, UpdatedAt: now}
|
||
} else if voiceErr == nil {
|
||
return model.Asset{}, model.AssetVersion{}, nil, nil, BadAuthRequest("角色声音绑定数据不完整")
|
||
} else if !errors.Is(voiceErr, gorm.ErrRecordNotFound) {
|
||
return model.Asset{}, model.AssetVersion{}, nil, nil, voiceErr
|
||
}
|
||
}
|
||
if voice != nil {
|
||
voice.ID = newID()
|
||
voice.AssetVersionID = next.ID
|
||
voice.CreatedAt = now
|
||
voice.UpdatedAt = now
|
||
}
|
||
payload, err := characterAssetPayload(asset.ID, next.ID, name, json.RawMessage(definitionJSON), asset.CreatedAt, now)
|
||
if err != nil {
|
||
return model.Asset{}, model.AssetVersion{}, nil, nil, err
|
||
}
|
||
nextAsset := *asset
|
||
nextAsset.Title = name
|
||
nextAsset.Kind = "entity"
|
||
nextAsset.Category = model.AssetCategoryCharacter
|
||
nextAsset.Status = model.AssetVersionStatusConfirmed
|
||
nextAsset.PrimaryVersionID = next.ID
|
||
nextAsset.PayloadJSON = payload
|
||
nextAsset.UpdatedAt = now
|
||
return nextAsset, next, representations, voice, nil
|
||
}
|
||
|
||
func (s *Service) projectCharacterDetail(userID string, projectID string, asset *model.Asset) (ProjectCharacterDetail, error) {
|
||
summary, err := s.projectAssetSummary(userID, projectID, asset)
|
||
if err != nil {
|
||
return ProjectCharacterDetail{}, err
|
||
}
|
||
if summary.Character == nil {
|
||
return ProjectCharacterDetail{}, BadAuthRequest("角色素材缺少角色设定")
|
||
}
|
||
return ProjectCharacterDetail{Asset: summary, Character: *summary.Character}, nil
|
||
}
|
||
|
||
func (s *Service) characterCard(userID string, asset *model.Asset) (CharacterCardSummary, error) {
|
||
version, err := s.repo.AssetVersion(asset.PrimaryVersionID)
|
||
if err != nil {
|
||
return CharacterCardSummary{}, err
|
||
}
|
||
return s.characterCardVersion(userID, version)
|
||
}
|
||
|
||
func (s *Service) characterCardVersion(userID string, version *model.AssetVersion) (CharacterCardSummary, error) {
|
||
definition := map[string]any{}
|
||
if err := json.Unmarshal([]byte(version.DefinitionJSON), &definition); err != nil {
|
||
return CharacterCardSummary{}, err
|
||
}
|
||
stored, err := s.repo.AssetRepresentations(version.ID)
|
||
if err != nil {
|
||
return CharacterCardSummary{}, err
|
||
}
|
||
representations := make([]CharacterRepresentationSummary, 0, len(stored))
|
||
roles := make(map[string]bool, len(stored))
|
||
for _, representation := range stored {
|
||
representations = append(representations, CharacterRepresentationSummary{ID: representation.ID, ResourceID: representation.ResourceID, MediaType: representation.MediaType, Role: representation.Role})
|
||
roles[representation.Role] = true
|
||
}
|
||
visualStatus := "missing"
|
||
// 新角色只需要一张完整的三视图设定图;旧版本的三张视图仍按原规则兼容读取。
|
||
if roles["turnaround_sheet"] || (roles["front"] && roles["side"] && roles["back"]) {
|
||
visualStatus = "ready"
|
||
} else if len(representations) > 0 {
|
||
visualStatus = "partial"
|
||
}
|
||
var voice *CharacterVoiceSummary
|
||
voiceStatus := "missing"
|
||
binding, bindingErr := s.repo.CharacterVoiceBinding(version.ID)
|
||
if bindingErr == nil && binding != nil {
|
||
profile, profileErr := s.repo.VoiceProfileForUser(userID, binding.VoiceProfileID)
|
||
if profileErr == nil && profile != nil && profile.Status == "active" {
|
||
voice = &CharacterVoiceSummary{Profile: voiceProfileSummary(*profile), Instructions: binding.Instructions}
|
||
voiceStatus = "ready"
|
||
} else {
|
||
voiceStatus = "unavailable"
|
||
}
|
||
} else if !errors.Is(bindingErr, gorm.ErrRecordNotFound) {
|
||
return CharacterCardSummary{}, bindingErr
|
||
}
|
||
return CharacterCardSummary{VersionID: version.ID, Version: version.Version, Definition: definition, Representations: representations, Voice: voice, VisualStatus: visualStatus, VoiceStatus: voiceStatus}, nil
|
||
}
|
||
|
||
func (s *Service) characterAssetDetail(userID string, asset *model.Asset) (CharacterAssetSummary, error) {
|
||
if asset == nil || asset.Category != model.AssetCategoryCharacter || asset.Kind != "entity" {
|
||
return CharacterAssetSummary{}, BadAuthRequest("角色资产不可用")
|
||
}
|
||
versions, err := s.repo.AssetVersions(asset.ID)
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
card, err := s.characterCard(userID, asset)
|
||
if err != nil {
|
||
return CharacterAssetSummary{}, err
|
||
}
|
||
storageKey, previewText, source, durationMs := projectAssetPreview(asset.PayloadJSON)
|
||
return CharacterAssetSummary{Asset: ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: []string{}, StorageKey: storageKey, PreviewText: previewText, DurationMs: durationMs, UpdatedAt: asset.UpdatedAt, Source: source, Character: &card}, Character: card}, nil
|
||
}
|
||
|
||
func (s *Service) characterRepresentationsForResources(userID, taskID, imageResourceID string, now time.Time) ([]model.AssetRepresentation, error) {
|
||
imageResourceID = strings.TrimSpace(imageResourceID)
|
||
if imageResourceID == "" {
|
||
return nil, nil
|
||
}
|
||
resource, err := s.repo.ResourceForUser(userID, imageResourceID)
|
||
if err != nil || resource == nil || resource.Kind != "image" || resource.Status != model.ResourceStatusReady {
|
||
return nil, BadAuthRequest("角色形象资源不可用")
|
||
}
|
||
metadata, _ := json.Marshal(map[string]any{"source": "character_image"})
|
||
return []model.AssetRepresentation{
|
||
{ID: newID(), TaskID: taskID, ResourceID: imageResourceID, MediaType: "image", Role: "turnaround_sheet", MetadataJSON: string(metadata), CreatedAt: now},
|
||
{ID: newID(), TaskID: taskID, ResourceID: imageResourceID, MediaType: "image", Role: "primary", MetadataJSON: string(metadata), CreatedAt: now},
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) characterRepresentationsForInputs(userID string, inputs []CharacterRepresentationInput, taskID string, now time.Time) ([]model.AssetRepresentation, error) {
|
||
if len(inputs) == 0 || len(inputs) > 8 {
|
||
return nil, BadAuthRequest("角色形象数量必须在 1 到 8 之间")
|
||
}
|
||
roles := make(map[string]struct{}, len(inputs))
|
||
result := make([]model.AssetRepresentation, 0, len(inputs))
|
||
for _, input := range inputs {
|
||
role := strings.TrimSpace(input.Role)
|
||
if !validCharacterRepresentationRole(role) {
|
||
return nil, BadAuthRequest("不支持的角色形象视角")
|
||
}
|
||
if _, exists := roles[role]; exists {
|
||
return nil, BadAuthRequest("同一角色形象视角不能重复")
|
||
}
|
||
roles[role] = struct{}{}
|
||
resourceID := strings.TrimSpace(input.ResourceID)
|
||
resource, err := s.repo.ResourceForUser(userID, resourceID)
|
||
if err != nil || resource == nil || resource.Kind != "image" || resource.Status != model.ResourceStatusReady {
|
||
return nil, BadAuthRequest("角色形象资源不可用")
|
||
}
|
||
metadata, marshalErr := json.Marshal(input.Metadata)
|
||
if marshalErr != nil {
|
||
return nil, BadAuthRequest("角色形象元数据格式无效")
|
||
}
|
||
result = append(result, model.AssetRepresentation{ID: newID(), TaskID: taskID, ResourceID: resourceID, MediaType: "image", Role: role, MetadataJSON: string(metadata), CreatedAt: now})
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) characterVoiceForResource(userID, resourceID, voiceName, instructions string, now time.Time) (*model.CharacterVoiceBinding, error) {
|
||
resourceID = strings.TrimSpace(resourceID)
|
||
if resourceID == "" {
|
||
return nil, nil
|
||
}
|
||
resource, err := s.repo.ResourceForUser(userID, resourceID)
|
||
if err != nil || resource == nil || resource.Kind != "audio" || resource.Status != model.ResourceStatusReady || !isSupportedVoiceSampleMimeType(resource.MimeType) {
|
||
return nil, BadAuthRequest("请选择已上传完成的支持格式音频:MP3、WAV、M4A/AAC、FLAC、OGG/Opus 或 WebM")
|
||
}
|
||
profile, profileErr := s.repo.VoiceProfileBySampleResource(userID, resourceID)
|
||
if profileErr == nil {
|
||
if err := s.renameSampleVoiceProfile(userID, profile, voiceName); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if profileErr != nil {
|
||
if !errors.Is(profileErr, gorm.ErrRecordNotFound) {
|
||
return nil, profileErr
|
||
}
|
||
voiceName = strings.TrimSpace(voiceName)
|
||
if voiceName == "" {
|
||
voiceName = "上传声音 · " + resourceID[:min(8, len(resourceID))]
|
||
}
|
||
profile = &model.VoiceProfile{ID: newID(), UserID: userID, Name: voiceName, Provider: "user_upload", VoiceKey: "sample:" + resourceID, Language: "按样本使用", Timbre: "用户上传样本", SampleResourceID: resourceID, CompatibleModelsJSON: "[]", Status: "active", CreatedAt: now, UpdatedAt: now}
|
||
if err := s.repo.CreateVoiceProfile(profile); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return &model.CharacterVoiceBinding{ID: newID(), VoiceProfileID: profile.ID, Instructions: strings.TrimSpace(instructions), CreatedAt: now, UpdatedAt: now}, nil
|
||
}
|
||
|
||
// renameSampleVoiceProfile 让同一段样本复用的声音档案跟随用户最新给的名字
|
||
// (例如画布上把「生成音频」改名为「杨过·声音」后重新绑定)。
|
||
func (s *Service) renameSampleVoiceProfile(userID string, profile *model.VoiceProfile, voiceName string) error {
|
||
voiceName = truncateRunes(strings.TrimSpace(voiceName), 160)
|
||
if profile == nil || profile.Provider != "user_upload" || voiceName == "" || voiceName == profile.Name {
|
||
return nil
|
||
}
|
||
if err := s.repo.RenameVoiceProfile(userID, profile.ID, voiceName); err != nil {
|
||
return err
|
||
}
|
||
profile.Name = voiceName
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) characterVoiceForProfile(userID, profileID, instructions string, now time.Time) (*model.CharacterVoiceBinding, error) {
|
||
profile, err := s.repo.VoiceProfileForUser(userID, strings.TrimSpace(profileID))
|
||
if err != nil || profile == nil || profile.Status != "active" {
|
||
return nil, BadAuthRequest("选择的声音素材不可用")
|
||
}
|
||
return &model.CharacterVoiceBinding{ID: newID(), VoiceProfileID: profile.ID, Instructions: strings.TrimSpace(instructions), CreatedAt: now, UpdatedAt: now}, nil
|
||
}
|
||
func normalizedCharacterDefinition(value map[string]any) (json.RawMessage, error) {
|
||
if value == nil {
|
||
value = map[string]any{}
|
||
}
|
||
encoded, err := json.Marshal(value)
|
||
if err != nil {
|
||
return nil, BadAuthRequest("角色设定格式无效")
|
||
}
|
||
return encoded, nil
|
||
}
|
||
|
||
func characterAssetPayload(assetID string, versionID string, name string, definition json.RawMessage, createdAt time.Time, updatedAt time.Time) (string, error) {
|
||
var value map[string]any
|
||
if err := json.Unmarshal(definition, &value); err != nil {
|
||
return "", err
|
||
}
|
||
payload, err := json.Marshal(map[string]any{
|
||
"id": assetID, "kind": "entity", "category": model.AssetCategoryCharacter, "status": model.AssetVersionStatusConfirmed,
|
||
"primaryVersionId": versionID, "title": name, "coverUrl": "", "tags": []string{}, "data": map[string]any{"definition": value},
|
||
"createdAt": createdAt.Format(time.RFC3339Nano), "updatedAt": updatedAt.Format(time.RFC3339Nano),
|
||
})
|
||
return string(payload), err
|
||
}
|
||
|
||
func voiceProfileSummary(profile model.VoiceProfile) VoiceProfileSummary {
|
||
compatible := []string{}
|
||
_ = json.Unmarshal([]byte(profile.CompatibleModelsJSON), &compatible)
|
||
return VoiceProfileSummary{ID: profile.ID, Name: profile.Name, Provider: profile.Provider, VoiceKey: profile.VoiceKey, Language: profile.Language, Timbre: profile.Timbre, SampleResourceID: profile.SampleResourceID, CompatibleModels: compatible, Status: profile.Status}
|
||
}
|
||
|
||
func validCharacterRepresentationRole(role string) bool {
|
||
switch role {
|
||
case "primary", "front", "side", "back", "turnaround_sheet", "expression_sheet":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|