repair-agent c219ec2fcf
Some checks failed
Build and Deploy Backend / build-and-deploy (push) Failing after 4m28s
feat(hw-ws-service): 将 Go WebSocket 服务纳入 CI/CD 并通过 Traefik 统一入口
## 变更内容

### k8s/ingress.yaml
- 新增 /xiaozhi/v1/ 路径规则,将 WebSocket 流量路由到 hw-ws-svc:8888
- Traefik 最长前缀优先,/xiaozhi/v1/ 不影响 / 下的 Django 路由

### hw_service_go/k8s/service.yaml
- Service 类型由 LoadBalancer 改为 ClusterIP
- 移除阿里云 SLB 注解(通过 Traefik Ingress 统一暴露,不再需要独立公网 IP)

### hw_service_go/k8s/deployment.yaml
- 镜像地址改为 ${CI_REGISTRY_IMAGE}/hw-ws-service:latest 占位符
- CI/CD 部署时统一通过 sed 替换为华为云 SWR 实际地址

### hw_service_go/internal/server/server.go
- 新增 GET /xiaozhi/v1/healthz 接口,返回 {"status":"ok","active_connections":N}
- 用于部署后验证服务存活及当前连接数

### .gitea/workflows/deploy.yaml
- 新增 Build and Push HW WebSocket Service 步骤,构建并推送 hw_service_go 镜像
- 部署步骤新增 kubectl apply hw_service_go/k8s/deployment.yaml 和 service.yaml
- 新增 kubectl rollout restart deployment/hw-ws-service

### run.sh
- 本地同时启动 Django + hw_service_go 的开发脚本

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 17:16:26 +08:00

397 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# hw_service_go - Claude Code 开发指南
> ESP32 硬件 WebSocket 通讯服务,负责接收设备指令并推送 Opus 音频流。
## 技术栈
- **Go 1.23+**
- `github.com/gorilla/websocket` — WebSocket 服务器
- `github.com/hraban/opus` — CGO libopus 编码(需 `opus-dev`
- `ffmpeg`(系统级二进制)— MP3/AAC 解码为 PCM
- K8s 部署,端口 **8888**
## 目录结构
```
hw_service_go/
├── cmd/main.go # 唯一入口,只做启动和优雅关闭
├── internal/
│ ├── config/config.go # 环境变量,只读,不可变
│ ├── server/server.go # HTTP Upgrader + 连接生命周期
│ ├── connection/connection.go # 单连接状态,并发安全
│ ├── handler/
│ │ ├── story.go # 故事播放主流程
│ │ └── audio_sender.go # Opus 帧流控发送
│ ├── audio/convert.go # MP3→PCM→Opus 转码
│ └── rtcclient/client.go # 调用 Django REST API
├── go.mod / go.sum
└── Dockerfile
```
> `internal/` 包不对外暴露,所有跨包通信通过显式函数参数传递,**不使用全局变量**。
---
## 一、代码规范
### 1.1 命名
| 类型 | 规范 | 示例 |
|------|------|------|
| 包名 | 小写单词,不含下划线 | `server`, `rtcclient` |
| 导出类型/函数 | UpperCamelCase | `Connection`, `HandleStory` |
| 非导出标识符 | lowerCamelCase | `abortCh`, `sendFrame` |
| 常量 | UpperCamelCase非全大写| `FrameSizeMs`, `PreBufferCount` |
| 接口 | 以行为命名,单方法接口加 `-er` 后缀 | `Sender`, `Converter` |
| 错误变量 | `Err` 前缀 | `ErrDeviceNotFound`, `ErrAudioConvert` |
> **不使用** `SCREAMING_SNAKE_CASE` 常量,这是 C 习惯,不是 Go 惯例。
### 1.2 错误处理
```go
// ✅ 正确:始终包装上下文
frames, err := audio.Convert(ctx, url)
if err != nil {
return fmt.Errorf("story handler: convert audio: %w", err)
}
// ❌ 错误:丢弃错误
frames, _ = audio.Convert(ctx, url)
// ❌ 错误panic 在业务逻辑里(仅允许在 main 初始化阶段)
frames, err = audio.Convert(ctx, url)
if err != nil { panic(err) }
```
- 错误链用 `%w`(支持 `errors.Is` / `errors.As`
- 叶子函数返回 `errors.New()`,中间层用 `fmt.Errorf("context: %w", err)`
- 只在 `cmd/main.go` 初始化失败时允许 `log.Fatal`
### 1.3 Context 使用
```go
// ✅ Context 作为第一个参数
func (c *Client) FetchStory(ctx context.Context, mac string) (*StoryInfo, error)
// ✅ 所有 I/O 操作绑定 context支持超时/取消)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
cmd := exec.CommandContext(ctx, "ffmpeg", ...)
// ❌ 不存储 context 到结构体字段
type Handler struct {
ctx context.Context // 禁止
}
```
### 1.4 并发与 goroutine
```go
// ✅ goroutine 必须有明确的退出机制
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case <-abortCh:
return
case frame := <-frameCh:
ws.WriteMessage(websocket.BinaryMessage, frame)
}
}
}()
// ❌ 禁止裸 goroutine无法追踪生命周期
go processAudio(url)
```
- 每启动一个 goroutine必须确保它**有且只有一个**退出路径
- 使用 `sync.WaitGroup` 跟踪服务级 goroutine确保优雅关闭时全部结束
- Channel 方向声明:`send <-chan T``recv chan<- T`,减少误用
### 1.5 结构体初始化
```go
// ✅ 始终使用字段名初始化(顺序变更不会引入 bug
conn := &Connection{
WS: ws,
DeviceID: deviceID,
ClientID: clientID,
}
// ❌ 位置初始化(字段顺序改变后静默错误)
conn := &Connection{ws, deviceID, clientID}
```
### 1.6 接口设计
```go
// ✅ 在使用方定义接口(而非实现方)
// audio/convert.go 不定义接口,由 handler 包定义它需要的最小接口
package handler
type AudioConverter interface {
Convert(ctx context.Context, url string) ([][]byte, error)
}
```
---
## 二、代码生成规范
### 2.1 新增消息类型处理器
硬件消息类型通过 `server.go``switch envelope.Type` 路由。新增类型时:
1.`handler/` 下创建 `<type>.go`
2. 函数签名必须为:`func Handle<Type>(conn *connection.Connection, raw []byte)`
3.`server.go` 的 switch 中注册
```go
// server.go
switch envelope.Type {
case "story":
go handler.HandleStory(conn, raw)
case "music": // 新增
go handler.HandleMusic(conn, raw) // 新增
}
```
### 2.2 新增配置项
所有配置**只能**通过环境变量注入,**不允许**读取配置文件或命令行参数(保持 12-Factor App 原则):
```go
// config/config.go
type Config struct {
WSPort string // HW_WS_PORT默认 "8888"
RTCBackendURL string // HW_RTC_BACKEND_URL必填
NewFeatureXXX string // HW_NEW_FEATURE_XXX新增时遵循此格式
}
```
- 环境变量前缀统一为 `HW_`
- 必填项在 `Load()``log.Fatal` 校验
- 不使用 `viper` 等配置库(项目够小,标准库足够)
### 2.3 Dockerfile 变更
Dockerfile 使用**多阶段构建**,修改时严格遵守:
- 构建阶段:`golang:1.23-alpine`,只安装编译依赖(`gcc musl-dev opus-dev`
- 运行阶段:`alpine:3.20`,只安装运行时依赖(`opus ffmpeg ca-certificates`
- 最终镜像不包含 Go 工具链、源码、测试文件
---
## 三、安全风险防范
### 3.1 ⚠️ exec 命令注入(最高优先级)
`audio/convert.go` 调用 `exec.Command("ffmpeg", ...)` 时,**所有参数必须是硬编码常量,绝对不能包含任何用户输入**。
```go
// ✅ 安全:参数全部硬编码
cmd := exec.CommandContext(ctx, "ffmpeg",
"-nostdin",
"-i", "pipe:0", // 始终从 stdin 读,不接受文件路径
"-ar", "16000",
"-ac", "1",
"-f", "s16le",
"pipe:1",
)
cmd.Stdin = resp.Body // HTTP body 通过 stdin 传入,不是命令行参数
// ❌ 危险audio_url 进入命令行参数(命令注入)
cmd := exec.CommandContext(ctx, "ffmpeg", "-i", audioURL, ...)
// ❌ 危险:使用 shell 执行
exec.Command("sh", "-c", "ffmpeg -i "+audioURL)
```
> `audioURL` 只能作为 HTTP 请求的 URL由 `net/http` 处理,永远不进入 `exec.Command` 的参数列表。
### 3.2 WebSocket 输入验证
```go
// server.go设置消息大小上限防止内存耗尽攻击
upgrader := websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // IoT 设备无 Origin允许所有来源
},
}
// 连接建立后立即设置读限制
ws.SetReadLimit(4 * 1024) // 文本消息上限 4KB硬件不会发大消息
```
```go
// 解析 JSON 时验证关键字段
var msg StoryMessage
if err := json.Unmarshal(raw, &msg); err != nil {
return fmt.Errorf("invalid json: %w", err)
}
// device_id 来自 URL 参数(已在连接时验证),不信任消息体中的 device_id
```
### 3.3 资源耗尽防护
```go
// server.go限制最大并发连接数
const maxConnections = 500
func (s *Server) register(conn *Connection) error {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.conns) >= maxConnections {
return ErrTooManyConnections
}
s.conns[conn.DeviceID] = conn
return nil
}
// 同一设备同时只允许一个连接(防止设备重复连接内存泄漏)
if old, exists := s.conns[conn.DeviceID]; exists {
old.Close() // 踢掉旧连接
}
```
```go
// audio/convert.goffmpeg 超时保护(防止卡死)
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "ffmpeg", ...)
```
### 3.4 HTTP 客户端安全
```go
// rtcclient/client.go必须设置超时防止 RTC 后端无响应时 goroutine 泄漏
var httpClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 50,
IdleConnTimeout: 90 * time.Second,
},
// 禁止无限重定向
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("too many redirects")
}
return nil
},
}
```
### 3.5 goroutine 泄漏防护
```go
// ✅ handler 必须响应 context 取消
func HandleStory(conn *Connection, raw []byte) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() // 无论何种退出路径context 都会被取消
frames, err := audio.Convert(ctx, story.AudioURL)
// ...
}
// ✅ audio sender 通过 select 同时监听多个退出信号
select {
case <-time.After(delay):
case <-abortCh: // 用户打断
return
case <-ctx.Done(): // 超时或连接关闭
return
}
```
### 3.6 日志安全
```go
// ✅ 日志中不输出敏感信息
log.Printf("fetch story for device %s", conn.DeviceID) // MAC 地址可以记录(非个人数据)
log.Printf("audio url: %s", truncate(story.AudioURL, 60)) // URL 截断记录
// ❌ 不记录完整 audio_url可能含签名 token
log.Printf("audio url: %s", story.AudioURL)
```
---
## 四、测试规范
```go
// 测试文件命名:<被测文件>_test.go
// 测试函数命名Test<FunctionName>_<Scenario>
func TestFetchStoryByMAC_Success(t *testing.T) { ... }
func TestFetchStoryByMAC_DeviceNotFound(t *testing.T) { ... }
func TestSendOpusStream_AbortMidway(t *testing.T) { ... }
```
- 使用 `net/http/httptest` mock RTC 后端 HTTP 接口
- 音频转码测试使用真实小文件(`testdata/short.mp3`< 5s
- 不测试 WebSocket 集成逻辑由端到端脚本覆盖
---
## 五、常用命令
```bash
# 编译(在 hw_service_go/ 目录下)
go build ./...
# 静态检查
go vet ./...
# 本地运行
HW_RTC_BACKEND_URL=http://localhost:8000 go run ./cmd/main.go
# 运行测试
go test ./... -v -race # -race 开启竞态检测
# 格式化(提交前必须执行)
gofmt -w .
goimports -w . # 需安装: go install golang.org/x/tools/cmd/goimports@latest
# 构建 Docker 镜像
docker build -t hw-ws-service:dev .
# 查看 goroutine 泄漏(开发调试)
curl http://localhost:8888/debug/pprof/goroutine?debug=1
```
---
## 六、开发检查清单
**新增功能前:**
- [ ] 消息处理函数签名是否为 `func Handle<Type>(conn *connection.Connection, raw []byte)`
- [ ] 是否正确使用 `context.Context` 传递超时
- [ ] 是否有 goroutine 退出机制channel / context
**提交代码前:**
- [ ] `gofmt -w .` 格式化通过
- [ ] `go vet ./...` 无警告
- [ ] `go test ./... -race` data race
- [ ] exec.Command 参数**不包含任何**来自外部的数据
- [ ] 所有 HTTP 客户端调用都有超时设置
- [ ] 新增环境变量已更新 `.env.example` `k8s/deployment.yaml`
**安全 review 要点:**
- [ ] `audio/convert.go`audioURL 是否只经过 `http.Get()`没有进入 `exec.Command`
- [ ] WebSocket `SetReadLimit` 是否已设置
- [ ] 新增 goroutine 是否有对应的 `wg.Add(1)` `defer wg.Done()`
---
## 参考资料
- [Effective Go](https://go.dev/doc/effective_go)
- [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments)
- [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md)
- [gorilla/websocket 文档](https://pkg.go.dev/github.com/gorilla/websocket)
- [hraban/opus 文档](https://pkg.go.dev/github.com/hraban/opus)