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

11 KiB
Raw Permalink Blame History

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 错误处理

// ✅ 正确:始终包装上下文
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 使用

// ✅ 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

// ✅ 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 Trecv chan<- T,减少误用

1.5 结构体初始化

// ✅ 始终使用字段名初始化(顺序变更不会引入 bug
conn := &Connection{
    WS:       ws,
    DeviceID: deviceID,
    ClientID: clientID,
}

// ❌ 位置初始化(字段顺序改变后静默错误)
conn := &Connection{ws, deviceID, clientID}

1.6 接口设计

// ✅ 在使用方定义接口(而非实现方)
// audio/convert.go 不定义接口,由 handler 包定义它需要的最小接口
package handler

type AudioConverter interface {
    Convert(ctx context.Context, url string) ([][]byte, error)
}

二、代码生成规范

2.1 新增消息类型处理器

硬件消息类型通过 server.goswitch envelope.Type 路由。新增类型时:

  1. handler/ 下创建 <type>.go
  2. 函数签名必须为:func Handle<Type>(conn *connection.Connection, raw []byte)
  3. server.go 的 switch 中注册
// server.go
switch envelope.Type {
case "story":
    go handler.HandleStory(conn, raw)
case "music":                          // 新增
    go handler.HandleMusic(conn, raw)  // 新增
}

2.2 新增配置项

所有配置只能通过环境变量注入,不允许读取配置文件或命令行参数(保持 12-Factor App 原则):

// 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", ...) 时,所有参数必须是硬编码常量,绝对不能包含任何用户输入

// ✅ 安全:参数全部硬编码
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 请求的 URLnet/http 处理,永远不进入 exec.Command 的参数列表。

3.2 WebSocket 输入验证

// server.go设置消息大小上限防止内存耗尽攻击
upgrader := websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    CheckOrigin: func(r *http.Request) bool {
        return true  // IoT 设备无 Origin允许所有来源
    },
}

// 连接建立后立即设置读限制
ws.SetReadLimit(4 * 1024)  // 文本消息上限 4KB硬件不会发大消息
// 解析 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 资源耗尽防护

// 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()  // 踢掉旧连接
}
// audio/convert.goffmpeg 超时保护(防止卡死)
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "ffmpeg", ...)

3.4 HTTP 客户端安全

// 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 泄漏防护

// ✅ 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 日志安全

// ✅ 日志中不输出敏感信息
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)

四、测试规范

// 测试文件命名:<被测文件>_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 集成逻辑(由端到端脚本覆盖)

五、常用命令

# 编译(在 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.examplek8s/deployment.yaml

安全 review 要点:

  • audio/convert.goaudioURL 是否只经过 http.Get(),没有进入 exec.Command
  • WebSocket SetReadLimit 是否已设置
  • 新增 goroutine 是否有对应的 wg.Add(1)defer wg.Done()

参考资料