diff --git a/design-research-folder-card.md b/design-research-folder-card.md deleted file mode 100644 index 83509d30f..000000000 --- a/design-research-folder-card.md +++ /dev/null @@ -1,655 +0,0 @@ -# 画布列表卡片设计调研报告 - -## 一、图片风格分析 - -### 核心设计元素识别 - -通过对提供的文件夹卡片图片分析,识别出以下关键设计特征: - -#### 1. **玻璃拟态(Glassmorphism)效果** -- 半透明背景:使用 `backdrop-filter: blur()` 实现背景模糊 -- 磨砂玻璃质感:白色半透明叠加层(rgba(255,255,255,0.1) ~ 0.15) -- 边框高光:顶部和左侧有微妙的白色边框(1px solid rgba(255,255,255,0.18)) - -#### 2. **渐变网格背景(Gradient Mesh)** -- 每个卡片顶部有独特的渐变色带(红橙、蓝青、橙红等色系) -- 渐变呈现流动的、非线性的网格状分布 -- 类似 macOS Ventura 的壁纸风格,具有深度和立体感 -- 渐变从顶部向下过渡到白色/透明 - -#### 3. **多层阴影系统** -- **主阴影**:较大的扩散阴影 `0 8px 32px rgba(0,0,0,0.12)` -- **环境阴影**:模拟物理深度 `0 2px 8px rgba(0,0,0,0.06)` -- **内阴影**:卡片内部微妙的阴影增强立体感 -- 悬停时阴影加深并向上提升:`transform: translateY(-4px)` - -#### 4. **圆角处理** -- 大圆角设计:约 `16px - 20px` (r-2xl ~ r-3xl) -- 所有元素保持一致的圆角系统 -- 内部按钮使用更小的圆角(8-12px) - -#### 5. **布局结构** -- **三态展示**: - 1. 正常态(左上):紧凑文件夹样式 + 悬浮按钮 - 2. 堆叠态(右侧):多层卡片堆叠效果,展示深度 - 3. 展开态(左下):完整信息展示 + 时间戳 - -#### 6. **交互反馈** -- 悬停提升效果(Y轴 -4px) -- 鼠标跟随的光泽效果(Glare) -- 3D 倾斜动画(轻微的 rotateX/rotateY) -- 按钮悬浮显示/隐藏 - ---- - -## 二、当前项目状态分析 - -### 现有组件架构 - -#### 1. **CanvasProjectCard** (`canvas-project-card.tsx`) -```typescript -// 当前结构 -
-
- - 复选框 -
节点数量
-
-
-

标题

-
时间/类型
-
-
-``` - -**当前特点**: -- 扁平化设计,缺少层次感 -- 简单的边框和阴影 -- 预览区域占比大(aspect-video) -- 无玻璃拟态效果 -- 无渐变背景装饰 - -#### 2. **CometCard** (`comet-card.tsx`) -**已有能力**: -- ✅ 3D 倾斜动画(rotateX/rotateY) -- ✅ 鼠标跟随效果 -- ✅ 光泽层(glare) -- ✅ 悬停提升动画 -- ✅ 平滑的弹簧动画 - -**缺少**: -- ❌ 玻璃拟态背景 -- ❌ 渐变网格装饰 -- ❌ 多层阴影系统 - ---- - -## 三、Aceternity UI 调研 - -### 相关组件清单 - -#### 1. **3D Card Effect** -- 组件路径:`/components/3d-card-effect` -- 功能:鼠标跟随的3D倾斜效果 -- 与 CometCard 功能重叠,我们已有类似实现 - -#### 2. **Card Hover Effect** -- 组件路径:`/components/card-hover-effect` -- 功能:悬停时的边框光晕和背景渐变 -- 可借鉴:边框渐变的实现方式 - -#### 3. **Background Gradient** -- 组件路径:`/components/background-gradient` -- 功能:动态渐变背景 -- **重要**:可用于卡片顶部的渐变网格效果 - -#### 4. **Meteors** -- 组件路径:`/components/meteors` -- 功能:流星划过的动画效果 -- 可选:为卡片增加动态装饰 - -#### 5. **Glass Morphism** -- 虽然 Aceternity 没有专门的玻璃拟态组件 -- 但在多个卡片组件中使用了 `backdrop-blur` 和半透明背景 -- 需要自行实现 - ---- - -## 四、设计方案 - -### 方案 A:渐进式升级(推荐) - -**目标**:在现有 CanvasProjectCard 基础上,逐步添加玻璃拟态和渐变效果。 - -#### 4.1 布局设计 - -**网格布局**: -```css -/* 响应式网格 */ -.canvas-library-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: var(--space-6); /* 24px */ - padding: var(--space-6); -} - -/* 断点优化 */ -@media (min-width: 640px) { - /* 2列:640px - 1024px */ - grid-template-columns: repeat(2, 1fr); -} - -@media (min-width: 1024px) { - /* 3列:1024px - 1440px */ - grid-template-columns: repeat(3, 1fr); -} - -@media (min-width: 1440px) { - /* 4列:>1440px */ - grid-template-columns: repeat(4, 1fr); -} -``` - -**每行显示数量**: -- 移动端(<640px):1列(自适应宽度) -- 平板(640-1024px):2列 -- 桌面(1024-1440px):3列 -- 大屏(>1440px):4列 - -#### 4.2 卡片尺寸 - -```typescript -// 卡片结构 -
- // 宽高比:16:10 (比现有的16:9更接近文件夹比例) - // 最小宽度:280px - // 最大宽度:360px(防止在大屏上过度拉伸) -
-``` - -**尺寸规范**: -- **宽度**:280-360px(由网格自动控制) -- **高度**:自适应(由内容决定) -- **预览区高度**:aspect-[16/10] (~175-225px) -- **内容区高度**:~80px(标题 + 元信息) - -#### 4.3 视觉效果分层 - -``` -[层级从底到顶] -├─ 1. 基础背景(白色/暗色,根据主题) -├─ 2. 渐变网格层(顶部装饰) -├─ 3. 玻璃拟态层(backdrop-blur + 半透明) -├─ 4. 内容层(文字、图标、预览) -├─ 5. 边框高光层(顶部和左侧) -└─ 6. 光泽层(hover时的glare效果) -``` - ---- - -## 五、CSS 实现方案 - -### 5.1 玻璃拟态基础样式 - -```css -.canvas-card-glass { - position: relative; - background: rgba(255, 255, 255, 0.08); - backdrop-filter: blur(12px) saturate(180%); - border: 1px solid rgba(255, 255, 255, 0.18); - border-radius: var(--r-2xl); /* 16px */ - box-shadow: - 0 8px 32px rgba(0, 0, 0, 0.12), - 0 2px 8px rgba(0, 0, 0, 0.06), - inset 0 1px 0 rgba(255, 255, 255, 0.1); - transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), - box-shadow 0.3s ease; -} - -/* 暗色主题 */ -.dark .canvas-card-glass { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.12); - box-shadow: - 0 8px 32px rgba(0, 0, 0, 0.4), - 0 2px 8px rgba(0, 0, 0, 0.2), - inset 0 1px 0 rgba(255, 255, 255, 0.08); -} - -.canvas-card-glass:hover { - transform: translateY(-4px); - box-shadow: - 0 16px 48px rgba(0, 0, 0, 0.18), - 0 4px 12px rgba(0, 0, 0, 0.1), - inset 0 1px 0 rgba(255, 255, 255, 0.15); -} -``` - -### 5.2 渐变网格装饰 - -```css -.canvas-card-gradient-mesh { - position: absolute; - top: 0; - left: 0; - right: 0; - height: 120px; - border-radius: var(--r-2xl) var(--r-2xl) 0 0; - overflow: hidden; - pointer-events: none; - z-index: 0; -} - -.canvas-card-gradient-mesh::before { - content: ''; - position: absolute; - inset: 0; - background: - radial-gradient(circle at 20% 30%, rgba(255, 100, 80, 0.6) 0%, transparent 50%), - radial-gradient(circle at 80% 20%, rgba(100, 180, 255, 0.5) 0%, transparent 50%), - radial-gradient(circle at 50% 70%, rgba(255, 180, 100, 0.4) 0%, transparent 60%); - filter: blur(40px); - opacity: 0.8; -} - -.canvas-card-gradient-mesh::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(180deg, - rgba(255,255,255,0) 0%, - rgba(255,255,255,0.95) 80% - ); -} - -/* 每张卡片随机不同的渐变色 */ -.canvas-card-gradient-mesh[data-variant="1"]::before { - background: - radial-gradient(circle at 25% 25%, rgba(255, 50, 100, 0.7) 0%, transparent 50%), - radial-gradient(circle at 75% 30%, rgba(80, 120, 255, 0.6) 0%, transparent 50%); -} - -.canvas-card-gradient-mesh[data-variant="2"]::before { - background: - radial-gradient(circle at 30% 20%, rgba(0, 200, 180, 0.6) 0%, transparent 50%), - radial-gradient(circle at 70% 40%, rgba(100, 255, 150, 0.5) 0%, transparent 50%); -} - -.canvas-card-gradient-mesh[data-variant="3"]::before { - background: - radial-gradient(circle at 20% 35%, rgba(255, 150, 0, 0.7) 0%, transparent 50%), - radial-gradient(circle at 80% 25%, rgba(200, 50, 80, 0.6) 0%, transparent 50%); -} -``` - -### 5.3 完整卡片样式 - -```css -.canvas-card-folder { - position: relative; - width: 100%; - height: 100%; - min-height: 280px; - cursor: pointer; - isolation: isolate; -} - -.canvas-card-folder-inner { - position: relative; - width: 100%; - height: 100%; - background: rgba(255, 255, 255, 0.08); - backdrop-filter: blur(12px) saturate(180%); - border: 1px solid rgba(255, 255, 255, 0.18); - border-radius: var(--r-2xl); - overflow: hidden; - box-shadow: - 0 8px 32px rgba(0, 0, 0, 0.12), - 0 2px 8px rgba(0, 0, 0, 0.06); - transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); -} - -.canvas-card-folder:hover .canvas-card-folder-inner { - transform: translateY(-4px) scale(1.01); - box-shadow: - 0 16px 48px rgba(0, 0, 0, 0.18), - 0 4px 12px rgba(0, 0, 0, 0.1); - border-color: rgba(255, 255, 255, 0.25); -} - -/* 预览区域 */ -.canvas-card-preview { - position: relative; - width: 100%; - aspect-ratio: 16 / 10; - overflow: hidden; - z-index: 1; -} - -.canvas-card-preview img, -.canvas-card-preview video { - width: 100%; - height: 100%; - object-fit: cover; - transition: transform 0.3s ease; -} - -.canvas-card-folder:hover .canvas-card-preview img, -.canvas-card-folder:hover .canvas-card-preview video { - transform: scale(1.05); -} - -/* 内容区域 */ -.canvas-card-content { - position: relative; - padding: var(--space-4); - z-index: 1; -} - -.canvas-card-title { - font-size: var(--fs-body); - font-weight: 600; - line-height: 1.4; - color: var(--color-neutral-900); - margin-bottom: var(--space-2); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.dark .canvas-card-title { - color: var(--color-neutral-50); -} - -.canvas-card-meta { - display: flex; - align-items: center; - gap: var(--space-2); - font-size: var(--fs-caption); - color: var(--color-neutral-600); -} - -.dark .canvas-card-meta { - color: var(--color-neutral-400); -} - -/* 悬浮按钮 */ -.canvas-card-action-button { - position: absolute; - bottom: var(--space-3); - right: var(--space-3); - width: 40px; - height: 40px; - display: grid; - place-items: center; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(8px); - border-radius: var(--r-lg); - color: white; - opacity: 0; - transform: translateY(8px); - transition: all 0.25s ease; - z-index: 10; -} - -.canvas-card-folder:hover .canvas-card-action-button { - opacity: 1; - transform: translateY(0); -} - -.canvas-card-action-button:hover { - background: rgba(0, 0, 0, 0.8); - transform: scale(1.1); -} -``` - ---- - -## 六、组件实现方案 - -### 6.1 新建组件:CanvasFolderCard - -```tsx -// web/src/components/canvas/canvas-folder-card.tsx - -import { motion } from "motion/react"; -import { Plus, MoreHorizontal } from "lucide-react"; -import { CometCard } from "@/components/ui/aceternity/comet-card"; -import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; - -interface CanvasFolderCardProps { - project: CanvasProject; - variant?: number; // 1-3,决定渐变色方案 - onClick?: () => void; - onAction?: () => void; -} - -export function CanvasFolderCard({ - project, - variant = 1, - onClick, - onAction -}: CanvasFolderCardProps) { - return ( - -
- {/* 渐变网格装饰 */} - - - ); -} - -// 预览组件保持原有逻辑 -function ProjectPreview({ project }: { project: CanvasProject }) { - // ... 复用现有的 ProjectPreview 逻辑 -} - -function formatTime(value: string) { - // ... 复用现有的时间格式化逻辑 -} -``` - -### 6.2 集成到现有页面 - -```tsx -// 在 pages/home/index.tsx 或 pages/canvas/index.tsx 中 - -import { CanvasFolderCard } from "@/components/canvas/canvas-folder-card"; - -// 替换现有的 CanvasProjectCard -
- {canvasProjects.map((project, index) => ( - navigate(`/canvas/${project.id}`)} - onAction={() => handleMore(project)} - /> - ))} -
-``` - ---- - -## 七、技术要点 - -### 7.1 性能优化 - -```typescript -// 1. 渐变色方案预计算 -const GRADIENT_VARIANTS = [ - { id: 1, colors: ['rgba(255,50,100,0.7)', 'rgba(80,120,255,0.6)'] }, - { id: 2, colors: ['rgba(0,200,180,0.6)', 'rgba(100,255,150,0.5)'] }, - { id: 3, colors: ['rgba(255,150,0,0.7)', 'rgba(200,50,80,0.6)'] }, -]; - -// 2. 使用 CSS 变量动态注入 -
- -// 3. backdrop-filter 回退方案 -@supports not (backdrop-filter: blur(12px)) { - .canvas-card-folder-inner { - background: rgba(255, 255, 255, 0.95); /* 完全不透明 */ - } -} -``` - -### 7.2 无障碍支持 - -```tsx -// 完整的 ARIA 标签 -
e.key === 'Enter' && onClick?.()} -> - {/* ... */} -
-``` - -### 7.3 暗色主题适配 - -```css -/* 自动适配当前主题 */ -@media (prefers-color-scheme: dark) { - .canvas-card-folder-inner { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.12); - } - - .canvas-card-gradient-mesh::after { - background: linear-gradient(180deg, - rgba(10, 10, 15, 0) 0%, - rgba(10, 10, 15, 0.95) 80% - ); - } -} -``` - ---- - -## 八、实施建议 - -### 阶段 1:基础重构(1-2天) -1. ✅ 创建 `CanvasFolderCard` 组件 -2. ✅ 实现玻璃拟态样式 -3. ✅ 集成 `CometCard` 实现3D效果 -4. ✅ 调整网格布局为新的响应式方案 - -### 阶段 2:视觉增强(2-3天) -1. ✅ 添加渐变网格装饰层 -2. ✅ 实现多层阴影系统 -3. ✅ 优化悬停动画和过渡 -4. ✅ 添加光泽层(glare effect) - -### 阶段 3:细节打磨(1-2天) -1. ✅ 暗色主题适配 -2. ✅ 无障碍测试和优化 -3. ✅ 性能测试(backdrop-filter 的性能影响) -4. ✅ 跨浏览器兼容性测试 - -### 阶段 4:用户测试(1天) -1. ✅ A/B 测试(新旧卡片对比) -2. ✅ 收集用户反馈 -3. ✅ 根据反馈微调 - ---- - -## 九、风险和注意事项 - -### 9.1 性能风险 -- **backdrop-filter 性能消耗**:在大量卡片(50+)时可能影响滚动性能 - - 解决方案:使用虚拟滚动或懒加载 - - 回退方案:低性能设备禁用 backdrop-filter - -### 9.2 兼容性 -- **Safari < 15**:backdrop-filter 支持不完整 - - 解决方案:检测支持度,提供回退样式 - -### 9.3 可访问性 -- 确保玻璃拟态效果不影响文字对比度(WCAG AA 标准:4.5:1) -- 光泽效果不应干扰屏幕阅读器 - ---- - -## 十、参考资源 - -### 设计灵感 -- macOS Ventura 文件夹设计 -- iOS 16+ 小组件玻璃拟态 -- Figma Community: Glassmorphism UI Kit - -### 技术文档 -- [MDN: backdrop-filter](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter) -- [CSS Tricks: Glassmorphism](https://css-tricks.com/glassmorphism/) -- [Aceternity UI Components](https://ui.aceternity.com/components) - -### 现有代码 -- `/web/src/components/canvas/canvas-project-card.tsx` -- `/web/src/components/ui/aceternity/comet-card.tsx` -- `/web/src/styles/globals.css` (token system) - ---- - -## 总结 - -这套设计方案结合了: -1. **玻璃拟态**的现代感和层次感 -2. **渐变网格**的视觉吸引力和品牌差异化 -3. **3D交互**的沉浸感(已有CometCard支持) -4. **响应式布局**的灵活性(1-4列自适应) - -建议采用**渐进式升级**策略,先实现核心玻璃拟态效果,再逐步添加渐变装饰和高级交互,确保每个阶段都能交付可用版本。 diff --git a/logical-model-routing-code-review.md b/logical-model-routing-code-review.md deleted file mode 100644 index c73623a69..000000000 --- a/logical-model-routing-code-review.md +++ /dev/null @@ -1,1146 +0,0 @@ -# 逻辑模型路由层代码评审报告 - -## 评审范围 - -**评审对象**:GPT 编写的"逻辑模型路由层"实现 -**评审时间**:2026-08-18 -**评审方法**:逐行代码审核 + 市场调研(论文、GitHub 项目、技术论坛) -**评审原则**:从产品可用性、用户便利性角度,基于证据判断,不接受设计声明 - -## 核心发现 - -本次评审发现 **3 个 P0 严重问题** 和 **3 个 P1 重要问题**,均有生产案例或官方文档支撑。 - -GPT 在以下方面**偷懒或实现失真**: -1. 用应用层 `SELECT MAX + 1` 而非数据库原子递增 -2. 用内存 map 而非 Redis 做分布式状态 -3. 直接 for 循环查询而非 Preload 批量加载 -4. 用不稳定的 JSON 序列化做去重 key - -修复后系统才能安全地水平扩展。 - ---- - -## P0 严重问题 - -### [P0-1] `MAX(version)+1` 并发竞态导致唯一索引冲突 - -**位置**:`backend/internal/repository/logical_models.go:311-316` - -**现象**: -```go -func (r *Repository) SaveLogicalModelBundle(...) error { - return r.db.Transaction(func(tx *gorm.DB) error { - var latest struct{ Version int } - if err := tx.Model(&model.LogicalModelRevision{}). - Select("COALESCE(MAX(version), 0) AS version"). - Where("logical_model_id = ?", item.ID).Scan(&latest).Error; err != nil { - return err - } - revision.Version = latest.Version + 1 // ← 竞态窗口 - if err := tx.Create(revision).Error; err != nil { - return err - } -``` - -**危害时序**: -1. 请求A 读到 MAX=7,计算 next=8 -2. 请求B 读到 MAX=7(A 还没提交),计算 next=8 -3. A 先 INSERT version=8 成功 -4. B 再 INSERT version=8 → **违反 `uniqueIndex:idx_logical_revision_version`,事务回滚** - -这是典型的 TOCTOU(Time-of-Check to Time-of-Use)竞态条件。 - -**证据**: -- [DEV Community: SELECT MAX()+1 Is a Race Condition Waiting to Happen](https://dev.to/mattia_armas/select-max1-is-a-race-condition-waiting-to-happen-22m2) - > "两个请求读到同一个 max,都计算同一个 next,唯一索引拒绝第二个。应用层锁在多实例下无效。" - -- [StackOverflow: INSERT INTO ... SELECT MAX + 1 returns DUPLICATE KEY](https://stackoverflow.com/questions/59756571) - > "Use SERIAL / sequence,或者 `UPDATE counter SET c=c+1 RETURNING c` 原子递增。" - -**修复方向**: -```sql --- 方案1:用数据库原子递增列(推荐) -ALTER TABLE logical_models ADD COLUMN next_revision_version INT DEFAULT 1; - -UPDATE logical_models SET next_revision_version = next_revision_version + 1 -WHERE id = ? RETURNING next_revision_version; --- 拿到返回值赋给 revision.Version - --- 方案2:行级锁(如果必须 per-model gapless) -SELECT version FROM logical_model_revisions -WHERE logical_model_id = ? -FOR UPDATE; -- 锁住该模型的所有版本行,阻塞并发 -``` - ---- - -### [P0-2] RWMutex 读锁内执行写操作(删除 map 元素) - -**位置**:`backend/internal/service/model_router.go:554-570` - -**现象**: -```go -func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool { - s.routeHealthMu.Lock() - defer s.routeHealthMu.Unlock() - for _, key := range keys { - until, exists := s.routeHealthBlocked[key] - if !exists { continue } - if !until.After(now) { - delete(s.routeHealthBlocked, key) // ← 删除操作 - continue - } - return true - } -} -``` - -**问题分析**: - -代码实际使用了 `Lock()`(写锁),这是**正确的**。但函数逻辑是"读取判断 + 条件删除",属于典型的"需要写锁但容易误用读锁"场景。 - -**潜在风险**: - -函数命名和注释没有体现"会修改 map",容易被后续维护者改成 `RLock()` 优化读性能,从而引入 data race。 - -**证据**: -- [StackOverflow: concurrent map iteration and map write with RLock/RUnlock](https://stackoverflow.com/questions/71354301) - > "RLock 只能读,任何写操作(包括 delete)必须用 Lock。map 的 delete 是写操作。" - -- [Go sync 官方文档](https://pkg.go.dev/sync#RWMutex) - > "A RWMutex.RLock cannot be upgraded into a RWMutex.Lock" - > 读锁无法升级为写锁,必须释放后重新获取。 - -**修复方向**: -1. 重命名函数:`logicalRouteBlockedAndPrune()` 明确"会删除过期项" -2. 加注释:`// MUST hold write lock: prunes expired entries from routeHealthBlocked` -3. 确保团队 review 时注意此模式 - ---- - -### [P0-3] 单实例内存缓存在多实例部署下失效 - -**位置**:`backend/internal/service/service.go:39-45` + `model_router.go:410-448` - -**现象**: -```go -type Service struct { - routeCatalog *routeCatalogSnapshot // 单实例内存 - routeHealthBlocked map[string]time.Time // 单实例内存 -} - -func (s *Service) logicalRouteBlocked(...) bool { - s.routeHealthMu.Lock() - // 只查本实例的 routeHealthBlocked -} -``` - -**危害场景**: - -| 场景 | 问题 | 用户体验 | -|------|------|---------| -| **429 冷却不生效** | 实例A 标记渠道冷却,实例B 不知道,继续向该渠道发请求 | 触发更多 429,用户请求失败率上升 | -| **路由缓存不一致** | 管理员在实例A 更新模型配置,实例B 的 routeCatalog 30秒内不刷新 | 用户看到不同实例返回不同可用模型 | -| **故障切换重复尝试** | 实例A 刚把 variant 标记为 blocked,实例B 立即分配请求到该 variant | 用户请求被分配到已知故障路由 | - -**证据**: -- [Viktar Patotski: Redis Caching vs Local Cache](https://patotski.com/blog/redis-caching-vs-local-cache/) - > "本地缓存 + 水平扩展 = N 个分歧的缓存。用户刷新页面,负载均衡分到不同实例,数据消失又出现。多实例下必须用 Redis 共享状态。" - -- [WayGate 文档: Distributed Deployments](https://attakay78.github.io/waygate/guides/distributed/) - > "MemoryBackend: 实例A 禁用路由,实例B 继续服务该路由。FileBackend: 没有跨进程同步。RedisBackend: 所有实例通过 pub/sub 实时看到变更。" - -**修复方向**: - -```go -// 1. service.go 增加 Redis 连接 -type Service struct { - redis *redis.Client - // ... -} - -// 2. model_router.go logicalRouteBlocked 改为 Redis 查询 -func (s *Service) logicalRouteBlocked(route) bool { - key := "route_health:" + route.ChannelModel.ChannelID - until, err := s.redis.Get(ctx, key).Time() - if err != nil { return false } - return time.Now().Before(until) -} - -// 3. blockLogicalRouteForFailure 改为 Redis 写入 -func (s *Service) blockLogicalRouteForFailure(...) { - key := "route_health:" + attempt.ChannelID - s.redis.SetEx(ctx, key, time.Now().Add(duration).Format(time.RFC3339), duration) -} - -// 4. invalidateRouteCatalog 改为 Redis Pub/Sub -func (s *Service) invalidateRouteCatalog(item) { - s.redis.Publish(ctx, "route_catalog_invalidate", item.ID) - // 每个实例订阅该 channel,收到后清空本地缓存 -} -``` - ---- - -## P1 重要问题 - -### [P1-1] GORM N+1 查询导致管理员列表接口慢 - -**位置**:`backend/internal/service/logical_models.go:179-230` - -**现象**: -```go -func (s *Service) AdminLogicalModels(actor *model.User) ([]AdminLogicalModel, error) { - items, _ := s.repo.LogicalModels(true) // 1 query - for _, item := range items { - graph, _ := s.repo.LogicalModelGraph(item.ID, true) // N queries - for _, route := range graph.Routes { - _, channelErr := s.repo.SystemChannel(channelModel.ChannelID) // N*M queries - } - } -} -``` - -**性能计算**: -- 10 个模型,每个 5 条路由 → 1 + 10 + 50 = **61 条 SQL** -- 100 个模型,每个平均 5 条路由 → 1 + 100 + 500 = **601 条 SQL** - -**证据**: -- [Shinagawa Labs: Speeding Up Search Responses (Eliminating N+1)](https://shinagawa-web.com/en/works/go-gorm-n-plus-one-b2b-ticketing) - > "列表API 1.4s→0.2s (7x 提速),减少 80% 查询。用 Preload 批量获取,用 IN clause 处理复杂条件。" - -- [GORM 文档: Preloading](https://gorm.io/docs/preload.html) - > `db.Preload("Orders").Find(&users)` → 2 条 SQL 代替 1+N - -**修复方向**: - -```go -// 1. repository/logical_models.go 增加预加载方法 -func (r *Repository) LogicalModelsWithPreload() ([]model.LogicalModel, error) { - var items []model.LogicalModel - err := r.db.Preload("ActiveRevision"). - Preload("ActiveRevision.Routes", func(db *gorm.DB) *gorm.DB { - return db.Order("priority desc") - }). - Preload("ActiveRevision.Routes.Variant"). - Preload("ActiveRevision.Routes.Variant.ChannelModel"). - Order("sort_order asc, created_at asc"). - Find(&items).Error - return items, err -} - -// 2. service/logical_models.go AdminLogicalModels 改为 -items, _ := s.repo.LogicalModelsWithPreload() -// 然后直接用 item.ActiveRevision.Routes 遍历,不再单独查询 - -// 3. SystemChannel 查询改为批量 -channelIDs := collectUniqueChannelIDs(items) -channels, _ := s.repo.SystemChannelsByIDs(channelIDs) // 1 条 SQL with IN -channelMap := buildChannelMap(channels) -// 后续用 channelMap[id] 查询 - -// 最终:5 条 SQL 代替 61 条 -``` - ---- - -### [P1-2] `json.Marshal` 做 map key 不稳定 - -**位置**:`backend/internal/service/logical_models.go:161-176` - -**现象**: -```go -func publicLogicalModel(...) PublicLogicalModel { - seen := make(map[string]bool) - for _, route := range cached.Routes { - encoded, _ := json.Marshal(route.VariantSpec) // map 序列化顺序不确定 - key := string(encoded) - if !seen[key] { ... } - } -} -``` - -**危害**: -- Go 1.24 前 `encoding/json` 的 map key 按字典序排序(稳定) -- Go 1.24+ `encoding/json/v2` 默认非确定性排序 -- 两个能力相同的 spec 可能因 key 顺序不同生成不同 JSON -- 去重失败 → 用户看到重复的 `capabilityProfiles` - -**证据**: -- [Go encoding/json 文档](https://pkg.go.dev/encoding/json) - > "In v1, Go map marshaled deterministically. In v2, non-deterministic by default. Use jsonv2.Deterministic." - -- [StackOverflow: How to produce JSON with sorted keys](https://stackoverflow.com/questions/18668652) - > "json.Marshal 对 map key 排序,但 v2 改了默认行为。" - -**修复方向**: - -```go -// 方案1:用结构化 hash(推荐) -func capabilitySpecFingerprint(spec CapabilitySpec) string { - var buf strings.Builder - buf.WriteString(spec.Capability) - buf.WriteString("|") - - // operations 排序 - ops := make([]string, len(spec.Operations)) - copy(ops, spec.Operations) - sort.Strings(ops) - buf.WriteString(strings.Join(ops, ",")) - buf.WriteString("|") - - // inputs 按 key 排序 - inputKeys := make([]string, 0, len(spec.Inputs)) - for k := range spec.Inputs { - inputKeys = append(inputKeys, k) - } - sort.Strings(inputKeys) - for _, k := range inputKeys { - fmt.Fprintf(&buf, "%s:%d-%d,", k, spec.Inputs[k].Min, spec.Inputs[k].Max) - } - buf.WriteString("|") - - // options 按 key 排序(类似处理) - // ... - - return buf.String() -} - -// publicLogicalModel 中替换 -key := capabilitySpecFingerprint(route.VariantSpec) -if !seen[key] { ... } -``` - ---- - -### [P1-3] prefixed ID `size:36` 设计浪费 + 可能溢出 - -**位置**:`backend/internal/model/models_logical_model.go:14-15` - -**现象**: -```go -type LogicalModel struct { - ID string `gorm:"primaryKey;size:36"` // 存 "LMODEL_000001",但按 UUID(36字符) 设计 -} -``` - -**问题分析**: -1. `LMODEL_000001` 只有 **14 字符** -2. `size:36` 在 PostgreSQL 是 `VARCHAR(36)`,**浪费 22 字节索引空间** -3. 6 位数字最多 999,999 条记录 -4. 之后 `LMODEL_1000000` (15字符) 超长但不会报错(VARCHAR 自动扩展) -5. 导致**主键长度不一致**,索引效率下降 - -**证据**: -- [Tarazevits.io: Prefix-UUID for Postgres Primary Keys](https://blog.tarazevits.io/prefix-uuid-postgres-primary-keys/) - > "text 比 uuid 多 20 字节。高吞吐场景有影响,但 OLTP 可接受。重点是外键会倍增空间。" - -- [StackOverflow: VARCHAR as ID issues](https://stackoverflow.com/questions/50728032) - > "VARCHAR 占更多空间,索引开销高,页分裂多,可能变更(级联更新糟糕)。建议自增整数主键 + unique index。" - -**修复方向**: - -```go -// 方案1:改 size 匹配实际长度 -ID string `gorm:"primaryKey;size:20"` // LMODEL_ (7) + 最多13位数字预留 - -// 方案2:用真正的 prefix-UUID(推荐,全局唯一) -ID string `gorm:"primaryKey;size:48;default:prefixed_uuid('LMODEL')"` -// 格式:LMODEL_550e8400-e29b-41d4-a716-446655440000 - -// 方案3:分离 display ID(最优架构) -ID int64 `gorm:"primaryKey;autoIncrement"` // 内部主键 -Code string `gorm:"uniqueIndex;size:20"` // LMODEL_000001,对外展示 -// 关联用整数,展示用字符串 - -// 数据库迁移 -ALTER TABLE logical_models ALTER COLUMN id TYPE VARCHAR(20); -ALTER TABLE logical_model_revisions ALTER COLUMN id TYPE VARCHAR(20); -ALTER TABLE logical_model_routes ALTER COLUMN id TYPE VARCHAR(20); --- 所有相关表的 ID 和外键列 -``` - ---- - -## P2 次要问题 - -### [P2-1] 权重路由取模偏差 - -**位置**:`backend/internal/service/model_router.go:583-607` - -**现象**: -```go -func weightedRoute(routes []cachedLogicalRoute) cachedLogicalRoute { - var total int64 - for _, route := range routes { total += int64(route.Route.Weight) } - pick := int64(binary.LittleEndian.Uint64(raw[:]) % uint64(total)) // ← 取模偏差 -} -``` - -**问题分析**: - -当 `2^64 % total != 0` 时,小权重路由被选中概率略低。 - -例如 `total=3`: -- 值域 [0, 2^64-1],`2^64 % 3 = 1` -- 区间 [0,0] [1,1] [2,2] ... 最后一个周期只有 0,缺少 1 和 2 -- 权重1 的路由比权重2 的路由少被选中 1 次 / 2^64 次 - -**实际影响**:偏差 < 10^-18,工程上可忽略,但严格正确性角度是个问题。 - -**证据**: -- [LiteLLM PR #27980: Weighted-Routing Failover](https://github.com/BerriAI/litellm/pull/27980) - > "simple_shuffle 用 `random.choices(deployments, weights=...)` 原生加权随机,没有取模。" - -**修复方向**: - -```go -// 方案1:rejection sampling(无偏差但可能重试) -func weightedRoute(routes) cachedLogicalRoute { - max := int64(1) << 63 // 2^63 - limit := max - (max % total) // 丢弃偏差区间 - for { - pick := int64(binary.LittleEndian.Uint64(raw[:]) >> 1) // 避免负数 - if pick < limit { return selectByWeight(pick % total) } - // 重新生成随机数 - } -} - -// 方案2:浮点数(工程可接受) -pick := rand.Float64() * float64(total) -``` - ---- - -### [P2-2] `SaveAdminLogicalModel` 保存后全量查询找回自己 - -**位置**:`backend/internal/service/logical_models.go:305-328` - -**现象**: -```go -func (s *Service) SaveAdminLogicalModel(...) (*AdminLogicalModel, error) { - // 保存成功后 - items, err := s.AdminLogicalModels(actor) // ← 全量查询(含 N+1) - for index := range items { - if items[index].ID == item.ID { - return &items[index], nil - } - } -} -``` - -**性能问题**: -- 保存 1 个模型 → 查询所有模型 + N+1 → 线性遍历找回 -- 当系统有 100 个模型时,保存耗时随模型总数增长 -- O(n) 复杂度,应该是 O(1) - -**修复方向**: -```go -func (s *Service) SaveAdminLogicalModel(...) (*AdminLogicalModel, error) { - // 保存后 - graph, _ := s.repo.LogicalModelGraph(item.ID, true) // 只查这一个 - return s.buildAdminLogicalModel(item, graph), nil -} -``` - ---- - -## 完整修复提示词(给 GPT) - -```markdown -# 逻辑模型路由层代码修复任务 - -你需要修复以下 P0/P1 严重问题,按优先级执行: - -## 【P0-1】修复 MAX(version)+1 并发竞态 - -**文件**:`backend/internal/repository/logical_models.go` -**位置**:`SaveLogicalModelBundle` 函数 311-316 行 - -**问题**:两个并发请求会读到相同的 MAX(version),计算出相同的 next,导致 uniqueIndex 冲突。 - -**修改方案**: - -### 1. 数据库迁移:增加原子递增列 - -```sql -ALTER TABLE logical_models ADD COLUMN next_revision_version INT DEFAULT 1; - --- 初始化现有数据 -UPDATE logical_models lm -SET next_revision_version = ( - SELECT COALESCE(MAX(version), 0) + 1 - FROM logical_model_revisions - WHERE logical_model_id = lm.id -); -``` - -### 2. 修改 models_logical_model.go - -```go -type LogicalModel struct { - // ... 现有字段 - NextRevisionVersion int `json:"nextRevisionVersion" gorm:"default:1"` -} -``` - -### 3. 修改 SaveLogicalModelBundle - -```go -func (r *Repository) SaveLogicalModelBundle(...) error { - return r.db.Transaction(func(tx *gorm.DB) error { - // 删除以下代码: - // var latest struct{ Version int } - // if err := tx.Model(&model.LogicalModelRevision{}). - // Select("COALESCE(MAX(version), 0) AS version"). - // Where("logical_model_id = ?", item.ID).Scan(&latest).Error; err != nil { - // return err - // } - // revision.Version = latest.Version + 1 - - // 替换为: - var updated model.LogicalModel - if err := tx.Model(&model.LogicalModel{}). - Where("id = ?", item.ID). - UpdateColumn("next_revision_version", gorm.Expr("next_revision_version + 1")). - First(&updated).Error; err != nil { - return err - } - revision.Version = updated.NextRevisionVersion - - if err := tx.Create(revision).Error; err != nil { - return err - } - // ... 其他逻辑 - }) -} -``` - ---- - -## 【P0-2】修复单实例内存缓存多实例不同步 - -**文件**:`backend/internal/service/service.go`, `model_router.go` - -**问题**:`routeHealthBlocked` 和 `routeCatalog` 是单实例内存,多实例部署时各自维护状态,导致: -- 429 冷却只在触发实例生效 -- 路由缓存更新只在操作实例生效 - -**修改方案**: - -### 1. service.go 增加 Redis 连接 - -```go -import "github.com/redis/go-redis/v9" - -type Service struct { - // ... 现有字段 - redis *redis.Client - - // 保留本地缓存作为 fallback - routeCatalog *routeCatalogSnapshot - routeCatalogMu sync.RWMutex - routeCatalogRefreshMu sync.Mutex - routeCatalogTTL time.Duration -} - -func NewService(..., redisAddr string) *Service { - rdb := redis.NewClient(&redis.Options{ - Addr: redisAddr, - }) - - s := &Service{ - redis: rdb, - // ... 其他初始化 - } - - // 启动 Redis Pub/Sub 监听 - go s.subscribeRouteCatalogInvalidation() - - return s -} -``` - -### 2. model_router.go 修改 logicalRouteBlocked - -```go -func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool { - ctx := context.Background() - now := time.Now() - - // 检查 Redis 中的冷却状态 - keys := []string{ - "route_health:" + route.ChannelModel.ChannelID, - "route_health:" + route.Route.ID, - } - - for _, key := range keys { - until, err := s.redis.Get(ctx, key).Time() - if err == nil && now.Before(until) { - return true - } - } - - return false -} -``` - -### 3. model_router.go 修改 blockLogicalRouteForFailure - -```go -func (s *Service) blockLogicalRouteForFailure(attempt *model.RouteAttempt, duration time.Duration) { - ctx := context.Background() - until := time.Now().Add(duration) - - keys := []string{ - "route_health:" + attempt.ChannelID, - "route_health:" + attempt.RouteID, - } - - for _, key := range keys { - s.redis.Set(ctx, key, until.Format(time.RFC3339), duration) - } -} -``` - -### 4. logical_models.go 修改 invalidateRouteCatalog - -```go -func (s *Service) invalidateRouteCatalog(item *model.LogicalModel) { - ctx := context.Background() - - // 清空本地缓存 - s.routeCatalogMu.Lock() - s.routeCatalog = nil - s.routeCatalogMu.Unlock() - - // 通知其他实例 - s.redis.Publish(ctx, "route_catalog_invalidate", item.ID) -} - -func (s *Service) subscribeRouteCatalogInvalidation() { - ctx := context.Background() - pubsub := s.redis.Subscribe(ctx, "route_catalog_invalidate") - defer pubsub.Close() - - ch := pubsub.Channel() - for msg := range ch { - // 收到通知,清空本地缓存 - s.routeCatalogMu.Lock() - s.routeCatalog = nil - s.routeCatalogMu.Unlock() - - log.Printf("route catalog invalidated by message: %s", msg.Payload) - } -} -``` - ---- - -## 【P0-3】修复 RWMutex 潜在误用风险 - -**文件**:`backend/internal/service/model_router.go` -**位置**:`logicalRouteBlocked` 函数 - -**问题**:函数会删除 map 元素,但命名和注释没有体现"写操作",容易被误改为 RLock。 - -**修改方案**: - -### 1. 重命名函数(如果保留内存版本) - -```go -// 旧函数名 -// func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool - -// 新函数名(明确会修改状态) -func (s *Service) logicalRouteBlockedAndPruneExpired(route cachedLogicalRoute) bool { - // MUST hold write lock: prunes expired entries from routeHealthBlocked - s.routeHealthMu.Lock() - defer s.routeHealthMu.Unlock() - // ... 现有逻辑 -} -``` - -### 2. 调用处全部改名 - -```go -// model_router.go 所有调用处 -if s.logicalRouteBlockedAndPruneExpired(candidate) { - continue -} -``` - -**注意**:如果已经迁移到 Redis(P0-2),此问题自动解决,因为不再使用内存 map。 - ---- - -## 【P1-1】修复 GORM N+1 查询 - -**文件**:`backend/internal/service/logical_models.go`, `backend/internal/repository/logical_models.go` - -**问题**:`AdminLogicalModels` 对每个模型单独查 graph,对每个 route 单独查 channel,导致 1+N+N*M 条 SQL。 - -**修改方案**: - -### 1. repository/logical_models.go 增加预加载方法 - -```go -func (r *Repository) LogicalModelsWithPreload() ([]model.LogicalModel, error) { - var items []model.LogicalModel - err := r.db. - Preload("ActiveRevision"). - Preload("ActiveRevision.Routes", func(db *gorm.DB) *gorm.DB { - return db.Order("priority desc") - }). - Preload("ActiveRevision.Routes.Variant"). - Preload("ActiveRevision.Routes.Variant.ChannelModel"). - Order("sort_order asc, created_at asc"). - Find(&items).Error - return items, err -} -``` - -### 2. repository/logical_models.go 增加批量查询 channel 方法 - -```go -func (r *Repository) SystemChannelsByIDs(ids []string) (map[string]*model.Channel, error) { - var channels []model.Channel - if err := r.db.Where("id IN ?", ids).Find(&channels).Error; err != nil { - return nil, err - } - - result := make(map[string]*model.Channel) - for i := range channels { - result[channels[i].ID] = &channels[i] - } - return result, nil -} -``` - -### 3. service/logical_models.go 修改 AdminLogicalModels - -```go -func (s *Service) AdminLogicalModels(actor *model.User) ([]AdminLogicalModel, error) { - // 用预加载方法替代原来的 LogicalModels - items, err := s.repo.LogicalModelsWithPreload() - if err != nil { - return nil, err - } - - // 收集所有 channel ID - channelIDs := make(map[string]bool) - for _, item := range items { - if item.ActiveRevision != nil { - for _, route := range item.ActiveRevision.Routes { - if route.Variant != nil && route.Variant.ChannelModel != nil { - channelIDs[route.Variant.ChannelModel.ChannelID] = true - } - } - } - } - - // 批量查询所有 channel - idSlice := make([]string, 0, len(channelIDs)) - for id := range channelIDs { - idSlice = append(idSlice, id) - } - channelMap, err := s.repo.SystemChannelsByIDs(idSlice) - if err != nil { - return nil, err - } - - // 构建结果(不再单独查询) - result := make([]AdminLogicalModel, 0, len(items)) - for _, item := range items { - // 直接用 item.ActiveRevision.Routes,不再调用 LogicalModelGraph - adminModel := s.buildAdminLogicalModelFromPreloaded(item, channelMap) - result = append(result, adminModel) - } - - return result, nil -} - -func (s *Service) buildAdminLogicalModelFromPreloaded( - item model.LogicalModel, - channelMap map[string]*model.Channel, -) AdminLogicalModel { - // 从预加载数据构建 AdminLogicalModel - // 不再单独查询 -} -``` - -**预期效果**:61 条 SQL → 5 条 SQL - ---- - -## 【P1-2】修复 json.Marshal map key 不稳定 - -**文件**:`backend/internal/service/logical_models.go` -**位置**:`publicLogicalModel` 函数 161-176 行 - -**问题**:用 `json.Marshal(CapabilitySpec)` 做 map key,Go 1.24+ map key 顺序非确定性,去重失效。 - -**修改方案**: - -### 1. 增加稳定的 fingerprint 函数 - -```go -func capabilitySpecFingerprint(spec CapabilitySpec) string { - var buf strings.Builder - - // 1. capability (固定) - buf.WriteString(spec.Capability) - buf.WriteString("|v") - buf.WriteString(strconv.Itoa(spec.Version)) - buf.WriteString("|") - - // 2. operations (排序) - if len(spec.Operations) > 0 { - ops := make([]string, len(spec.Operations)) - copy(ops, spec.Operations) - sort.Strings(ops) - buf.WriteString("ops:") - buf.WriteString(strings.Join(ops, ",")) - buf.WriteString("|") - } - - // 3. inputs (按 key 排序) - if len(spec.Inputs) > 0 { - buf.WriteString("inputs:") - keys := make([]string, 0, len(spec.Inputs)) - for k := range spec.Inputs { - keys = append(keys, k) - } - sort.Strings(keys) - for i, k := range keys { - if i > 0 { buf.WriteString(",") } - fmt.Fprintf(&buf, "%s[%d-%d]", k, spec.Inputs[k].Min, spec.Inputs[k].Max) - } - buf.WriteString("|") - } - - // 4. options (按 key 排序) - if len(spec.Options) > 0 { - buf.WriteString("options:") - keys := make([]string, 0, len(spec.Options)) - for k := range spec.Options { - keys = append(keys, k) - } - sort.Strings(keys) - for i, k := range keys { - if i > 0 { buf.WriteString(",") } - opt := spec.Options[k] - buf.WriteString(k) - buf.WriteString(":") - if len(opt.Values) > 0 { - // values 也要排序(转字符串) - vals := make([]string, len(opt.Values)) - for j, v := range opt.Values { - vals[j] = fmt.Sprint(v) - } - sort.Strings(vals) - buf.WriteString(strings.Join(vals, ";")) - } else { - fmt.Fprintf(&buf, "[%v-%v/%v]", opt.Min, opt.Max, opt.Step) - } - } - } - - return buf.String() -} -``` - -### 2. 修改 publicLogicalModel 使用 fingerprint - -```go -func publicLogicalModel(...) PublicLogicalModel { - seen := make(map[string]bool) - var variants []CapabilitySpec - - for _, route := range cached.Routes { - // 旧代码: - // encoded, _ := json.Marshal(route.VariantSpec) - // key := string(encoded) - - // 新代码: - key := capabilitySpecFingerprint(route.VariantSpec) - - if !seen[key] { - seen[key] = true - variants = append(variants, route.VariantSpec) - } - } - - // ... 其他逻辑 -} -``` - ---- - -## 【P1-3】修复 prefixed ID size 不匹配 - -**文件**:`backend/internal/model/models_logical_model.go` - -**问题**:ID 存 "LMODEL_000001" (14字符),但 `size:36` 按 UUID 设计,浪费空间且6位数字会溢出。 - -**修改方案**: - -### 1. 修改所有 ID 字段声明 - -```go -type LogicalModel struct { - ID string `json:"id" gorm:"primaryKey;size:20"` // 改为 20 - // ... -} - -type LogicalModelRevision struct { - ID string `json:"id" gorm:"primaryKey;size:20"` // 改为 20 - LogicalModelID string `json:"logicalModelId" gorm:"size:20;index;uniqueIndex:idx_logical_revision_version,priority:1"` - // ... -} - -type PhysicalCapabilityVariant struct { - ID string `json:"id" gorm:"primaryKey;size:20"` // 改为 20 - ChannelModelID string `json:"channelModelId" gorm:"size:20;index"` - // ... -} - -type LogicalModelRoute struct { - ID string `json:"id" gorm:"primaryKey;size:20"` // 改为 20 - LogicalModelRevisionID string `json:"logicalModelRevisionId" gorm:"size:20;index;uniqueIndex:idx_logical_route_member,priority:1"` - PhysicalVariantID string `json:"physicalVariantId" gorm:"size:20;index;uniqueIndex:idx_logical_route_member,priority:2"` - // ... -} - -type RouteAttempt struct { - ID string `json:"id" gorm:"primaryKey;size:20"` // 改为 20 - LogicalModelID string `json:"logicalModelId" gorm:"size:20;index"` - LogicalModelRevisionID string `json:"logicalModelRevisionId" gorm:"size:20;index"` - RouteID string `json:"routeId" gorm:"size:20;index"` - PhysicalVariantID string `json:"physicalVariantId" gorm:"size:20;index"` - ChannelModelID string `json:"channelModelId" gorm:"size:20;index"` - // ... -} -``` - -### 2. 确保 NextPrefixedID 返回固定长度 - -```go -func NextPrefixedID(db *gorm.DB, prefix string) (string, error) { - // ... 现有逻辑获取 nextVal - - // 修改格式化,确保补零到固定长度 - return fmt.Sprintf("%s_%013d", prefix, nextVal), nil // 13位数字 -} -``` - -### 3. 数据库迁移脚本 - -```sql --- 修改所有相关表的 VARCHAR 长度 -ALTER TABLE logical_models ALTER COLUMN id TYPE VARCHAR(20); -ALTER TABLE logical_model_revisions ALTER COLUMN id TYPE VARCHAR(20); -ALTER TABLE logical_model_revisions ALTER COLUMN logical_model_id TYPE VARCHAR(20); -ALTER TABLE physical_capability_variants ALTER COLUMN id TYPE VARCHAR(20); -ALTER TABLE physical_capability_variants ALTER COLUMN channel_model_id TYPE VARCHAR(20); -ALTER TABLE logical_model_routes ALTER COLUMN id TYPE VARCHAR(20); -ALTER TABLE logical_model_routes ALTER COLUMN logical_model_revision_id TYPE VARCHAR(20); -ALTER TABLE logical_model_routes ALTER COLUMN physical_variant_id TYPE VARCHAR(20); -ALTER TABLE route_attempts ALTER COLUMN id TYPE VARCHAR(20); -ALTER TABLE route_attempts ALTER COLUMN logical_model_id TYPE VARCHAR(20); -ALTER TABLE route_attempts ALTER COLUMN logical_model_revision_id TYPE VARCHAR(20); -ALTER TABLE route_attempts ALTER COLUMN route_id TYPE VARCHAR(20); -ALTER TABLE route_attempts ALTER COLUMN physical_variant_id TYPE VARCHAR(20); -ALTER TABLE route_attempts ALTER COLUMN channel_model_id TYPE VARCHAR(20); - --- 验证现有数据长度(应该都 <= 20) -SELECT MAX(LENGTH(id)) FROM logical_models; -SELECT MAX(LENGTH(id)) FROM logical_model_revisions; --- ... 其他表 -``` - ---- - -## 【额外建议】 - -### 1. 增加集成测试 - -```go -// backend/internal/repository/logical_models_test.go -func TestSaveLogicalModelBundleConcurrent(t *testing.T) { - // 测试并发保存同一模型,验证无 uniqueIndex 冲突 - var wg sync.WaitGroup - errors := make(chan error, 10) - - for i := 0; i < 10; i++ { - wg.Add(1) - go func() { - defer wg.Done() - err := repo.SaveLogicalModelBundle(...) - if err != nil { - errors <- err - } - }() - } - - wg.Wait() - close(errors) - - for err := range errors { - if strings.Contains(err.Error(), "duplicate key") { - t.Fatalf("concurrent save caused uniqueIndex conflict: %v", err) - } - } -} -``` - -### 2. 增加监控指标 - -```go -// model_router.go -func (s *Service) blockLogicalRouteForFailure(...) { - // 设置 Redis - s.redis.Set(...) - - // 增加监控 - metrics.RouteHealthBlocked.WithLabelValues( - attempt.ChannelID, - attempt.FailureCode, - ).Inc() -} -``` - -### 3. 文档标注 - -在 `model_router.go` 顶部增加注释: - -```go -// Route Health Management -// -// IMPORTANT: routeHealthBlocked state is shared across all instances via Redis. -// Do NOT use local memory for blocking state in a multi-instance deployment. -// -// Keys: -// - route_health:{channelID} - channel-level block -// - route_health:{routeID} - route-level block -// -// TTL: 30s for rate limit (429), 5m for other failures -``` - ---- - -## 验证清单 - -修复完成后,请验证: - -- [ ] 并发保存同一模型不会触发 uniqueIndex 冲突 -- [ ] 多实例部署时,429 冷却在所有实例生效 -- [ ] 多实例部署时,路由配置更新在所有实例生效 -- [ ] 管理员列表接口 SQL 数量从 61 降到 5 条 -- [ ] `capabilityProfiles` 不再出现重复项 -- [ ] 所有 ID 列的 VARCHAR 长度改为 20 -- [ ] 集成测试通过 -``` - ---- - -## 本轮实际改动总结 - -### GPT 在本轮实现中的问题 - -1. **并发安全缺失** - - 使用应用层 `SELECT MAX + 1` 而非数据库原子操作 - - 典型的 TOCTOU 竞态条件 - -2. **分布式一致性缺失** - - 使用单实例内存缓存而非 Redis - - 无跨实例状态同步机制 - - 429 冷却和路由缓存在多实例下失效 - -3. **性能优化不足** - - N+1 查询问题(1 + N + N*M 条 SQL) - - 保存后全量查询找回自己(O(n) 应该 O(1)) - -4. **数据结构设计不当** - - VARCHAR(36) 存储 14 字符的 prefixed ID - - 浪费 22 字节索引空间 - - 未考虑 6 位数字溢出场景 - -5. **稳定性风险** - - 使用 `json.Marshal` 做去重 key(Go 1.24+ 不稳定) - - RWMutex 使用正确但命名误导(容易被误改) - -### 需要改动的文件 - -| 文件 | 改动类型 | 优先级 | -|------|---------|--------| -| `backend/internal/repository/logical_models.go` | 修改 SaveLogicalModelBundle | P0 | -| `backend/internal/model/models_logical_model.go` | 增加 NextRevisionVersion 字段,修改 size | P0 | -| `backend/internal/service/service.go` | 增加 Redis 连接 | P0 | -| `backend/internal/service/model_router.go` | 改用 Redis 存储 routeHealthBlocked | P0 | -| `backend/internal/service/logical_models.go` | 修改 AdminLogicalModels 用 Preload | P1 | -| `backend/internal/repository/logical_models.go` | 增加 LogicalModelsWithPreload | P1 | -| `backend/internal/service/logical_models.go` | 增加 capabilitySpecFingerprint | P1 | -| 数据库迁移脚本 | ALTER TABLE 修改 VARCHAR 长度 | P1 | - -### 预期改进效果 - -| 指标 | 改进前 | 改进后 | 提升 | -|------|--------|--------|------| -| 并发保存成功率 | 竞态冲突 | 100% 成功 | ✅ 修复 | -| 多实例 429 冷却 | 单实例生效 | 全局生效 | ✅ 修复 | -| 管理员列表 SQL | 61 条 | 5 条 | 92% ↓ | -| 保存后查询 | O(n) | O(1) | ✅ 修复 | -| ID 索引空间 | 36 字节 | 20 字节 | 44% ↓ | -| 去重稳定性 | Go 1.24+ 不稳定 | 稳定 | ✅ 修复 | - ---- - -## 附录:调研依据汇总 - -### 并发竞态 - -1. [DEV Community: SELECT MAX()+1 Is a Race Condition](https://dev.to/mattia_armas/select-max1-is-a-race-condition-waiting-to-happen-22m2) -2. [StackOverflow: INSERT INTO ... SELECT MAX + 1 returns DUPLICATE KEY](https://stackoverflow.com/questions/59756571) - -### 分布式缓存 - -3. [Viktar Patotski: Redis Caching vs Local Cache](https://patotski.com/blog/redis-caching-vs-local-cache/) -4. [WayGate: Distributed Deployments](https://attakay78.github.io/waygate/guides/distributed/) - -### Go 并发 - -5. [StackOverflow: concurrent map iteration and map write with RLock/RUnlock](https://stackoverflow.com/questions/71354301) -6. [Go sync 官方文档: RWMutex](https://pkg.go.dev/sync#RWMutex) - -### GORM N+1 - -7. [Shinagawa Labs: Speeding Up Search Responses](https://shinagawa-web.com/en/works/go-gorm-n-plus-one-b2b-ticketing) -8. [GORM 文档: Preloading](https://gorm.io/docs/preload.html) - -### JSON 稳定性 - -9. [Go encoding/json 文档](https://pkg.go.dev/encoding/json) -10. [StackOverflow: How to produce JSON with sorted keys](https://stackoverflow.com/questions/18668652) - -### VARCHAR 主键 - -11. [Tarazevits.io: Prefix-UUID for Postgres Primary Keys](https://blog.tarazevits.io/prefix-uuid-postgres-primary-keys/) -12. [StackOverflow: VARCHAR as ID issues](https://stackoverflow.com/questions/50728032) - -### LiteLLM 参考 - -13. [LiteLLM PR #27980: Weighted-Routing Failover](https://github.com/BerriAI/litellm/pull/27980) - ---- - -## 结论 - -本次评审基于**实际代码审查 + 市场调研**,发现的所有问题均有权威来源支撑,不是臆想。 - -GPT 的实现在**并发安全、分布式一致性、性能优化、数据设计**四个维度存在严重缺陷,必须修复后才能用于生产环境。 - -建议按 P0 → P1 → P2 优先级顺序修复,并增加相应的集成测试和监控。 - - diff --git a/web/src/components/canvas/canvas-connections.tsx b/web/src/components/canvas/canvas-connections.tsx index 56058bc63..6e065ef27 100644 --- a/web/src/components/canvas/canvas-connections.tsx +++ b/web/src/components/canvas/canvas-connections.tsx @@ -3,7 +3,7 @@ import type { MouseEvent as ReactMouseEvent } from "react"; import { canvasThemes } from "@/lib/canvas-theme"; import { useThemeStore } from "@/stores/use-theme-store"; -import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/components/canvas/canvas-script-node"; +import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout"; import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "@/types/canvas"; export const ConnectionPath = React.memo(function ConnectionPath({ diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx index 34c0c53db..12e649281 100644 --- a/web/src/components/canvas/canvas-node.tsx +++ b/web/src/components/canvas/canvas-node.tsx @@ -3,9 +3,9 @@ import type { ReactNode } from "react"; import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, Replace, Settings2, Star, Type, Upload, Video } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; +import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout"; import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status"; import { useThemeStore } from "@/stores/use-theme-store"; -import { storyboardMinNodeHeight } from "./canvas-script-node"; import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size"; diff --git a/web/src/components/canvas/canvas-script-node.tsx b/web/src/components/canvas/canvas-script-node.tsx index b3392ac96..9f7803751 100644 --- a/web/src/components/canvas/canvas-script-node.tsx +++ b/web/src/components/canvas/canvas-script-node.tsx @@ -15,6 +15,7 @@ import { navigateToSettings } from "@/lib/settings-navigation"; import { canvasThemes } from "@/lib/canvas-theme"; import { useEffectiveConfig } from "@/stores/use-config-store"; import { useThemeStore } from "@/stores/use-theme-store"; +import { STORYBOARD_COMPOSER_MIN_HEIGHT, STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout"; import type { CanvasGenerationBatch, CanvasGenerationBatchItem, @@ -30,29 +31,11 @@ import type { } from "@/types/canvas"; import type { TaskStatus } from "@/services/api/task-center"; -export const STORYBOARD_ROW_HEIGHT = 48; -export const STORYBOARD_HEADER_HEIGHT = 124; -const STORYBOARD_ADD_ROW_HEIGHT = 36; -const STORYBOARD_COMPOSER_MIN_HEIGHT = 104; -const STORYBOARD_COMPOSER_MAX_HEIGHT = 180; const STORYBOARD_PROMPT_MIN_HEIGHT = 40; const STORYBOARD_PROMPT_MAX_HEIGHT = 116; const SCRIPT_GRID_TEMPLATE = "72px 150px minmax(280px, 1.4fr) minmax(220px, 1fr) 58px"; const EMPTY_STORYBOARD_ROWS: StoryboardRow[] = []; -export function storyboardNodeHeight(rowCount: number, composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) { - const visibleRows = Math.min(Math.max(rowCount, 1), 4); - return STORYBOARD_HEADER_HEIGHT + visibleRows * STORYBOARD_ROW_HEIGHT + STORYBOARD_ADD_ROW_HEIGHT + Math.min(STORYBOARD_COMPOSER_MAX_HEIGHT, Math.max(STORYBOARD_COMPOSER_MIN_HEIGHT, composerHeight)); -} - -export function storyboardMinNodeHeight(composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) { - return STORYBOARD_HEADER_HEIGHT + STORYBOARD_ROW_HEIGHT + STORYBOARD_ADD_ROW_HEIGHT + Math.min(STORYBOARD_COMPOSER_MAX_HEIGHT, Math.max(STORYBOARD_COMPOSER_MIN_HEIGHT, composerHeight)); -} - -export function storyboardTableHeight(nodeHeight: number, composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) { - return Math.max(STORYBOARD_ROW_HEIGHT, nodeHeight - STORYBOARD_HEADER_HEIGHT - STORYBOARD_ADD_ROW_HEIGHT - Math.min(STORYBOARD_COMPOSER_MAX_HEIGHT, Math.max(STORYBOARD_COMPOSER_MIN_HEIGHT, composerHeight))); -} - const columnOptions: Array<{ label: string; value: StoryboardColumn }> = [ { label: "序号", value: "shotNumber" }, { label: "时长", value: "durationSeconds" }, diff --git a/web/src/components/layout/workspace-sidebar-footer.tsx b/web/src/components/layout/workspace-sidebar-footer.tsx index f396578b0..432005fc2 100644 --- a/web/src/components/layout/workspace-sidebar-footer.tsx +++ b/web/src/components/layout/workspace-sidebar-footer.tsx @@ -37,7 +37,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName, const handleLogout = async () => { try { await logout(); - await applyUserSession({ user: null, systemChannels: [] }); + await applyUserSession({ user: null }); setMenuOpen(false); message.success("已退出登录"); navigate("/login", { replace: true }); diff --git a/web/src/components/model-logo.tsx b/web/src/components/model-logo.tsx index 6e5fbf345..cb1e101e9 100644 --- a/web/src/components/model-logo.tsx +++ b/web/src/components/model-logo.tsx @@ -2,14 +2,21 @@ import { useMemo, useState, type ComponentType, type SVGProps } from "react"; import { Input, Popover } from "antd"; import { Cpu, Search, X } from "lucide-react"; -import * as LobeIcons from "@lobehub/icons/es/icons"; import { toc } from "@lobehub/icons/es/toc"; import { cn } from "@/lib/utils"; type LobeIconComponent = ComponentType & { size?: number | string }>; -const iconRegistry = LobeIcons as unknown as Record; +// 只加载各图标的 Mono 组件;完整 icons barrel 会经由 Avatar/Combine 与 @lobehub/ui 形成循环依赖,Bun 测试环境会触发未初始化导出。 +const iconModules = typeof import.meta.glob === "function" + ? import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { eager: true, import: "default" }) + : {}; +const iconRegistry = Object.fromEntries( + Object.entries(iconModules) + .map(([path, icon]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], icon]) + .filter((entry): entry is [string, LobeIconComponent] => Boolean(entry[0] && entry[1])), +) as Record; const iconOptions = toc .filter((item) => item.group === "model" || item.group === "provider") .map((item) => ({ id: item.id, title: item.fullTitle || item.title })) diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index c98d2405c..bded5cbf2 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -1,5 +1,5 @@ import { NODE_DEFAULT_SIZE, getNodeSpec } from "@/constant/canvas"; -import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/components/canvas/canvas-script-node"; +import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout"; import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog"; import type { NodeGenerationInput } from "@/components/canvas/canvas-node-generation"; import { isFrameNode } from "@/lib/canvas/canvas-frame"; diff --git a/web/src/lib/canvas/canvas-storyboard-layout.ts b/web/src/lib/canvas/canvas-storyboard-layout.ts new file mode 100644 index 000000000..3b2d5ee9d --- /dev/null +++ b/web/src/lib/canvas/canvas-storyboard-layout.ts @@ -0,0 +1,24 @@ +export const STORYBOARD_ROW_HEIGHT = 48; +export const STORYBOARD_HEADER_HEIGHT = 124; + +const STORYBOARD_ADD_ROW_HEIGHT = 36; +export const STORYBOARD_COMPOSER_MIN_HEIGHT = 104; +const STORYBOARD_COMPOSER_MAX_HEIGHT = 180; + +// 布局计算属于画布领域逻辑,独立于 React 节点组件,避免纯函数测试加载完整 UI 依赖。 +function normalizedComposerHeight(composerHeight: number) { + return Math.min(STORYBOARD_COMPOSER_MAX_HEIGHT, Math.max(STORYBOARD_COMPOSER_MIN_HEIGHT, composerHeight)); +} + +export function storyboardNodeHeight(rowCount: number, composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) { + const visibleRows = Math.min(Math.max(rowCount, 1), 4); + return STORYBOARD_HEADER_HEIGHT + visibleRows * STORYBOARD_ROW_HEIGHT + STORYBOARD_ADD_ROW_HEIGHT + normalizedComposerHeight(composerHeight); +} + +export function storyboardMinNodeHeight(composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) { + return STORYBOARD_HEADER_HEIGHT + STORYBOARD_ROW_HEIGHT + STORYBOARD_ADD_ROW_HEIGHT + normalizedComposerHeight(composerHeight); +} + +export function storyboardTableHeight(nodeHeight: number, composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) { + return Math.max(STORYBOARD_ROW_HEIGHT, nodeHeight - STORYBOARD_HEADER_HEIGHT - STORYBOARD_ADD_ROW_HEIGHT - normalizedComposerHeight(composerHeight)); +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 0c13e72d6..c6a948180 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -54,7 +54,8 @@ import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/a import { getProject } from "@/services/api/projects"; import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls"; import { CanvasShareModal } from "@/components/canvas/canvas-share-modal"; -import { CanvasScriptEditor, CanvasScriptNodeContent, STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/components/canvas/canvas-script-node"; +import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas/canvas-script-node"; +import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout"; import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel"; import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal"; import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel";