fix(platform): 存储与 Agent - 修复配额准入、Live2D 与用户筛选

This commit is contained in:
Rou
2026-09-30 18:28:19 +08:00
parent 53050a7a30
commit 87fa805caf
14 changed files with 211 additions and 39 deletions

View File

@@ -13,13 +13,13 @@ import (
)
type AdminResourceQuery struct {
Keyword string
Kind string
Status string
Provider string
UserID string
Page int
Limit int
Keyword string
Kind string
Status string
Provider string
UserQuery string
Page int
Limit int
}
type AdminStorageResourceView struct {
@@ -149,13 +149,13 @@ func (s *Service) PrepareResourceDeliveryAsAdmin(actor *model.User, id string, o
func normalizeAdminResourceQuery(query AdminResourceQuery) (repository.AdminResourceFilter, int, int, error) {
page, limit := normalizeAdminPage(query.Page, query.Limit)
filter := repository.AdminResourceFilter{
Keyword: strings.TrimSpace(query.Keyword),
Kind: strings.ToLower(strings.TrimSpace(query.Kind)),
Status: strings.ToLower(strings.TrimSpace(query.Status)),
Provider: strings.ToLower(strings.TrimSpace(query.Provider)),
UserID: strings.TrimSpace(query.UserID),
Limit: limit,
Offset: (page - 1) * limit,
Keyword: strings.TrimSpace(query.Keyword),
Kind: strings.ToLower(strings.TrimSpace(query.Kind)),
Status: strings.ToLower(strings.TrimSpace(query.Status)),
Provider: strings.ToLower(strings.TrimSpace(query.Provider)),
UserQuery: strings.TrimSpace(query.UserQuery),
Limit: limit,
Offset: (page - 1) * limit,
}
if filter.Kind != "" && !oneOf(filter.Kind, "image", "video", "audio", "file", "live2d") {
return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源类型筛选无效")
@@ -166,7 +166,7 @@ func normalizeAdminResourceQuery(query AdminResourceQuery) (repository.AdminReso
if filter.Provider != "" && !oneOf(filter.Provider, "local", aliyunOSSProvider, tencentCOSProvider, qiniuKodoProvider, s3Provider) {
return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源存储位置筛选无效")
}
if len(filter.Keyword) > 200 || len(filter.UserID) > 64 {
if len(filter.Keyword) > 200 || len(filter.UserQuery) > 160 {
return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源筛选条件过长")
}
return filter, page, limit, nil

View File

@@ -4,6 +4,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -29,7 +30,7 @@ func TestAdminStorageListStatsAndPreview(t *testing.T) {
}
users := []model.User{
{ID: "admin", Username: "admin", DisplayName: "管理员", Role: model.UserRoleAdmin, Status: model.UserStatusActive},
{ID: "user-1", Username: "creator", DisplayName: "创作者", Role: model.UserRoleUser, Status: model.UserStatusActive},
{ID: "user-1", Username: "creator", Email: "creator@example.com", DisplayName: "创作者", Role: model.UserRoleUser, Status: model.UserStatusActive},
}
if err := db.Create(&users).Error; err != nil {
t.Fatal(err)
@@ -66,6 +67,28 @@ func TestAdminStorageListStatsAndPreview(t *testing.T) {
t.Fatalf("physical bytes = %+v", page.Items)
}
for _, userQuery := range []string{"user-1", "creator", "创作者", "creator@example.com"} {
filtered, filterErr := svc.AdminResourcePage(admin, AdminResourceQuery{UserQuery: userQuery, Page: 1, Limit: 20})
if filterErr != nil {
t.Fatalf("AdminResourcePage(UserQuery=%q) error = %v", userQuery, filterErr)
}
if filtered.Total != 2 || len(filtered.Items) != 2 {
t.Fatalf("AdminResourcePage(UserQuery=%q) = %+v", userQuery, filtered)
}
for _, item := range filtered.Items {
if item.UserID != "user-1" {
t.Fatalf("AdminResourcePage(UserQuery=%q) returned user %q", userQuery, item.UserID)
}
}
}
missing, missingErr := svc.AdminResourcePage(admin, AdminResourceQuery{UserQuery: "missing-user", Page: 1, Limit: 20})
if missingErr != nil {
t.Fatal(missingErr)
}
if missing.Total != 0 || len(missing.Items) != 0 {
t.Fatalf("missing user filter returned resources: %+v", missing)
}
stats, err := svc.AdminStorageStats(admin)
if err != nil {
t.Fatal(err)
@@ -103,6 +126,9 @@ func TestAdminStorageRejectsNonAdminAndInvalidFilters(t *testing.T) {
if _, _, _, err := normalizeAdminResourceQuery(AdminResourceQuery{Status: "unknown"}); err == nil {
t.Fatal("expected invalid status filter to be rejected")
}
if _, _, _, err := normalizeAdminResourceQuery(AdminResourceQuery{UserQuery: strings.Repeat("x", 161)}); err == nil {
t.Fatal("expected oversized user filter to be rejected")
}
if _, err := svc.AdminStorageStats(user); err == nil {
t.Fatal("expected non-admin stats to be rejected")
}

View File

@@ -118,6 +118,10 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
if activeTasks >= int64(policy.Task.ActiveTaskLimit) {
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
}
// 媒体任务在扣费和调用上游前确认账号文件容量,避免容量已满仍然计费却无法回存产物。
if err := s.requireStoredFileCapacityForTask(userID, taskType, policy); err != nil {
return nil, err
}
task := model.Task{ID: newID(), UserID: userID, TraceID: req.TraceID, RequestID: req.RequestID, ProjectID: req.ProjectID, Type: taskType, Status: model.TaskStatusQueued, Stage: "等待队列调度", Progress: 5, Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: req.Model}
if req.admission != nil {
task.ID = req.admission.ID

View File

@@ -0,0 +1,44 @@
package app
import (
"fmt"
"strings"
)
// 产出媒体文件的任务类型:这些任务成功后必须把结果回存为资源文件。
// 文本任务只增长任务历史数据,不占用账号文件容量。
func taskTypeProducesStoredFile(taskType string) bool {
switch taskType {
case "canvas_image", "canvas_video", "canvas_audio":
return true
}
return strings.HasPrefix(taskType, "video_")
}
// requireStoredFileCapacityForTask 是生成任务 admission 阶段的账号文件容量校验。
//
// 账号文件容量此前只在产物回存时校验,上游调用已经发出并扣费,失败只能表现为
// 媒体无法入库。生成任务必须在扣费前确认账号还有可用容量,容量已满时直接拒绝。
func (s *Service) requireStoredFileCapacityForTask(userID string, taskType string, policy RuntimePolicySetting) error {
if !taskTypeProducesStoredFile(taskType) {
return nil
}
storedLimit := gigabytes(policy.Resource.StoredFileGB)
if storedLimit <= 0 {
return nil
}
s.storageMu.Lock()
defer s.storageMu.Unlock()
storedBytes, err := s.repo.UserStoredFileBytes(userID)
if err != nil {
return err
}
return validateStoredFileCapacity(storedBytes, s.pendingStorage[userID], storedLimit)
}
func validateStoredFileCapacity(storedBytes int64, pendingBytes int64, storedLimit int64) error {
if storedLimit > 0 && storedBytes+pendingBytes >= storedLimit {
return QuotaExceeded(fmt.Sprintf("账号文件容量已达到 %s 上限,请先清理素材后再生成", formatStorageLimit(storedLimit)))
}
return nil
}

View File

@@ -0,0 +1,45 @@
package app
import (
"strings"
"testing"
)
func TestTaskTypeProducesStoredFile(t *testing.T) {
for _, taskType := range []string{"canvas_image", "canvas_video", "canvas_audio", "video_generate"} {
if !taskTypeProducesStoredFile(taskType) {
t.Fatalf("taskTypeProducesStoredFile(%q) = false", taskType)
}
}
for _, taskType := range []string{"text", "canvas_text"} {
if taskTypeProducesStoredFile(taskType) {
t.Fatalf("taskTypeProducesStoredFile(%q) = true", taskType)
}
}
}
func TestValidateStoredFileCapacityRejectsFullAccount(t *testing.T) {
limit := gigabytes(defaultRuntimePolicy().Resource.StoredFileGB)
for _, test := range []struct {
name string
stored int64
pending int64
}{
{name: "stored files fill capacity", stored: limit},
{name: "pending upload fills capacity", stored: limit - 1, pending: 1},
} {
t.Run(test.name, func(t *testing.T) {
err := validateStoredFileCapacity(test.stored, test.pending, limit)
if err == nil || !strings.Contains(err.Error(), "账号文件容量已达到") {
t.Fatalf("validateStoredFileCapacity() error = %v", err)
}
})
}
}
func TestValidateStoredFileCapacityAllowsRemainingSpace(t *testing.T) {
limit := gigabytes(defaultRuntimePolicy().Resource.StoredFileGB)
if err := validateStoredFileCapacity(limit-2, 1, limit); err != nil {
t.Fatalf("validateStoredFileCapacity() error = %v", err)
}
}

View File

@@ -53,9 +53,13 @@ func RegisterAdminStorageRoutes(r *gin.RouterGroup, svc *service.Service) {
fail(c, http.StatusBadRequest, err)
return
}
userQuery := c.Query("user")
if userQuery == "" {
userQuery = c.Query("userId")
}
result, err := svc.AdminResourcePage(user, service.AdminResourceQuery{
Keyword: c.Query("keyword"), Kind: c.Query("kind"), Status: c.Query("status"),
Provider: c.Query("provider"), UserID: c.Query("userId"), Page: page, Limit: limit,
Provider: c.Query("provider"), UserQuery: userQuery, Page: page, Limit: limit,
})
if err != nil {
failService(c, err)

View File

@@ -7,13 +7,13 @@ import (
)
type AdminResourceFilter struct {
Kind string
Status string
Provider string
UserID string
Keyword string
Limit int
Offset int
Kind string
Status string
Provider string
UserQuery string
Keyword string
Limit int
Offset int
}
type ResourceKindStat struct {
@@ -52,8 +52,14 @@ func (r *Repository) AdminResources(filter AdminResourceFilter) ([]model.Resourc
if filter.Provider != "" {
query = query.Where(resourceProviderExpression()+" = ?", filter.Provider)
}
if filter.UserID != "" {
query = query.Where("user_id = ?", filter.UserID)
if filter.UserQuery != "" {
pattern := "%" + strings.ToLower(filter.UserQuery) + "%"
matchingUsers := r.db.Model(&model.User{}).Select("id").Where(
"lower(id) LIKE ? OR lower(username) LIKE ? OR lower(display_name) LIKE ? OR lower(email) LIKE ?",
pattern, pattern, pattern, pattern,
)
// 保留对已删除用户资源的 ID 搜索,同时支持按现存用户的账号信息筛选。
query = query.Where("(lower(user_id) LIKE ? OR user_id IN (?))", pattern, matchingUsers)
}
if filter.Keyword != "" {
pattern := "%" + strings.ToLower(filter.Keyword) + "%"