From a6e3f3cca66ba55c71d02fb7c0bd199e7d2c8044 Mon Sep 17 00:00:00 2001 From: Rou <76741805+Rou325@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:28:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(platform):=20=E5=AD=98=E5=82=A8=E4=B8=8E=20?= =?UTF-8?q?Agent=20-=20=E4=BF=AE=E5=A4=8D=E9=85=8D=E9=A2=9D=E5=87=86?= =?UTF-8?q?=E5=85=A5=E3=80=81Live2D=20=E4=B8=8E=E7=94=A8=E6=88=B7=E7=AD=9B?= =?UTF-8?q?=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/admin_storage.go | 30 ++++++------- backend/internal/app/admin_storage_test.go | 28 +++++++++++- backend/internal/app/task_creation.go | 4 ++ .../internal/app/task_storage_admission.go | 44 ++++++++++++++++++ .../app/task_storage_admission_test.go | 45 +++++++++++++++++++ backend/internal/handler/admin_storage.go | 6 ++- backend/internal/repository/admin_storage.go | 24 ++++++---- docs/content/docs/progress/pending-test.mdx | 23 ++++++++++ .../canvas/canvas-cloud-agent-panel-parts.tsx | 8 +++- .../canvas/canvas-cloud-agent-panel.tsx | 2 +- .../components/storage-resources-panel.tsx | 19 ++++---- web/src/services/api/admin-storage.ts | 1 + web/test/admin-ui-regressions.test.ts | 4 +- web/test/agent-live2d-lifecycle.test.ts | 12 +++++ 14 files changed, 211 insertions(+), 39 deletions(-) create mode 100644 backend/internal/app/task_storage_admission.go create mode 100644 backend/internal/app/task_storage_admission_test.go create mode 100644 web/test/agent-live2d-lifecycle.test.ts diff --git a/backend/internal/app/admin_storage.go b/backend/internal/app/admin_storage.go index da343e0b2..babd706ca 100644 --- a/backend/internal/app/admin_storage.go +++ b/backend/internal/app/admin_storage.go @@ -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 diff --git a/backend/internal/app/admin_storage_test.go b/backend/internal/app/admin_storage_test.go index e206bedcc..7f13d240d 100644 --- a/backend/internal/app/admin_storage_test.go +++ b/backend/internal/app/admin_storage_test.go @@ -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") } diff --git a/backend/internal/app/task_creation.go b/backend/internal/app/task_creation.go index 68bef92af..6614ae8a4 100644 --- a/backend/internal/app/task_creation.go +++ b/backend/internal/app/task_creation.go @@ -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 diff --git a/backend/internal/app/task_storage_admission.go b/backend/internal/app/task_storage_admission.go new file mode 100644 index 000000000..0051a7b29 --- /dev/null +++ b/backend/internal/app/task_storage_admission.go @@ -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 +} diff --git a/backend/internal/app/task_storage_admission_test.go b/backend/internal/app/task_storage_admission_test.go new file mode 100644 index 000000000..3466156a8 --- /dev/null +++ b/backend/internal/app/task_storage_admission_test.go @@ -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) + } +} diff --git a/backend/internal/handler/admin_storage.go b/backend/internal/handler/admin_storage.go index 3ba552d2e..4689323c3 100644 --- a/backend/internal/handler/admin_storage.go +++ b/backend/internal/handler/admin_storage.go @@ -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) diff --git a/backend/internal/repository/admin_storage.go b/backend/internal/repository/admin_storage.go index 1483d2fdf..0ce2d2550 100644 --- a/backend/internal/repository/admin_storage.go +++ b/backend/internal/repository/admin_storage.go @@ -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) + "%" diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 4e4201aeb..497078ecc 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -2828,3 +2828,26 @@ SSE 现在按 revision 变化发送紧凑快照,无变化只 heartbeat;客 画布角色卡仍为 `text` 节点,但角色设定由项目角色资产按当前版或固定版读取;Agent 的节点目录标注 `kind: character`,精读返回角色设定、形象/声音状态及图片参考就绪情况,不再把空 `content` 当作空角色卡。选择角色卡作为媒体参考时,后端从同一角色版本解析三视图图片,并把角色设定加入本次生成提示词;只选 `sourceNodeId` 则使用文字设定。审批期间角色版本变化会阻止旧输入提交。生成仍需用户确认或符合 auto 准入,不在读取时发起付费任务。 待登录态页面验收:对「扶颦」精读应返回实际外貌和服装;选择该卡生成时,审批/任务输入应同时包含该版设定与已绑定三视图;故意改动角色版本后旧审批不能提交。声音档案可精读,音频任务在未显式选择音色时采用角色绑定的 voiceKey 与声音指令;跨模型适配及真实音色效果仍需单独验收。此轮不代表生产已部署。 + + +## 存储满额生成拦截与 Live2D 开关稳定性(2026-09-30) + +- 已修复:图片、视频、音频生成任务在创建、积分预留和上游调用之前检查账号文件容量;容量已满时返回配额错误并提示先清理素材,文本任务不受文件容量限制。普通画布与 Agent 媒体生成共用该 admission 边界。 +- 已修复:Agent 面板开合时保留 Live2D 入口组件和已加载模型,仅隐藏入口并暂停不可见渲染,不再因反复卸载、重建 PIXI/Live2D 上下文而短暂回退默认动态球。 +- 自动验证:后端纯 admission 专项测试通过(CGO 关闭,3 项);前端 Live2D 生命周期专项测试通过;前端生产构建通过。既有 Agent 位置测试在当前 Windows 环境因 Bun 将磁盘路径解析为 `/E:/...` 而未进入测试逻辑。 + +仍待真实环境验收: + +- [ ] 将测试账号文件容量填满后,从普通画布和 Agent 分别提交图片、视频、音频生成,确认立即显示容量提示、没有创建计费订单、没有调用上游;清理出容量后可恢复生成。 +- [ ] 使用自定义 Live2D 模型连续开合 Agent 面板,确认不出现默认动态球闪回,拖动、窗口缩放和重新进入画布仍正常。 + + +## 后台存储资源用户筛选(2026-09-30) + +- 已修复:后台存储资源列表的用户筛选不再只接受完整用户 ID;现在支持按用户 ID、用户名、昵称和邮箱进行不区分大小写的模糊匹配,并可与资源类型、状态、存储位置、资源关键词组合使用。 +- API 新增可读性更高的 `user` 查询参数,后端继续兼容旧 `userId` 参数;前端请求同时发送两者,避免滚动升级期间旧后端忽略新参数并错误返回全量资源。 +- 自动验证:后台 UI 回归 17 项通过;前端生产构建通过;后端参数规范化专项通过。SQLite 集成用例已补充四种用户字段匹配,但当前 Windows 本机 CGO/GCC 无法解析生成的对象文件,未在本机实际执行该数据库用例。 + +仍待真实环境验收: + +- [ ] 在管理员后台存储资源页分别输入完整/部分用户名、昵称、邮箱和用户 ID,确认返回同一用户资源;再叠加类型、状态和存储位置筛选,确认分页与清空筛选正常。 diff --git a/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx b/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx index 9ad39a11d..5b8d41847 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx @@ -32,7 +32,9 @@ import { CanvasAgentImageApprovalSettings } from "./canvas-agent-image-approval- import { markdownPlainText } from "@/lib/markdown-plain-text"; import type { ApprovalState } from "./canvas-cloud-agent-events"; -export function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onOpen }: { theme: CanvasTheme; statusColor: string; approvalPending: boolean; reducedMotion: boolean; onOpen: () => void }) { +// hidden 为 true 时入口保持挂载但不显示:Live2D 模型加载开销大,面板开合不能卸载它, +// 否则每次重新挂载都要重新下载模型并重建渲染上下文,期间只能显示默认形象。 +export function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, hidden = false, onOpen }: { theme: CanvasTheme; statusColor: string; approvalPending: boolean; reducedMotion: boolean; hidden?: boolean; onOpen: () => void }) { const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE; const live = appearance.avatarType === "live2d" && Boolean(appearance.live2dResourceId && appearance.live2dEntry); const [viewport, setViewport] = useState(() => ({ width: window.innerWidth, height: window.innerHeight })); @@ -50,7 +52,9 @@ export function AgentLauncher({ theme, statusColor, approvalPending, reducedMoti aria-label={`打开${appearance.agentName}`} title={`${approvalPending ? "Agent 等待你的审批" : "打开 Agent 助手"} · 拖动可调整位置,聚焦后可用方向键移动`} className={cn("canvas-agent-launcher fixed z-[calc(var(--z-toast)+1)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35", dragging && "is-dragging", live && "canvas-agent-launcher-live2d")} - style={{ ...position, width, height, color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow } as CSSProperties} + style={{ ...position, width, height, color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow, ...(hidden ? { display: "none" } : null) } as CSSProperties} + aria-hidden={hidden || undefined} + tabIndex={hidden ? -1 : undefined} data-canvas-no-zoom {...handlers} whileHover={reducedMotion || dragging ? undefined : { scale: 1.035 }} diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx index 0539cebb8..b9bb7ba1c 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx @@ -857,7 +857,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se return ( <> - {!open ? : null} +