Files
ttstd-infinite-canvas/backend/internal/repository/repository.go

180 lines
7.5 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 repository
import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
"yingce/backend/internal/database"
"yingce/backend/internal/model"
)
var ErrDailyUploadLimitExceeded = errors.New("今日上传额度已用完,请明天再试")
var ErrTaskProviderRecoveryConflict = errors.New("任务正在恢复中,请稍后查看")
var ErrTaskProviderCancellationConflict = errors.New("任务正在取消中,请稍后查看")
var ErrTaskStateConflict = errors.New("任务状态已变化,请刷新后重试")
var ErrTextReplayQuotaExceeded = errors.New("文本回放额度已用完")
var ErrTextReplayClosed = errors.New("该文本任务已结束")
var ErrEmailVerificationCodeInvalid = errors.New("邮箱验证码已失效,请重新获取")
var ErrProjectAssetFolderNotEmpty = errors.New("资产文件夹不为空,无法删除")
var ErrProjectHasActiveTasks = errors.New("项目中还有进行中的任务,请等待完成后再操作")
var ErrProjectUnitShotsChanged = errors.New("镜头已被修改,请刷新后重试")
var ErrCanvasRevisionConflict = errors.New("画布已被更新,请刷新后重试")
type Repository struct {
db *gorm.DB
}
type UserStorageUsage struct {
AssetCount int64 `json:"assetCount"`
AssetBytes int64 `json:"assetBytes"`
CanvasCount int64 `json:"canvasCount"`
CanvasBytes int64 `json:"canvasBytes"`
TaskCount int64 `json:"taskCount"`
TaskBytes int64 `json:"taskBytes"`
APICallCount int64 `json:"apiCallCount"`
}
func New(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) WithContext(ctx context.Context) *Repository {
return &Repository{db: r.db.WithContext(ctx)}
}
// Transaction exposes a repository scoped to one database transaction. Domain
// services use it when a canvas mutation and its receipt must commit together.
func (r *Repository) Transaction(fn func(*Repository) error) error {
return r.db.Transaction(func(tx *gorm.DB) error {
return fn(New(tx))
})
}
func (r *Repository) Dialect() string {
return r.db.Dialector.Name()
}
func (r *Repository) ReleaseTaskLease(id string, owner string) error {
return r.db.Model(&model.Task{}).
Where("id = ? AND status = ? AND lease_owner = ?", id, model.TaskStatusRunning, owner).
Updates(map[string]any{"lease_owner": "", "lease_expires_at": nil, "updated_at": time.Now()}).Error
}
// NextPrefixedID 在数据库事务中递增序列,避免 UUID/父子字符串拼接导致的不可读和不可排序 ID。
// prefix 只决定展示前缀,关联关系仍由独立外键维护。
func (r *Repository) NextPrefixedID(prefix string) (string, error) {
return database.AllocatePrefixedID(r.db, prefix)
}
func (r *Repository) UserStorageUsage(userID string) (UserStorageUsage, error) {
var usage UserStorageUsage
query := `
SELECT
(SELECT COUNT(*) FROM assets WHERE user_id = ?) AS asset_count,
(SELECT COALESCE(SUM(length(CAST(COALESCE(payload_json, '') AS BLOB))), 0) FROM assets WHERE user_id = ?) AS asset_bytes,
(SELECT COUNT(*) FROM canvas_projects WHERE user_id = ?) AS canvas_count,
(SELECT COALESCE(SUM(length(CAST(COALESCE(payload_json, '') AS BLOB))), 0) FROM canvas_projects WHERE user_id = ?) AS canvas_bytes,
(SELECT COUNT(*) FROM tasks WHERE user_id = ?) AS task_count,
(SELECT COALESCE(SUM(length(CAST(COALESCE(prompt, '') AS BLOB)) + length(CAST(COALESCE(input_json, '') AS BLOB)) + length(CAST(COALESCE(result_json, '') AS BLOB)) + length(CAST(COALESCE(media_recovery_json, '') AS BLOB)) + length(CAST(COALESCE(text_draft, '') AS BLOB)) + length(CAST(COALESCE(error, '') AS BLOB))), 0) FROM tasks WHERE user_id = ?)
+ (SELECT COALESCE(SUM(length(CAST(COALESCE(message, '') AS BLOB)) + length(CAST(COALESCE(payload, '') AS BLOB))), 0) FROM task_logs WHERE user_id = ?)
+ (SELECT COALESCE(SUM(length(CAST(COALESCE(url, '') AS BLOB)) + length(CAST(COALESCE(payload, '') AS BLOB))), 0) FROM results WHERE user_id = ?)
+ (SELECT COALESCE(SUM(byte_count), 0) FROM task_text_delta WHERE user_id = ?)
+ (SELECT COALESCE(SUM(length(CAST(COALESCE(path, '') AS BLOB)) + length(CAST(COALESCE(model, '') AS BLOB)) + length(CAST(COALESCE(provider_request_id, '') AS BLOB)) + length(CAST(COALESCE(error_code, '') AS BLOB)) + length(CAST(COALESCE(error, '') AS BLOB)) + length(CAST(COALESCE(upstream_url, '') AS BLOB)) + length(CAST(COALESCE(request_body, '') AS BLOB)) + length(CAST(COALESCE(response_body, '') AS BLOB))), 0) FROM api_call_logs WHERE user_id = ?) AS task_bytes,
(SELECT COUNT(*) FROM api_call_logs WHERE user_id = ?) AS api_call_count
`
if r.Dialect() == "postgres" {
query = strings.ReplaceAll(query, "length(CAST(COALESCE(", "octet_length(COALESCE(")
query = strings.ReplaceAll(query, ", '') AS BLOB))", ", ''))")
}
err := r.db.Raw(query, userID, userID, userID, userID, userID, userID, userID, userID, userID, userID, userID).Scan(&usage).Error
return usage, err
}
// UserStructuredStorageUsage reads only the counters and payload bytes used by
// asset/canvas structured-data quotas. Keeping task and API-log accounting out of
// this path avoids scanning unrelated large history rows on every canvas write.
func (r *Repository) UserStructuredStorageUsage(userID string) (UserStorageUsage, error) {
var usage UserStorageUsage
query := `
SELECT
(SELECT COUNT(*) FROM assets WHERE user_id = ?) AS asset_count,
(SELECT COALESCE(SUM(length(CAST(COALESCE(payload_json, '') AS BLOB))), 0) FROM assets WHERE user_id = ?) AS asset_bytes,
(SELECT COUNT(*) FROM canvas_projects WHERE user_id = ?) AS canvas_count,
(SELECT COALESCE(SUM(length(CAST(COALESCE(payload_json, '') AS BLOB))), 0) FROM canvas_projects WHERE user_id = ?) AS canvas_bytes
`
if r.Dialect() == "postgres" {
query = strings.ReplaceAll(query, "length(CAST(COALESCE(", "octet_length(COALESCE(")
query = strings.ReplaceAll(query, ", '') AS BLOB))", ", ''))")
}
err := r.db.Raw(query, userID, userID, userID, userID).Scan(&usage).Error
return usage, err
}
// Create 是低层兼容入口;业务写路径应优先使用带领域约束的显式方法。
func (r *Repository) Create(value any) error {
return r.db.Create(value).Error
}
// Save 是低层兼容入口;涉及状态机或权限边界的写入不得绕过显式事务方法。
func (r *Repository) Save(value any) error {
return r.db.Save(value).Error
}
func (r *Repository) AllTasks() ([]model.Task, error) {
var tasks []model.Task
return tasks, r.db.Find(&tasks).Error
}
func (r *Repository) AllAssets() ([]model.Asset, error) {
var assets []model.Asset
return assets, r.db.Find(&assets).Error
}
func (r *Repository) AllCanvasProjects() ([]model.CanvasProject, error) {
var projects []model.CanvasProject
return projects, r.db.Find(&projects).Error
}
func (r *Repository) CleanupDuplicateTaskPayloads() error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.TaskLog{}).Where("length(payload) > ?", 4000).Update("payload", "").Error; err != nil {
return err
}
return nil
})
}
func (r *Repository) BackupSQLite(path string) error {
if r.Dialect() != "sqlite" {
return errors.New("当前数据库不是 SQLite,不能执行 SQLite 备份")
}
escaped := strings.ReplaceAll(path, "'", "''")
return r.db.Exec("VACUUM INTO '" + escaped + "'").Error
}
func (r *Repository) Vacuum() error {
if r.Dialect() != "sqlite" {
return nil
}
return r.db.Exec("VACUUM").Error
}
// Delete 是低层兼容入口;删除业务数据应使用带用户/项目作用域和关联清理的显式方法。
func (r *Repository) Delete(value any, query any, args ...any) error {
conds := append([]any{query}, args...)
return r.db.Delete(value, conds...).Error
}