# 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/` 下创建 `.go` 2. 函数签名必须为:`func Handle(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_ 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(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)