Files
ttstd-infinite-canvas/backend/internal/service/user_data.go

405 lines
12 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 service
import (
"encoding/json"
"errors"
"strings"
"time"
"unicode/utf8"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
)
type AssetsSyncRequest struct {
Assets []json.RawMessage `json:"assets"`
}
type CanvasProjectsSyncRequest struct {
Projects []json.RawMessage `json:"projects"`
}
type UserDataSummary struct {
ID string `json:"id"`
Kind string `json:"kind,omitempty"`
Category string `json:"category,omitempty"`
Status string `json:"status,omitempty"`
Title string `json:"title"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type UserDataSnapshot struct {
Assets []json.RawMessage `json:"assets"`
Projects []json.RawMessage `json:"projects"`
}
func (s *Service) UserDataSnapshot(userID string) (UserDataSnapshot, error) {
assets, err := s.UserAssets(userID)
if err != nil {
return UserDataSnapshot{}, err
}
projects, err := s.UserCanvasProjects(userID)
if err != nil {
return UserDataSnapshot{}, err
}
return UserDataSnapshot{Assets: assets, Projects: projects}, nil
}
func (s *Service) UserAssetSummaries(userID string) ([]UserDataSummary, error) {
assets, err := s.repo.AssetSummaries(userID)
if err != nil {
return nil, err
}
result := make([]UserDataSummary, 0, len(assets))
for _, asset := range assets {
result = append(result, UserDataSummary{ID: asset.ID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt})
}
return result, nil
}
func (s *Service) UserAsset(userID string, id string) (json.RawMessage, error) {
asset, err := s.repo.AssetForUser(userID, id)
if err != nil {
return nil, err
}
return json.RawMessage(asset.PayloadJSON), nil
}
func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataSummary, error) {
asset, err := assetFromJSON(userID, raw)
if err != nil {
return UserDataSummary{}, err
}
policy, err := s.RuntimePolicy()
if err != nil {
return UserDataSummary{}, err
}
s.storageMu.Lock()
defer s.storageMu.Unlock()
existing, existingErr := s.repo.AssetForUser(userID, asset.ID)
if existingErr != nil && !errors.Is(existingErr, gorm.ErrRecordNotFound) {
return UserDataSummary{}, existingErr
}
if existing != nil && existing.PayloadJSON != asset.PayloadJSON {
if err := s.validateAssetCanvasReferences(userID, asset); err != nil {
return UserDataSummary{}, err
}
}
existingBytes := int64(0)
if existing != nil {
existingBytes = int64(len([]byte(existing.PayloadJSON)))
}
usage, err := s.repo.UserStorageUsage(userID)
if err != nil {
return UserDataSummary{}, err
}
if err := validateStructuredStorageQuotaWithPolicy(usage, "asset", errors.Is(existingErr, gorm.ErrRecordNotFound), int64(len(raw))-existingBytes, policy.Resource); err != nil {
return UserDataSummary{}, err
}
if err := s.repo.UpsertAsset(&asset); err != nil {
return UserDataSummary{}, err
}
if existingErr != nil {
s.recordActivity(userID, "asset", 1)
}
return UserDataSummary{ID: asset.ID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt}, nil
}
func (s *Service) DeleteUserAsset(userID string, id string) error {
s.storageMu.Lock()
defer s.storageMu.Unlock()
return s.deleteUserAssetWithResources(userID, id)
}
func (s *Service) UserAssets(userID string) ([]json.RawMessage, error) {
assets, err := s.repo.Assets(userID)
if err != nil {
return nil, err
}
result := make([]json.RawMessage, 0, len(assets))
for _, asset := range assets {
if strings.TrimSpace(asset.PayloadJSON) != "" {
result = append(result, json.RawMessage(asset.PayloadJSON))
}
}
return result, nil
}
func (s *Service) ReplaceUserAssets(userID string, req AssetsSyncRequest) ([]json.RawMessage, error) {
assets := make([]model.Asset, 0, len(req.Assets))
var totalBytes int64
for _, raw := range req.Assets {
item, err := assetFromJSON(userID, raw)
if err != nil {
return nil, err
}
assets = append(assets, item)
totalBytes += int64(len(raw))
}
policy, err := s.RuntimePolicy()
if err != nil {
return nil, err
}
s.storageMu.Lock()
defer s.storageMu.Unlock()
if err := s.validateAssetReplacementCanvasReferences(userID, assets); err != nil {
return nil, err
}
usage, err := s.repo.UserStorageUsage(userID)
if err != nil {
return nil, err
}
if err := validateStructuredReplacementQuotaWithPolicy(usage, "asset", len(assets), totalBytes, policy.Resource); err != nil {
return nil, err
}
if err := s.repo.ReplaceAssets(userID, assets); err != nil {
return nil, err
}
if len(assets) > 0 {
s.recordActivity(userID, "asset", len(assets))
}
return s.UserAssets(userID)
}
func (s *Service) UserCanvasProjects(userID string) ([]json.RawMessage, error) {
projects, err := s.repo.CanvasProjects(userID)
if err != nil {
return nil, err
}
result := make([]json.RawMessage, 0, len(projects))
for _, project := range projects {
if strings.TrimSpace(project.PayloadJSON) != "" {
result = append(result, json.RawMessage(project.PayloadJSON))
}
}
return result, nil
}
func (s *Service) UserCanvasProjectSummaries(userID string) ([]UserDataSummary, error) {
projects, err := s.repo.CanvasProjectSummaries(userID)
if err != nil {
return nil, err
}
result := make([]UserDataSummary, 0, len(projects))
for _, project := range projects {
result = append(result, UserDataSummary{ID: project.ID, Title: project.Title, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt})
}
return result, nil
}
func (s *Service) UserCanvasProject(userID string, id string) (json.RawMessage, error) {
project, err := s.repo.CanvasProjectForUser(userID, id)
if err != nil {
return nil, err
}
return json.RawMessage(project.PayloadJSON), nil
}
func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (UserDataSummary, error) {
project, err := canvasProjectFromJSON(userID, raw)
if err != nil {
return UserDataSummary{}, err
}
policy, err := s.RuntimePolicy()
if err != nil {
return UserDataSummary{}, err
}
s.storageMu.Lock()
defer s.storageMu.Unlock()
if err := s.validateCanvasMediaAssets(userID, raw); err != nil {
return UserDataSummary{}, err
}
existing, existingErr := s.repo.CanvasProjectForUser(userID, project.ID)
if existingErr != nil && !errors.Is(existingErr, gorm.ErrRecordNotFound) {
return UserDataSummary{}, existingErr
}
existingBytes := int64(0)
if existing != nil {
existingBytes = int64(len([]byte(existing.PayloadJSON)))
}
usage, err := s.repo.UserStorageUsage(userID)
if err != nil {
return UserDataSummary{}, err
}
if err := validateStructuredStorageQuotaWithPolicy(usage, "canvas", errors.Is(existingErr, gorm.ErrRecordNotFound), int64(len(raw))-existingBytes, policy.Resource); err != nil {
return UserDataSummary{}, err
}
if err := s.repo.UpsertCanvasProject(&project); err != nil {
return UserDataSummary{}, err
}
if existingErr != nil || existing.PayloadJSON != project.PayloadJSON || existing.Title != project.Title {
s.recordActivity(userID, "canvas", 1)
}
return UserDataSummary{ID: project.ID, Title: project.Title, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt}, nil
}
func (s *Service) DeleteUserCanvasProject(userID string, id string) error {
return s.repo.DeleteCanvasProject(userID, id)
}
func (s *Service) ReplaceUserCanvasProjects(userID string, req CanvasProjectsSyncRequest) ([]json.RawMessage, error) {
projects := make([]model.CanvasProject, 0, len(req.Projects))
var totalBytes int64
for _, raw := range req.Projects {
item, err := canvasProjectFromJSON(userID, raw)
if err != nil {
return nil, err
}
projects = append(projects, item)
totalBytes += int64(len(raw))
}
policy, err := s.RuntimePolicy()
if err != nil {
return nil, err
}
s.storageMu.Lock()
defer s.storageMu.Unlock()
for _, raw := range req.Projects {
if err := s.validateCanvasMediaAssets(userID, raw); err != nil {
return nil, err
}
}
usage, err := s.repo.UserStorageUsage(userID)
if err != nil {
return nil, err
}
if err := validateStructuredReplacementQuotaWithPolicy(usage, "canvas", len(projects), totalBytes, policy.Resource); err != nil {
return nil, err
}
if err := s.repo.ReplaceCanvasProjects(userID, projects); err != nil {
return nil, err
}
if len(projects) > 0 {
s.recordActivity(userID, "canvas", 1)
}
return s.UserCanvasProjects(userID)
}
func assetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) {
if err := validateSyncedPayload(raw, "素材"); err != nil {
return model.Asset{}, err
}
var payload struct {
ID string `json:"id"`
Kind string `json:"kind"`
Category string `json:"category"`
Status string `json:"status"`
PrimaryVersionID string `json:"primaryVersionId"`
Title string `json:"title"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return model.Asset{}, BadAuthRequest("素材数据格式错误")
}
now := time.Now()
createdAt := parseClientTime(payload.CreatedAt, now)
updatedAt := parseClientTime(payload.UpdatedAt, createdAt)
id := strings.TrimSpace(payload.ID)
if id == "" {
id = newID()
}
if utf8.RuneCountInString(id) > model.AssetIDMaxLength {
return model.Asset{}, BadAuthRequest("素材 ID 不能超过 80 个字符")
}
primaryVersionID := strings.TrimSpace(payload.PrimaryVersionID)
if utf8.RuneCountInString(primaryVersionID) > 36 {
return model.Asset{}, BadAuthRequest("素材主版本 ID 不能超过 36 个字符")
}
category := model.NormalizeAssetCategory(model.AssetCategory(payload.Category), payload.Kind)
status := model.AssetVersionStatus(strings.TrimSpace(payload.Status))
if status == "" {
status = model.AssetVersionStatusConfirmed
}
return model.Asset{
ID: id,
UserID: userID,
Kind: strings.TrimSpace(payload.Kind),
Category: category,
Status: status,
PrimaryVersionID: primaryVersionID,
Title: strings.TrimSpace(payload.Title),
PayloadJSON: string(raw),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, nil
}
func canvasProjectFromJSON(userID string, raw json.RawMessage) (model.CanvasProject, error) {
if err := validateSyncedPayload(raw, "画布"); err != nil {
return model.CanvasProject{}, err
}
var payload struct {
ID string `json:"id"`
Title string `json:"title"`
ProjectID string `json:"projectId"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return model.CanvasProject{}, BadAuthRequest("画布数据格式错误")
}
now := time.Now()
createdAt := parseClientTime(payload.CreatedAt, now)
updatedAt := parseClientTime(payload.UpdatedAt, createdAt)
id := strings.TrimSpace(payload.ID)
if id == "" {
id = newID()
}
return model.CanvasProject{
ID: id,
UserID: userID,
ProjectID: strings.TrimSpace(payload.ProjectID),
Title: strings.TrimSpace(payload.Title),
PayloadJSON: string(raw),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, nil
}
func validateSyncedPayload(raw json.RawMessage, label string) error {
if len(raw) > 4<<20 {
return BadAuthRequest(label + "数据超过 4MB,请先把媒体文件保存到资源存储")
}
var payload interface{}
if err := json.Unmarshal(raw, &payload); err == nil && containsInlineMediaDataURL(payload) {
return BadAuthRequest(label + "数据包含内嵌媒体,请先上传到资源存储")
}
return nil
}
// 同步数据只禁止作为字段值存在的媒体 Data URL;提示词和上游错误文案可能合法提到相同字符串。
func containsInlineMediaDataURL(value interface{}) bool {
switch item := value.(type) {
case string:
text := strings.ToLower(strings.TrimSpace(item))
return strings.HasPrefix(text, "data:image/") || strings.HasPrefix(text, "data:video/") || strings.HasPrefix(text, "data:audio/")
case []interface{}:
for _, child := range item {
if containsInlineMediaDataURL(child) {
return true
}
}
case map[string]interface{}:
for _, child := range item {
if containsInlineMediaDataURL(child) {
return true
}
}
}
return false
}
func parseClientTime(value string, fallback time.Time) time.Time {
value = strings.TrimSpace(value)
if value == "" {
return fallback
}
if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
return parsed
}
return fallback
}