Some checks failed
Build and Deploy Backend / build-and-deploy (push) Failing after 4m28s
## 变更内容
### 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>
397 lines
11 KiB
Markdown
397 lines
11 KiB
Markdown
# 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.go:ffmpeg 超时保护(防止卡死)
|
||
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)
|