# 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)
