Files
ttstd-infinite-canvas/backend/internal/app/project_character.go

880 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}