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

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
}
}