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>
11 KiB
11 KiB
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 T,recv 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.go 的 switch envelope.Type 路由。新增类型时:
- 在
handler/下创建<type>.go - 函数签名必须为:
func Handle<Type>(conn *connection.Connection, raw []byte) - 在
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 请求的 URL,由net/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.go:ffmpeg 超时保护(防止卡死)
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/httptestmock 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.example和k8s/deployment.yaml
安全 review 要点:
audio/convert.go:audioURL 是否只经过http.Get(),没有进入exec.Command- WebSocket
SetReadLimit是否已设置 - 新增 goroutine 是否有对应的
wg.Add(1)和defer wg.Done()