仓库自包含化:Docker/CI/文档/规范入库,路由分组拆分
CI / test (push) Successful in 15s

This commit is contained in:
Wang Defa
2026-07-09 19:18:04 +08:00
parent b9a3e97e84
commit 3e0389c1e9
83 changed files with 20241 additions and 248 deletions
+29
View File
@@ -0,0 +1,29 @@
# 并发规范
> Go 后端并发约定。
- 每个 goroutine 必须有明确退出路径:外部用 `context.Context` 或 stop channel 通知,内部 `select` 响应;禁止 fire-and-forget,不允许 goroutine 泄漏。
- 调用方能等待 goroutine 退出:`sync.WaitGroup``done` channel。
- `init()` 中不启动 goroutine、不做 IO。
- channel 容量只用 0 或 1,更大缓冲须注释说明理由。
- 涉及阻塞或远程调用的函数第一个参数是 `ctx context.Context`,不把 ctx 存进 struct。
## 模式:服务级后台 goroutine 的落地形态(2026-07 起为既定写法)
面板内「异步旁路」(通知发送 `Notifier.SendAsync`、日志异步落库 `SystemLogService.Record`、定期清理 `StartCleanup`)统一三件套:
```go
s.wg.Add(1)
go func() {
defer s.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) // 自带超时,不复用请求 ctx
defer cancel()
if err := s.doWork(ctx); err != nil {
log.Printf("xxx: %v", err) // 旁路失败只记日志,绝不影响主流程
}
}()
```
- 服务暴露 `Wait()`(内部 `wg.Wait()`);常驻循环(如清理 ticker)额外接收 ctx,`select` 响应退出。
- cmd/server 装配的 defer 顺序必须是「先停生产者、再等消费者」(LIFO):`defer notifier.Wait()` / `defer systemLogs.Wait()` 写在 `defer tasks.Stop()` / `defer stopCleanup()` **之前**
- 陷阱:`cron.Stop()` 返回的 context 要等(`<-s.cron.Stop().Done()`),否则仍在执行的任务尚未 `wg.Add``Wait()` 会提前返回,goroutine 泄漏且违反 WaitGroup 的 Add/Wait happens-before 约束。
@@ -0,0 +1,68 @@
# Database Guidelines
> Database patterns and conventions for this project.
---
## Overview
<!--
Document your project's database conventions here.
Questions to answer:
- What ORM/query library do you use?
- How are migrations managed?
- What are the naming conventions for tables/columns?
- How do you handle transactions?
-->
(To be filled by the team)
---
## Query Patterns
<!-- How should queries be written? Batch operations? -->
(To be filled by the team)
---
## Migrations
<!-- How to create and run migrations -->
(To be filled by the team)
---
## Naming Conventions
<!-- Table names, column names, index names -->
(To be filled by the team)
---
## Common Mistakes
<!-- Database-related mistakes your team has made -->
### Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
**Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。
**Cause**:gorm Scan 遇到 NULL 列时跳过赋值(保持字段现状),不会主动置 nil。复用变量(循环内重读、更新后回读同一 &ch)时旧值残留。
**Fix**:更新后回读一律用新变量(`var fresh model.X; db.First(&fresh, id)`)。
**Prevention**:凡「UPDATE 后回读返回」的服务方法,都声明新变量接收;测试中多次 `First` 同一行也各用独立变量。另:map Updates 写 NULL 用 `gorm.Expr("NULL")` 最稳(无类型 `nil` 在部分路径下不生效)。
### Common Mistake: 需要"条件更新多列含 NULL"时的写法
```go
// 正确:零写库条件 + 显式 NULL
db.Model(&model.AiChannel{}).
Where("id = ? AND (fail_count > 0 OR disabled_until IS NOT NULL)", id).
Updates(map[string]any{"fail_count": 0, "disabled_until": gorm.Expr("NULL")})
```
@@ -0,0 +1,32 @@
# 后端目录结构
> Go 后端工程的目录职责与代码组织约定。
## 目录职责
```text
oci-portal/
├── go.mod 模块名 oci-portal,依赖 oracle/oci-go-sdk/v65
├── go.sum
├── cmd/
│ └── server/ 启动入口 main.go
└── internal/
├── api/ HTTP 路由、参数绑定、响应、中间件
├── config/ 环境变量读取
├── crypto/ 敏感字段 AES-GCM 加解密
├── database/ SQLite 连接和 AutoMigrate
├── model/ GORM 数据模型
├── oci/ OCI SDK 封装,真实云请求只出现在此包
└── service/ 业务逻辑和本地快照同步
```
已实现:API Key 导入(ini 原文或显式字段)、测活(Identity GetTenancy)、账户类别判定(Organizations CLOUDCM 订阅)、重新测活时的快照差异同步。
## 代码组织约定
- 布局遵循官方约定:入口在 `cmd/<binary>/main.go`,非导出实现放 `internal/`(出现对外复用库时才建 `pkg/`),不过度分层。
- 接口定义在使用方包中;先有第二个实现或测试替身需求,再抽接口。
- 用 guard clause 尽早 return,减少嵌套。
- `defer` 紧跟资源获取语句,成对管理释放。
- 结构体初始化写字段名;已知容量的 slice/map 用 `make(T, 0, n)` 预分配。
- 注释解释"为什么"而非"是什么";导出符号写以名字开头的 godoc 注释。
+33
View File
@@ -0,0 +1,33 @@
# 错误处理规范
> Go 后端错误处理约定。
- error 向上返回并用 `fmt.Errorf("...: %w", err)` 附加上下文,不吞错。
- 一个错误只处理一次:要么就地处理(重试、降级),要么包装返回;不要既打日志又返回。
- 包装消息描述当前操作(如 `load oci config`),不堆叠 `failed to` 前缀。
- 需要调用方区分的错误用 sentinel(`errors.Is`)或自定义类型(`errors.As`),不比对错误字符串。
- 正常流程不使用 `panic`;类型断言一律 `v, ok := x.(T)` 双返回值形式。
## 禁止:让错误消息携带敏感信息外流
**问题**:错误消息会流向后端日志与 HTTP 响应(respondError),其中可能嵌着凭据。典型案例:`http.Client.Do` 失败返回 `*url.Error`,其 `Error()` 拼出**完整请求 URL**——对 Telegram 这类把 token 放路径的 API(`/bot<token>/sendMessage`),token 直接进日志和接口响应。
```go
// 错误:url.Error 原样外传,token 泄漏
resp, err := c.Do(req)
if err != nil {
return fmt.Errorf("send telegram message: %w", err)
}
// 正确:剥掉含 URL 的外壳,只保留底层原因(保持 %w 链)
if err != nil {
return fmt.Errorf("send telegram message: %w", sanitizeURLError(err))
}
func sanitizeURLError(err error) error {
var ue *url.Error
if errors.As(err, &ue) { return ue.Err }
return err
}
```
**预防**:凡外发 HTTP 且 URL/头中含凭据(token、签名、secret 路径段)的客户端,错误必须先脱敏再包装;新增此类客户端时补一条「错误不含凭据」的回归测试(参照 internal/service/notify_test.go 的 TestNotifierSendErrorHidesToken)。
+57
View File
@@ -0,0 +1,57 @@
# Backend Development Guidelines(oci-portal 后端规范)
> Go 后端(`oci-portal/`)编码规范入口。规范提炼自 Google / Uber Go Style Guide、Effective Go 与 Go 官方模块布局指南,按本项目裁剪;条目与项目约定冲突时,以本 spec 为准。迁自 docs/开发指南.md 与根目录 AGENTS.md(2026-07)。
---
## 技术栈约束
Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AES-GCM 字段加密 + `oracle/oci-go-sdk/v65`;JWT + bcrypt 用于登录。可选 MySQL / PostgreSQL(`DB_DRIVER`/`DB_DSN`,纯 Go 驱动,experimental);模型新增长文本字段时注意 MySQL 映射:确定 <64KB 用 `type:text`,可能超 64KB 用 `size:16777216`(→MEDIUMTEXT),短字段走 DefaultStringSize=512。**不引入 Redis**,单实例部署按需用进程内缓存;SQLite 快照承担"最近一次云端状态"。前端产物经 `internal/webui` go:embed 嵌入,来源为前端仓库 Release 的 `dist.zip`(release.yml 自动下载;本地构建方式见 README)。选型理由与本地运行方式(环境变量等)见 `docs/开发指南.md`
---
## Guidelines Index
| Guide | 内容 | 状态 |
|-------|------|------|
| [Directory Structure](./directory-structure.md) | 目录职责与代码组织约定 | 已填 |
| [Error Handling](./error-handling.md) | 错误包装、传播与 panic 纪律 | 已填 |
| [Quality Guidelines](./quality-guidelines.md) | 工具链检查与命名规范 | 已填 |
| [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 |
| [Testing](./testing.md) | table-driven 测试要求 | 已填 |
| [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 待填 |
| [Logging Guidelines](./logging-guidelines.md) | 结构化日志、日志级别 | 待填 |
---
## Pre-Development Checklist
写代码前确认:
- [ ] 读过 [Directory Structure](./directory-structure.md),新代码放对了包(云请求只出现在 `internal/oci/`)
- [ ] 阻塞 / 远程调用函数第一个参数是 `ctx context.Context`(见 [Concurrency](./concurrency.md))
- [ ] 错误按 [Error Handling](./error-handling.md) 用 `%w` 包装向上返回
- [ ] 复用已有封装,不重复造轮子(见 `../guides/code-reuse-thinking-guide.md`)
## Quality Check
提交前必过:
```bash
gofmt -l . && goimports -l . # 格式(或由编辑器保存时完成)
go vet ./...
go test ./...
go mod tidy # 依赖有变更时
```
`golangci-lint` 配置时以其为准。新逻辑必须带 table-driven 测试,`go test ./...` 全绿才算完成。
## API 文档(swagger)
全部 HTTP 接口带 swaggo 注释(@Summary 中文/@Tags 按域/@Param/@Success/@Router;JWT 组接口标 @Security BearerAuth)。**新增或修改接口必须同步注释**,并重新生成 spec:
```bash
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency
```
生成的 `docs/` 一并提交;UI 由 `SWAGGER=1` 开启(/swagger/index.html)。同名 handler 方法(list/create/get/update/remove)分布在多个 struct 上,写注释时确认 @Router 路径与该 receiver 的真实注册一致(routes_*.go)。
@@ -0,0 +1,51 @@
# Logging Guidelines
> How logging is done in this project.
---
## Overview
<!--
Document your project's logging conventions here.
Questions to answer:
- What logging library do you use?
- What are the log levels and when to use each?
- What should be logged?
- What should NOT be logged (PII, secrets)?
-->
(To be filled by the team)
---
## Log Levels
<!-- When to use each level: debug, info, warn, error -->
(To be filled by the team)
---
## Structured Logging
<!-- Log format, required fields -->
(To be filled by the team)
---
## What to Log
<!-- Important events to log -->
(To be filled by the team)
---
## What NOT to Log
<!-- Sensitive data, PII, secrets -->
(To be filled by the team)
@@ -0,0 +1,18 @@
# 质量与命名规范
> Go 后端工具链检查与命名约定。
## 工具链(提交前必过)
- 提交前必须通过 `gofmt`/`goimports``go vet ./...``go test ./...`
- 仓库有 `golangci-lint` 配置时以其为准。
- 依赖变更后运行 `go mod tidy`,`go.mod``go.sum` 一起提交。
## 命名
- 一律 MixedCaps,不用下划线;缩写词大小写保持一致:`ociConfigID``ParseURL``HTTPClient`
- 包名小写单个词,不用 `util``common``base` 这类空泛名。
- 变量名长度与作用域成正比:循环变量可用 `i``c`,包级变量用完整单词。
- Getter 不加 `Get` 前缀:`user.Name()` 而非 `user.GetName()`
- 方法接收者 1~2 个字母且同一类型内保持一致,如 `func (s *InstanceService)`
- 错误变量:导出 sentinel 用 `ErrXxx`,包内用 `errXxx`;自定义错误类型以 `Error` 结尾。
+23
View File
@@ -0,0 +1,23 @@
# 测试规范
> Go 后端测试约定。
- 新逻辑配 table-driven 测试,用例含名字、输入、期望值;`go test ./...` 全绿才算完成。
- 断言失败时输出 `got/want` 对比,便于定位。
- 辅助函数标注 `t.Helper()`,清理动作用 `t.Cleanup()`
## 常见坑:`:memory:` SQLite 每个连接是独立库
**症状**:被测代码里有异步 goroutine 写库(如日志异步落库)时,测试偶发 `no such table: xxx`
**原因**:glebarez/sqlite 的 `:memory:` 数据库按连接隔离;GORM 连接池为新 goroutine 开新连接时,拿到的是一个没跑过 AutoMigrate 的空库。
**修复**:测试 helper 建库后锁单连接,生产文件库不受影响:
```go
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1) // :memory: 每连接独立库,异步写复用同一连接
```
**预防**:测试涉及「后台 goroutine 写库」时一律加此设置(参照 internal/api/router_test.go 与 internal/service 各测试 helper);异步写路径同时拆出同步内核函数(如 `record` / `Record`)供测试直调断言。
@@ -0,0 +1,223 @@
# Code Reuse Thinking Guide
> **Purpose**: Stop and think before creating new code - does it already exist?
---
## The Problem
**Duplicated code is the #1 source of inconsistency bugs.**
When you copy-paste or rewrite existing logic:
- Bug fixes don't propagate
- Behavior diverges over time
- Codebase becomes harder to understand
---
## Before Writing New Code
### Step 1: Search First
```bash
# Search for similar function names
grep -r "functionName" .
# Search for similar logic
grep -r "keyword" .
```
### Step 2: Ask These Questions
| Question | If Yes... |
|----------|-----------|
| Does a similar function exist? | Use or extend it |
| Is this pattern used elsewhere? | Follow the existing pattern |
| Could this be a shared utility? | Create it in the right place |
| Am I copying code from another file? | **STOP** - extract to shared |
---
## Common Duplication Patterns
### Pattern 1: Copy-Paste Functions
**Bad**: Copying a validation function to another file
**Good**: Extract to shared utilities, import where needed
### Pattern 2: Similar Components
**Bad**: Creating a new component that's 80% similar to existing
**Good**: Extend existing component with props/variants
### Pattern 3: Repeated Constants
**Bad**: Defining the same constant in multiple files
**Good**: Single source of truth, import everywhere
### Pattern 4: Repeated Payload Field Extraction
**Bad**: Multiple consumers cast the same JSON/event fields locally:
```typescript
const description = (ev as { description?: string }).description;
const context = (ev as { context?: ContextEntry[] }).context;
```
This is duplicated contract logic even when the code is only two lines. Each
consumer now has its own definition of what a valid payload means.
**Good**: Put the decoder, type guard, or projection next to the data owner:
```typescript
if (isThreadEvent(ev)) {
renderThreadEvent(ev);
}
```
**Rule**: If the same untyped payload field is read in 2+ places, create a
shared type guard / normalizer / projection before adding a third reader.
---
## When to Abstract
**Abstract when**:
- Same code appears 3+ times
- Logic is complex enough to have bugs
- Multiple people might need this
**Don't abstract when**:
- Only used once
- Trivial one-liner
- Abstraction would be more complex than duplication
---
## After Batch Modifications
When you've made similar changes to multiple files:
1. **Review**: Did you catch all instances?
2. **Search**: Run grep to find any missed
3. **Consider**: Should this be abstracted?
### Reducers Should Use Exhaustive Structure
When state is derived from action-like values (`action`, `kind`, `status`,
`phase`), prefer a reducer with one `switch` over scattered `if/else` updates.
```typescript
// BAD - action-specific state transitions are hard to audit
if (action === "opened") { ... }
else if (action === "comment") { ... }
else if (action === "status") { ... }
// GOOD - one reducer owns the transition table
switch (event.action) {
case "opened":
...
return;
case "comment":
...
return;
}
```
This matters when the event log is the source of truth. A reducer is the
documented replay model; display code and commands should not duplicate pieces
of that replay model.
---
## Checklist Before Commit
- [ ] Searched for existing similar code
- [ ] No copy-pasted logic that should be shared
- [ ] No repeated untyped payload field extraction outside a shared decoder
- [ ] Constants defined in one place
- [ ] Similar patterns follow same structure
- [ ] Reducer/action transitions live in one reducer or command dispatcher
---
## Gotcha: Python if/elif/else Exhaustive Check
**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults.
**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised.
**Example** (`cli_adapter.py`):
```python
# BAD: "gemini" falls through to else, returns "claude"
@property
def cli_name(self) -> str:
if self.platform == "opencode":
return "opencode"
else:
return "claude" # gemini silently gets "claude"!
# GOOD: explicit branch for every platform
@property
def cli_name(self) -> str:
if self.platform == "opencode":
return "opencode"
elif self.platform == "gemini":
return "gemini"
else:
return "claude"
```
**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values.
---
## Gotcha: Asymmetric Mechanisms Producing Same Output
**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts.
**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely.
**Prevention**:
- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list)
- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms
- When migrating directory structures, search for ALL code paths that reference the old structure
**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3.
---
## Template File Registration (Trellis-specific)
When adding new files to `src/templates/trellis/scripts/`:
**Single registration point**: `src/templates/trellis/index.ts`
1. Add `export const xxxScript = readTemplate("scripts/path/file.py");`
2. Add to `getAllScripts()` Map
That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed.
**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate.
**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth.
### Quick Checklist for New Scripts
```bash
# After adding a new .py file, verify it's in getAllScripts():
grep -l "newFileName" src/templates/trellis/index.ts # Should match
```
### Template Sync Convention
`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync:
```bash
rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/
```
**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running.