# Lessons Learned ## Bug 1: Frontend/Bulk Field Name Mismatch (2026-04-16) **现象**: 部署 API 返回 400 "invalid version",即使前端传了正确的 version **根因**: 前端发送 JSON 字段 `version`,但 DTO 只有 `Tag`(json: `tag`),handler 读 `req.Tag` 始终为空 **修复**: 在 CreateInstanceRequest 中添加 `Version` 字段,并在 Normalize() 中将 Version 复制到 Tag **How to apply**: 前后端接口字段名必须一致。DTO 的 json tag 应与前端发送的字段名匹配,或在 Normalize() 中做兼容映射 ## Bug 2: Registry Decrypt Fails with Key Mismatch (2026-04-16) **现象**: GET /registries 列表正常,但 GET /registries/{id} 返回 404 "failed to decrypt password" **根因**: 旧数据用不同 ENCRYPTION_KEY 加密,GetByID/GetByName 解密失败直接返回 error **修复**: 解密失败时返回空密码而非错误(与 List 方法行为一致) **Why**: 列表查询不触发解密(`_ = r.encryptor.Decrypt`),但单条查询需要解密。密钥不匹配不应阻断核心业务流程 **How to apply**: 涉及敏感数据解密时,对密钥不匹配的情况做 graceful fallback 而非直接报错 ## Bug 3: Docker Compose Project Conflict (2026-04-16) **现象**: 从 backend/ 目录运行 docker compose 时报错 "container name already in use" **根因**: 容器通过不同 compose 项目启动(ocdp-go vs backend),但使用了相同的容器名 **修复**: 直接用 docker 命令重启旧容器:docker stop/rm 后用 docker run 启动新镜像 **How to apply**: 当有多个 compose 文件管理同一网络时,docker run 方式更灵活 ## Bug 4: InitSchema vs Actual DB Schema Mismatch (2026-04-16) **现象**: InitSchema() 创建的 registries 表缺少 workspace_id, owner_id, is_shared 字段 **根因**: 代码中的 InitSchema 与实际 init-db.sql 不同步 **影响**: GetByID/GetByName 查询时字段数不匹配会报错 **Fix**: 修复 GetByID/GetByName 的查询和 Scan,使用实际的 DB schema **How to apply**: InitSchema() 和实际 DB schema 必须保持同步 ## Bug 5: Frontend CreateInstanceRequest Missing registryId Field (2026-04-17) **现象**: Charts 页面 Deploy Modal 填好信息后点击 Deploy,后端报错无法找到 registry **根因**: Frontend `CreateInstanceRequest` type 缺少 `registryId` 字段,`charts/page.tsx` 发送 `registry_id`,但后端 DTO 用 `registryId` 接收 **修复**: 1. 在 `frontend/src/lib/types.ts` 的 `CreateInstanceRequest` 添加 `registryId: string` 2. 在 `frontend/src/app/charts/page.tsx` 发送 `registryId: selectedRegistry?.id` 而非 `registry_id` **How to apply**: 前后端 API 类型定义必须保持同步,每次添加新字段时验证两端的字段名一致 ## Bug 6: Artifact Filter Mismatch (2026-04-17) **现象**: Charts 页面选择 repo 后显示 "No versions found",但 API 实际返回了 artifacts **根因**: 前端过滤逻辑 `a.mediaType?.includes('chart')` 无法匹配 API 返回的 `mediaType: "application/vnd.oci.image.manifest.v1+json"` **影响**: 用户看不到任何 helm chart 版本 **修复**: 恢复过滤逻辑为 `a.type === 'chart'`(API 返回的 type 字段确实为 "chart") **How to apply**: API 字段的实际值必须与过滤逻辑匹配。type 字段比 mediaType 更可靠 ## Bug 7: Docker Node.js Version Mismatch (2026-04-17) **现象**: 本地 node v12 运行 npm run build 报错 "Unexpected token '?'" **根因**: Next.js 需要 Node.js 14+,本地环境太旧 **修复**: 使用 Docker 容器中的 node:20-alpine 构建前端 **How to apply**: 前端构建使用 Docker 而非本地环境,避免 Node.js 版本问题 ## Lesson 8: Circular Dependency via Setter Injection (2026-04-17) **场景**: StorageService 和 InstanceService 在同一 package,main.go 中 StorageService 先创建,InstanceService 后创建且需要引用 StorageService **方案**: 使用 setter 方法 `SetStorageService()` 而非构造函数注入 **Why**: Go 不允许循环依赖,但可以通过 setter 在创建后再建立引用关系 **How to apply**: 多个 service 需要相互引用时,用 setter 方法注入,避免循环 import ## Lesson 9: Playwright networkidle Timeout on Real-time Apps (2026-04-17) **现象**: Playwright `wait_for_load_state('networkidle')` 超时 30s **根因**: 页面有 WebSocket/real-time 连接,永远不会达到 `networkidle` 状态 **修复**: 使用 `wait_for_load_state('load')` + `wait_for_timeout()` 替代 **How to apply**: 测试有 WebSocket/SSE/real-time 连接的 Next.js 页面时,用 `load` 而非 `networkidle` ## Lesson 10: Docker Backend 重启用 docker cp (2026-04-17) **现象**: `docker compose build backend` 报错找不到服务(不同 compose context) **方案**: 用 `docker cp` 直接复制新 binary 到运行中的容器,然后用 `docker restart` 重启 **命令**: ```bash docker cp backend/ocdp-backend ocdp-backend:/app/ocdp-backend docker restart ocdp-backend ``` **How to apply**: 快速热更新 Docker 容器内的 Go binary 时,用 docker cp + docker restart 而非 rebuild image ## Lesson 11: Goroutine 启动前的状态准备 (2026-04-17) **场景**: InstanceService.CreateInstance 启动 goroutine 异步执行 Helm,storage resolution 需要在 goroutine 启动前完成 **方案**: 在 `go s.executeAndSyncInstall(...)` 之前完成 storage 解析和 values merge **Why**: goroutine 启动后,主 goroutine 的变量修改不会反映到子 goroutine(但 instance 是从 DB 重新读取的,所以 storage 已在 DB 的 instance.Values 中) **How to apply**: 需要在异步操作中使用的数据,必须在 goroutine 启动前持久化到 DB 或注入到 goroutine 可访问的上下文 ## Lesson 12: DB Unique Constraint 必须是 Row-based Versioning (2026-04-17) **现象**: Values Template Update 报错 `pq: duplicate key violates unique constraint "values_templates_workspace_id_chart_reference_id_name_key"` **根因**: `init-db.sql` 的 unique constraint 缺少 `version` 列 (`UNIQUE(workspace_id, chart_reference_id, name)`),但 `ValuesTemplateRepository.Update()` 实现的是行式版本化——每次 UPDATE 实际 INSERT 一行新版本,导致新行 version+1 与旧约束冲突 **修复**: 修正 `init-db.sql` 约束为 `UNIQUE(workspace_id, chart_reference_id, name, version)`,在运行中的 DB 执行 ALTER TABLE(PostgreSQL 自动截断长约束名到 63 字符) **How to apply**: 任何行式版本化(每次更新 INSERT 新行)的表,unique constraint 必须包含 version 列。建表前先确认 Repo 层的 UPDATE 语义。 ## Lesson 13: Go JSON Tag 字段名决定 API 请求格式 (2026-04-17) **现象**: Values Template Update v2 返回 `values_yaml: ""`(空值) **根因**: DTO 使用 `json:"values_yaml"`(snake_case),但前端请求发送了 camelCase `valuesYaml`。Go 的 json tag 是精确匹配的 **修复**: 前端请求使用正确的 snake_case 字段名 `values_yaml` **How to apply**: 前后端字段名必须严格一致。Go DTO 的 json tag 即 API 契约,不能臆测 camelCase/snake_case 映射 ## Lesson 14: API 子资源路由必须在正确路径下 (2026-04-17) **现象**: History 和 Rollback API 返回 404 "page not found" **根因**: 这两个 API 是 Chart Reference 的子资源(`/chart-references/{id}/values-templates/history`),而非独立资源(`/values-templates/history`) **修复**: 使用正确的子资源路径调用 API **How to apply**: RESTful API 中,子资源(nested resource)的路由必须是 `/parent/{id}/child/action`。测试时先查 handler 路由注册确认路径 ## Bug 15: values.yaml 未应用到 Helm Releases (2026-04-22) **现象**: 前端发送 values.yaml,自定义值(如 `replicaCount: 9`)没有应用到集群中的 Helm release **根因**: `Instance.SetValuesYAML()` 只存储了 `i.ValuesYAML = yaml`,**没有解析成 `i.Values` map**。Helm Client 使用 `install.Run(chart, instance.Values)` 但 `instance.Values` 是空 map **修复**: 1. `instance.go`: 在 `SetValuesYAML()` 中添加 `gopkg.in/yaml.v3` 解析,将 YAML string 解析为 `map[string]interface{}` 并 merge 到 `Values` 2. `instance_handler.go`: `ListInstances` 的 `InstanceResponse` 构造中缺少 `Values` 字段(只有 `GetInstance` 等有),添加 `Values: instance.Values` **Why**: values.yaml 存储在 DB 中但 Helm SDK 需要 `map[string]interface{}`。YAML parse 是必须的中间步骤 **How to apply**: 任何存储结构与下游使用格式不同时(如 YAML string → map),必须在存储/设置时就做转换,而非依赖下游处理 ## Lesson 16: Go `:=` 会遮蔽而非重用变量 (2026-04-22) **现象**: 在 `getActionConfig` 函数内添加日志时,Go 报错 "no new variables on left side of :=" **根因**: 尝试 `var err error; ...; actionConfig, err := getActionConfig(...)` — `err` 已声明,不能再用 `:=`。只有 `actionConfig` 是新变量 **修复**: `actionConfig, err := getActionConfig(...)` 即可,因为 actionConfig 是新的。`err` 必须用普通 `=` 赋值 **How to apply**: Go 的 `:=` 用于声明新变量并初始化。若变量已用 `var` 声明,再用 `:=` 会报 "no new variables"。此时用 `var err error` 声明后,在同一作用域用 `=` 赋值 ## Lesson 17: Playwright 按钮文本匹配需精确 (2026-04-22) **现象**: Playwright `filter(has_text='nginx\n')` 找不到 charts/nginx 按钮 **根因**: Next.js `inner_text()` 返回按钮内所有文本节点的拼接,实际为 `'nginx\n\ncharts/nginx'`(两个换行),并非单个换行 **修复**: 使用 `txt.strip().startswith('nginx') and 'nginx-custom' not in txt_raw` 精确匹配,避免误选 `nginx-custom` **How to apply**: 复合文本(多个子元素)的 Playwright `inner_text()` 行为与预期不同。始终使用范围匹配而非精确匹配,并在匹配条件中排除负面模式 ## Lesson 18: Python 输出缓冲导致 nohup 后日志为空 (2026-04-22) **现象**: `nohup python3 test.py > log &` 后 `cat log` 显示空文件,但进程正在运行 **根因**: Python 默认启用输出缓冲,nohup 不会自动 flush **修复**: 使用 `python3 -u test.py` (unbuffered mode) 或 `python3 -W- test.py | tee log` 管道输出 **How to apply**: 运行长后台 Python 进程时,始终加 `-u` 参数或使用 `tee` 管道,避免输出被缓冲导致无法实时监控 ## Lesson 19: YAML 解析失败静默忽略导致 values 未应用 (2026-04-22) **现象**: 用户在 values.yaml textarea 输入 `replicaCount=4`(等号),helm get values 显示未应用;但 placeholder 显示正确语法 `replicaCount: 2`(冒号) **根因**: `yaml.Unmarshal("replicaCount=4", ...)` 解析失败返回空 map,`if err == nil && parsed != nil` 条件不满足,**静默跳过** merge,Helm 收到空 values **修复**: 1. Backend `SetValuesYAML()`: 解析失败时打印 WARNING 日志 2. Frontend: textarea onChange 做客户端 YAML 语法检查(检测 `key=value` 模式),红色错误提示 + 禁用 Deploy 按钮 **Why**: YAML 解析失败不应该静默忽略,需要让用户感知输入错误。客户端提前验证比后端日志更友好 **How to apply**: 任何格式转换(YAML/JSON/Marshal)失败都要有明确反馈(日志/返回错误),不能静默跳过 ## Lesson 20: Next.js `next-server` 进程持有旧 Build 缓存 (2026-04-22) **现象**: `.next` 目录 rebuild 后,`ocdp-frontend` 容器(运行 `next-server`)的 HTML 仍引用旧的 JS chunk 哈希,导致 500 **根因**: Next.js Server Component 的 RSC payload 和 HTML 是在 `next-server` 进程启动时从 `.next` 目录读取的。简单的 `docker cp` 替换 `.next` 目录后,**进程内存中的路由映射仍是旧的**。需要 `docker restart` 重启进程 **How to apply**: 更新 Next.js 容器后必须 `docker restart ` 而非仅 `docker cp`,否则 RSC payload 和 HTML 模板不一致 ## Lesson 21: AES-GCM 解密失败静默破坏 kubeconfig 明文数据 (2026-04-22) **现象**: browser 点击 Deploy 后端报错 "no valid credentials found for cluster cluster1",但 CA 证书能正常解析到 K8s server cert **根因**: cluster1 的 `ca_data` 字段存储了明文 kubeconfig(`apiVersion: v1\n...` 格式)。`GetByID` 总是调用 `encryptor.Decrypt()`,AES-GCM 解密失败后返回乱码并丢弃。后续 `createRestConfig` 检测不到 kubeconfig 格式(乱码非 `apiVersion:`),且 cert/key 字段为空,最终走 kubeconfig 文件分支失败 **修复**: 添加 `decryptIfNeeded()` helper,检测数据以 `apiVersion:` 或 `kind:` 开头则跳过解密直接返回原值。更新 `GetByID`、`GetByName`、`scanClusters` 三处调用 **Why**: 明文 kubeconfig 绕过了解密流程,因为没有任何"已加密"标记。检测格式前缀是区分加密/明文的最简方式 **How to apply**: 所有存储加密数据的 Repository,在解密前必须检查数据格式。kubeconfig 内容不需要加密(已在文件系统中受保护),用明文格式存储更简单 ## Lesson 22: PostgreSQL 存储 Base64 编码加密数据 vs 直接存储加密字节 (2026-04-22) **现象**: clusters 表的 `ca_data` 等字段存储的是 `Encrypt()` 函数的 Base64 输出。但 Encrypt() 内部已经做了 Base64 编码,所以这些字段存的是**双重 Base64**(crypto output 的 Base64 再次作为普通文本存储) **分析**: `Encrypt()` 返回 `base64.StdEncoding.EncodeToString(ciphertext)`,ciphertext 包含 nonce+tag+内容,已是标准 Base64 格式。这个 Base64 字符串直接存储到 text 字段即可 **How to apply**: Go 的 `crypto.Encrypt()` → Base64 字符串 → 直接存 PostgreSQL text 字段。读取时需要先从 text 列获取 Base64 字符串,再调用 `Decrypt()` 解析。这与前端发送 PEM 内容(`LS0tLS1...`)直接加密存储不同