feat(hw-ws-service): 将 Go WebSocket 服务纳入 CI/CD 并通过 Traefik 统一入口
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>
This commit is contained in:
repair-agent 2026-03-02 17:16:26 +08:00
parent 5fb0db5da0
commit c219ec2fcf
23 changed files with 2214 additions and 0 deletions

View File

@ -37,6 +37,15 @@ jobs:
--tag ${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}/rtc-backend:latest \
. 2>&1 | tee /tmp/build.log
- name: Build and Push HW WebSocket Service
run: |
set -o pipefail
docker buildx build \
--push \
--provenance=false \
--tag ${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}/hw-ws-service:latest \
./hw_service_go 2>&1 | tee -a /tmp/build.log
- name: Setup Kubectl
run: |
curl -LO "https://dl.k8s.io/release/v1.28.2/bin/linux/amd64/kubectl" || \
@ -68,13 +77,17 @@ jobs:
# 2. 替换镜像地址
sed -i "s|\${CI_REGISTRY_IMAGE}/backend:latest|${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}/rtc-backend:latest|g" $DEPLOY_FILE
sed -i "s|\${CI_REGISTRY_IMAGE}/hw-ws-service:latest|${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}/hw-ws-service:latest|g" hw_service_go/k8s/deployment.yaml
# 3. 应用配置并捕获输出
set -o pipefail
{
kubectl apply -f $DEPLOY_FILE
kubectl apply -f $INGRESS_FILE
kubectl apply -f hw_service_go/k8s/deployment.yaml
kubectl apply -f hw_service_go/k8s/service.yaml
kubectl rollout restart deployment/$DEPLOY_NAME
kubectl rollout restart deployment/hw-ws-service
} 2>&1 | tee /tmp/deploy.log
- name: Report failure to Log Center

View File

@ -0,0 +1,13 @@
# hw-ws-service 环境变量示例
# 复制为 .env 并填入实际值(.env 不提交 git
# WebSocket 监听地址(默认 0.0.0.0
HW_WS_HOST=0.0.0.0
# WebSocket 监听端口(默认 8888
HW_WS_PORT=8888
# RTC 后端地址(必填)
# K8s 内部http://rtc-backend-svc:8000
# 本地开发http://localhost:8000
HW_RTC_BACKEND_URL=http://localhost:8000

396
hw_service_go/CLAUDE.md Normal file
View File

@ -0,0 +1,396 @@
# 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.goffmpeg 超时保护(防止卡死)
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)

49
hw_service_go/Dockerfile Normal file
View File

@ -0,0 +1,49 @@
# ============================================================
# hw-ws-service Dockerfile — 多阶段构建
# 构建阶段Go 编译(含 CGO for libopus
# 运行阶段Alpine + libopus + ffmpeg最终镜像 ~60-80MB
# ============================================================
# ---- 构建阶段 ----
FROM golang:1.23-alpine AS builder
# 安装 CGO 编译所需的 C 工具链和 libopus 开发头文件
RUN apk add --no-cache gcc musl-dev opus-dev
WORKDIR /app
# 先拷贝 go.mod/go.sum 利用 Docker 层缓存(依赖未变时跳过 go mod download
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=1 必须开启hraban/opus 是 CGO 库)
# -trimpath 去除本地路径信息(安全性)
# -ldflags="-s -w" 去除调试符号(缩减二进制大小)
RUN CGO_ENABLED=1 GOOS=linux \
go build \
-trimpath \
-ldflags="-s -w" \
-o hw-ws-service \
./cmd/main.go
# ---- 运行阶段 ----
FROM alpine:3.20
# 运行时依赖:
# opus — libopus 动态库hraban/opus CGO 绑定需要)
# ffmpeg — MP3/AAC 解码为 PCM
# ca-certificates — HTTPS 请求 OSS 需要根证书
RUN apk add --no-cache opus ffmpeg ca-certificates && \
# 创建非 root 运行用户(安全最佳实践)
addgroup -S hwws && adduser -S hwws -G hwws
COPY --from=builder /app/hw-ws-service /hw-ws-service
# 以非 root 用户运行
USER hwws
EXPOSE 8888
ENTRYPOINT ["/hw-ws-service"]

49
hw_service_go/cmd/main.go Normal file
View File

@ -0,0 +1,49 @@
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/qy/hw-ws-service/internal/config"
"github.com/qy/hw-ws-service/internal/rtcclient"
"github.com/qy/hw-ws-service/internal/server"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[hw-ws] ")
cfg := config.Load()
addr := cfg.WSHost + ":" + cfg.WSPort
client := rtcclient.New(cfg.RTCBackendURL)
srv := server.New(addr, client)
// 后台启动服务器
serverErr := make(chan error, 1)
go func() {
serverErr <- srv.ListenAndServe()
}()
// 监听系统信号K8s 滚动更新发送 SIGTERM
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
select {
case err := <-serverErr:
log.Fatalf("server error: %v", err)
case sig := <-sigCh:
log.Printf("received signal: %v, starting graceful shutdown...", sig)
}
// 优雅关闭:最长 80s与 K8s terminationGracePeriodSeconds=90 配合)
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Second)
defer cancel()
srv.Shutdown(ctx)
log.Println("shutdown complete")
}

8
hw_service_go/go.mod Normal file
View File

@ -0,0 +1,8 @@
module github.com/qy/hw-ws-service
go 1.23
require (
github.com/gorilla/websocket v1.5.3
github.com/hraban/opus v0.0.0-20230925203106-0188a62cb302
)

4
hw_service_go/go.sum Normal file
View File

@ -0,0 +1,4 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hraban/opus v0.0.0-20230925203106-0188a62cb302 h1:K7bmEmIesLcvCW0Ic2rCk6LtP5++nTnPmrO8mg5umlA=
github.com/hraban/opus v0.0.0-20230925203106-0188a62cb302/go.mod h1:YQQXrWHN3JEvCtw5ImyTCcPeU/ZLo/YMA+TpB64XdrU=

View File

@ -0,0 +1,13 @@
// Package audio 提供音频格式转换功能:从 URL 下载 MP3转码为 Opus 帧列表。
// 全程使用 ffmpeg stdin/stdout pipe不写临时文件。
package audio
const (
SampleRate = 16000
Channels = 1
FrameDurationMs = 60
// FrameSize 是每个 Opus 帧包含的 PCM 采样数16bit
FrameSize = SampleRate * FrameDurationMs / 1000 // 960 samples
// PreBufferCount 是流控前快速预发送的帧数,减少硬件首帧延迟。
PreBufferCount = 3
)

View File

@ -0,0 +1,127 @@
package audio
import (
"context"
"encoding/binary"
"fmt"
"io"
"net/http"
"os/exec"
"time"
"github.com/hraban/opus"
)
// MP3URLToOpusFrames 从 audioURL 下载音频,通过 ffmpeg pipe 解码为 PCM
// 再用 libopus 编码为 60ms 帧列表,全程流式处理不落磁盘。
//
// ⚠️ 安全约束audioURL 只能作为 http.Get 的参数,
// 绝对不能出现在 exec.Command 的参数列表中(防止命令注入)。
func MP3URLToOpusFrames(ctx context.Context, audioURL string) ([][]byte, error) {
// 1. 下载音频(流式,不全量载入内存)
httpCtx, httpCancel := context.WithTimeout(ctx, 60*time.Second)
defer httpCancel()
req, err := http.NewRequestWithContext(httpCtx, http.MethodGet, audioURL, nil)
if err != nil {
return nil, fmt.Errorf("audio: build request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("audio: download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("audio: download status %d", resp.StatusCode)
}
// 2. ffmpegstdin 读原始音频stdout 输出 s16le PCM16kHz 单声道)
// 所有参数硬编码audioURL 不进入命令行(防命令注入)
ffmpegCtx, ffmpegCancel := context.WithTimeout(ctx, 120*time.Second)
defer ffmpegCancel()
cmd := exec.CommandContext(ffmpegCtx,
"ffmpeg",
"-nostdin",
"-loglevel", "error", // 只输出错误,不污染 stdout pipe
"-i", "pipe:0", // 从 stdin 读输入
"-ar", "16000", // 目标采样率
"-ac", "1", // 单声道
"-f", "s16le", // 输出格式:有符号 16bit 小端 PCM
"pipe:1", // 输出到 stdout
)
cmd.Stdin = resp.Body // HTTP body 直接接 ffmpeg stdin不经过磁盘
pcmReader, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("audio: stdout pipe: %w", err)
}
stderrPipe, _ := cmd.StderrPipe()
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("audio: start ffmpeg: %w", err)
}
// 3. 逐帧读取 PCM 并实时 Opus 编码
enc, err := opus.NewEncoder(SampleRate, Channels, opus.AppAudio)
if err != nil {
return nil, fmt.Errorf("audio: create encoder: %w", err)
}
pcmBuf := make([]int16, FrameSize) // 960 int16 samples
opusBuf := make([]byte, 4000) // Opus 输出缓冲4KB 足够单帧)
var frames [][]byte
for {
err := binary.Read(pcmReader, binary.LittleEndian, pcmBuf)
if err == io.EOF || err == io.ErrUnexpectedEOF {
// 最后一帧不足时已补零binary.Read 会读已有字节),直接编码
if err == io.ErrUnexpectedEOF {
n, encErr := enc.Encode(pcmBuf, opusBuf)
if encErr == nil && n > 0 {
frame := make([]byte, n)
copy(frame, opusBuf[:n])
frames = append(frames, frame)
}
}
break
}
if err != nil {
// ffmpeg 已结束context cancel 等),读取结束
break
}
n, err := enc.Encode(pcmBuf, opusBuf)
if err != nil {
return nil, fmt.Errorf("audio: opus encode: %w", err)
}
frame := make([]byte, n)
copy(frame, opusBuf[:n])
frames = append(frames, frame)
}
// 排空 stderr 避免 ffmpeg 阻塞
io.Copy(io.Discard, stderrPipe)
if err := cmd.Wait(); err != nil {
// context 超时导致的退出不视为错误(已有 frames 可以播放)
if ffmpegCtx.Err() == nil {
return nil, fmt.Errorf("audio: ffmpeg exit: %w", err)
}
}
if len(frames) == 0 {
return nil, fmt.Errorf("audio: no frames produced from %s", truncateURL(audioURL))
}
return frames, nil
}
// truncateURL 截断 URL 用于日志,避免输出带签名的完整 URL。
func truncateURL(u string) string {
if len(u) > 80 {
return u[:80] + "..."
}
return u
}

View File

@ -0,0 +1,33 @@
package config
import (
"log"
"os"
)
// Config 保存所有服务配置全部通过环境变量注入12-Factor App
type Config struct {
WSHost string
WSPort string
RTCBackendURL string
}
// Load 从环境变量读取配置,必填项缺失时直接 Fatal。
func Load() *Config {
backendURL := getEnv("HW_RTC_BACKEND_URL", "")
if backendURL == "" {
log.Fatal("config: HW_RTC_BACKEND_URL is required")
}
return &Config{
WSHost: getEnv("HW_WS_HOST", "0.0.0.0"),
WSPort: getEnv("HW_WS_PORT", "8888"),
RTCBackendURL: backendURL,
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@ -0,0 +1,85 @@
// Package connection 管理单个 ESP32 硬件 WebSocket 连接的状态。
package connection
import (
"encoding/json"
"fmt"
"sync"
"github.com/gorilla/websocket"
)
// Connection 保存单个硬件连接的状态,所有方法并发安全。
type Connection struct {
WS *websocket.Conn
DeviceID string // MAC 地址,来自 URL 参数 device-id
ClientID string // 来自 URL 参数 client-id
mu sync.Mutex
isPlaying bool
abortCh chan struct{} // close(abortCh) 通知流控 goroutine 中止播放
writeMu sync.Mutex // gorilla/websocket 写操作不并发安全,需独立锁
}
// New 创建新连接对象。
func New(ws *websocket.Conn, deviceID, clientID string) *Connection {
return &Connection{
WS: ws,
DeviceID: deviceID,
ClientID: clientID,
}
}
// StartPlayback 开始新一轮播放,返回 abortCh 供流控 goroutine 监听。
// 若已在播放,先中止上一轮再开始新的。
func (c *Connection) StartPlayback() <-chan struct{} {
c.mu.Lock()
defer c.mu.Unlock()
// 中止上一轮播放(若有)
if c.isPlaying && c.abortCh != nil {
close(c.abortCh)
}
c.abortCh = make(chan struct{})
c.isPlaying = true
return c.abortCh
}
// StopPlayback 结束播放状态。
func (c *Connection) StopPlayback() {
c.mu.Lock()
defer c.mu.Unlock()
c.isPlaying = false
}
// IsPlaying 返回当前是否正在播放。
func (c *Connection) IsPlaying() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.isPlaying
}
// SendJSON 序列化 v 并以文本帧发送给设备,并发安全。
func (c *Connection) SendJSON(v any) error {
data, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("connection: marshal json: %w", err)
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
return c.WS.WriteMessage(websocket.TextMessage, data)
}
// SendBinary 以二进制帧发送 Opus 数据,并发安全。
func (c *Connection) SendBinary(data []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
return c.WS.WriteMessage(websocket.BinaryMessage, data)
}
// Close 关闭底层 WebSocket 连接。
func (c *Connection) Close() {
c.WS.Close()
}

View File

@ -0,0 +1,177 @@
package connection_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/qy/hw-ws-service/internal/connection"
)
// makeWSPair creates a real WebSocket pair for testing.
// Returns the server-side conn (what our code uses) and the client-side conn
// (what simulates the hardware). Call cleanup() after the test.
func makeWSPair(t *testing.T) (svrWS *websocket.Conn, cliWS *websocket.Conn, cleanup func()) {
t.Helper()
ch := make(chan *websocket.Conn, 1)
done := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
up := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
c, err := up.Upgrade(w, r, nil)
if err != nil {
t.Logf("upgrade error: %v", err)
return
}
ch <- c
<-done // hold handler open until cleanup
}))
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
cli, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
close(done)
srv.Close()
t.Fatalf("dial error: %v", err)
}
svr := <-ch
return svr, cli, func() {
close(done)
svr.Close()
cli.Close()
srv.Close()
}
}
func TestConnection_InitialState(t *testing.T) {
svrWS, _, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "AA:BB:CC:DD:EE:FF", "client-uuid")
if conn.DeviceID != "AA:BB:CC:DD:EE:FF" {
t.Errorf("DeviceID = %q", conn.DeviceID)
}
if conn.ClientID != "client-uuid" {
t.Errorf("ClientID = %q", conn.ClientID)
}
if conn.IsPlaying() {
t.Error("new connection should not be playing")
}
}
func TestConnection_StartStopPlayback(t *testing.T) {
svrWS, _, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
ch := conn.StartPlayback()
if ch == nil {
t.Fatal("StartPlayback should return a non-nil channel")
}
if !conn.IsPlaying() {
t.Error("IsPlaying should be true after StartPlayback")
}
// Channel must still be open
select {
case <-ch:
t.Error("abortCh should not be closed yet")
default:
}
conn.StopPlayback()
if conn.IsPlaying() {
t.Error("IsPlaying should be false after StopPlayback")
}
}
// TestConnection_StartPlayback_AbortsOld verifies that calling StartPlayback a second
// time closes the previous abort channel, stopping any in-progress streaming.
func TestConnection_StartPlayback_AbortsOld(t *testing.T) {
svrWS, _, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
ch1 := conn.StartPlayback()
ch2 := conn.StartPlayback() // should close ch1
// ch1 must be closed now
select {
case <-ch1:
// expected
case <-time.After(100 * time.Millisecond):
t.Error("first abortCh should be closed by second StartPlayback call")
}
// ch2 must still be open
select {
case <-ch2:
t.Error("second abortCh should not be closed yet")
default:
}
}
// TestConnection_SendJSON verifies JSON messages are delivered to the client.
func TestConnection_SendJSON(t *testing.T) {
svrWS, cliWS, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
if err := conn.SendJSON(map[string]string{"type": "tts", "state": "start"}); err != nil {
t.Fatalf("SendJSON error: %v", err)
}
cliWS.SetReadDeadline(time.Now().Add(2 * time.Second))
msgType, data, err := cliWS.ReadMessage()
if err != nil {
t.Fatalf("client read error: %v", err)
}
if msgType != websocket.TextMessage {
t.Errorf("message type = %d, want TextMessage (%d)", msgType, websocket.TextMessage)
}
var got map[string]string
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("json.Unmarshal error: %v", err)
}
if got["type"] != "tts" {
t.Errorf("type = %q, want %q", got["type"], "tts")
}
if got["state"] != "start" {
t.Errorf("state = %q, want %q", got["state"], "start")
}
}
// TestConnection_SendBinary verifies binary (Opus) frames are delivered to the client.
func TestConnection_SendBinary(t *testing.T) {
svrWS, cliWS, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
payload := []byte{0x01, 0x02, 0x03, 0x04}
if err := conn.SendBinary(payload); err != nil {
t.Fatalf("SendBinary error: %v", err)
}
cliWS.SetReadDeadline(time.Now().Add(2 * time.Second))
msgType, data, err := cliWS.ReadMessage()
if err != nil {
t.Fatalf("client read error: %v", err)
}
if msgType != websocket.BinaryMessage {
t.Errorf("message type = %d, want BinaryMessage (%d)", msgType, websocket.BinaryMessage)
}
if string(data) != string(payload) {
t.Errorf("payload = %v, want %v", data, payload)
}
}

View File

@ -0,0 +1,63 @@
package handler
import (
"time"
"github.com/qy/hw-ws-service/internal/audio"
"github.com/qy/hw-ws-service/internal/connection"
)
// SendOpusStream 将 Opus 帧列表按 60ms/帧的节奏流控发送给硬件。
//
// 流控策略:
// 1. 预缓冲:前 PreBufferCount 帧立即发送,减少硬件首帧延迟
// 2. 时序流控:按 (帧序号 × 60ms) 计算期望发送时间select 等待
// 3. 打断:监听 abortCh收到关闭信号立即返回
func SendOpusStream(conn *connection.Connection, frames [][]byte, abortCh <-chan struct{}) {
if len(frames) == 0 {
return
}
startTime := time.Now()
playedMs := 0
// 阶段1预缓冲快速发送前 N 帧
pre := audio.PreBufferCount
if pre > len(frames) {
pre = len(frames)
}
for _, f := range frames[:pre] {
select {
case <-abortCh:
return
default:
}
conn.SendBinary(f) //nolint:errcheck // 连接断开时下一次 ReadMessage 会返回错误
}
playedMs = pre * audio.FrameDurationMs
// 阶段2时序流控
for _, f := range frames[pre:] {
expectedAt := startTime.Add(time.Duration(playedMs) * time.Millisecond)
delay := time.Until(expectedAt)
if delay > 0 {
select {
case <-time.After(delay):
// 到达预期发送时间,继续
case <-abortCh:
return
}
}
// delay <= 0处理比预期慢追赶进度直接发送
select {
case <-abortCh:
return
default:
}
conn.SendBinary(f) //nolint:errcheck
playedMs += audio.FrameDurationMs
}
}

View File

@ -0,0 +1,195 @@
package handler_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/qy/hw-ws-service/internal/audio"
"github.com/qy/hw-ws-service/internal/connection"
"github.com/qy/hw-ws-service/internal/handler"
)
// makeWSPair creates a real WebSocket pair for testing.
// svrWS is the server side (used by our Connection), cliWS simulates the hardware.
func makeWSPair(t *testing.T) (svrWS *websocket.Conn, cliWS *websocket.Conn, cleanup func()) {
t.Helper()
ch := make(chan *websocket.Conn, 1)
done := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
up := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
c, err := up.Upgrade(w, r, nil)
if err != nil {
t.Logf("upgrade error: %v", err)
return
}
ch <- c
<-done
}))
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
cli, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
close(done)
srv.Close()
t.Fatalf("dial error: %v", err)
}
svr := <-ch
return svr, cli, func() {
close(done)
svr.Close()
cli.Close()
srv.Close()
}
}
// makeFrames creates n fake Opus frames of 4 bytes each.
func makeFrames(n int) [][]byte {
frames := make([][]byte, n)
for i := range frames {
frames[i] = []byte{byte(i), byte(i >> 8), 0x00, 0xff}
}
return frames
}
// TestSendOpusStream_Empty verifies that an empty frame list returns immediately.
func TestSendOpusStream_Empty(t *testing.T) {
svrWS, _, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
abort := make(chan struct{})
done := make(chan struct{})
go func() {
handler.SendOpusStream(conn, nil, abort)
close(done)
}()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("SendOpusStream did not return immediately for empty frames")
}
}
// TestSendOpusStream_AllFramesSent verifies that all frames reach the client.
// Uses PreBufferCount+2 frames so both pre-buffer and timed paths are exercised.
func TestSendOpusStream_AllFramesSent(t *testing.T) {
svrWS, cliWS, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
totalFrames := audio.PreBufferCount + 2 // 3 pre-buffer + 2 timed
frames := makeFrames(totalFrames)
abort := make(chan struct{})
senderDone := make(chan struct{})
go func() {
handler.SendOpusStream(conn, frames, abort)
close(senderDone)
}()
// Read all frames from the client side (simulates hardware receiving)
received := 0
cliWS.SetReadDeadline(time.Now().Add(10 * time.Second))
for received < totalFrames {
msgType, _, err := cliWS.ReadMessage()
if err != nil {
t.Fatalf("client read error after %d frames: %v", received, err)
}
if msgType == websocket.BinaryMessage {
received++
}
}
select {
case <-senderDone:
case <-time.After(2 * time.Second):
t.Fatal("SendOpusStream did not finish after all frames were sent")
}
if received != totalFrames {
t.Errorf("received %d frames, want %d", received, totalFrames)
}
}
// TestSendOpusStream_Abort verifies that closing abortCh stops streaming early.
func TestSendOpusStream_Abort(t *testing.T) {
svrWS, _, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
// Many frames so timing control is active (pre-buffer finishes quickly,
// then the time.After select can receive the abort signal)
frames := makeFrames(100)
abort := make(chan struct{})
senderDone := make(chan struct{})
go func() {
handler.SendOpusStream(conn, frames, abort)
close(senderDone)
}()
// Close abort after pre-buffer has had time to finish but before timed frames complete
time.Sleep(20 * time.Millisecond)
close(abort)
select {
case <-senderDone:
// SendOpusStream returned early — correct behaviour
case <-time.After(2 * time.Second):
t.Fatal("SendOpusStream did not abort within 2s after closing abortCh")
}
}
// TestSendOpusStream_PreBufferOnly verifies frames <= PreBufferCount are all sent
// without entering the timed loop (should finish nearly instantly).
func TestSendOpusStream_PreBufferOnly(t *testing.T) {
svrWS, cliWS, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
frames := makeFrames(audio.PreBufferCount) // exactly the pre-buffer count
abort := make(chan struct{})
start := time.Now()
senderDone := make(chan struct{})
go func() {
handler.SendOpusStream(conn, frames, abort)
close(senderDone)
}()
received := 0
cliWS.SetReadDeadline(time.Now().Add(3 * time.Second))
for received < len(frames) {
msgType, _, err := cliWS.ReadMessage()
if err != nil {
t.Fatalf("read error: %v", err)
}
if msgType == websocket.BinaryMessage {
received++
}
}
select {
case <-senderDone:
case <-time.After(time.Second):
t.Fatal("sender did not finish")
}
elapsed := time.Since(start)
// Pre-buffer frames should not wait on the timer; allow 200ms for overhead
if elapsed > 200*time.Millisecond {
t.Errorf("pre-buffer-only send took too long: %v (want < 200ms)", elapsed)
}
}

View File

@ -0,0 +1,73 @@
package handler
import (
"context"
"log"
"time"
"github.com/qy/hw-ws-service/internal/audio"
"github.com/qy/hw-ws-service/internal/connection"
"github.com/qy/hw-ws-service/internal/rtcclient"
)
// HandleStory 处理硬件发来的 {"type":"story"} 指令。
// 在独立 goroutine 中调用,不阻塞消息读取循环。
func HandleStory(conn *connection.Connection, client *rtcclient.Client) {
tag := "[story][" + conn.DeviceID + "]"
// 整个故事播放流程最长允许 10 分钟
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
// 1. 通知硬件TTS 开始
if err := conn.SendJSON(map[string]string{"type": "tts", "state": "start"}); err != nil {
log.Printf("%s send start failed: %v", tag, err)
return
}
// 确保异常退出时也发送 stop避免硬件卡住
defer func() {
conn.StopPlayback()
conn.SendJSON(map[string]string{"type": "tts", "state": "stop"}) //nolint:errcheck
}()
// 2. 调用 RTC 后端获取故事
story, err := client.FetchStoryByMAC(ctx, conn.DeviceID)
if err != nil {
log.Printf("%s fetch story error: %v", tag, err)
return
}
if story == nil {
log.Printf("%s no story available", tag)
return
}
log.Printf("%s playing: %s", tag, story.Title)
// 3. 下载 MP3 并转码为 Opus 帧CPU 密集,在当前 goroutine 中执行)
frames, err := audio.MP3URLToOpusFrames(ctx, story.AudioURL)
if err != nil {
log.Printf("%s audio convert error: %v", tag, err)
return
}
log.Printf("%s converted %d frames (~%.1fs)", tag, len(frames),
float64(len(frames)*audio.FrameDurationMs)/1000)
// 4. 通知硬件:句子开始(发送故事标题)
if err := conn.SendJSON(map[string]any{
"type": "tts",
"state": "sentence_start",
"text": story.Title,
}); err != nil {
log.Printf("%s send sentence_start failed: %v", tag, err)
return
}
// 5. 开始播放,获取打断 channel
abortCh := conn.StartPlayback()
// 6. 流控推送 Opus 帧
SendOpusStream(conn, frames, abortCh)
log.Printf("%s playback finished", tag)
// defer 会发送 stop 并调用 StopPlayback
}

View File

@ -0,0 +1,100 @@
// Package rtcclient 封装对 RTC 后端 Django REST API 的 HTTP 调用。
package rtcclient
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// StoryInfo 是 GET /api/v1/devices/stories/ 返回的故事信息。
type StoryInfo struct {
Title string `json:"title"`
AudioURL string `json:"audio_url"`
}
// Client 是 RTC 后端的 HTTP 客户端,复用连接池。
type Client struct {
baseURL string
httpClient *http.Client
}
// New 创建 ClientbaseURL 形如 "http://rtc-backend-svc:8000"。
func New(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 50,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
// 限制重定向次数,防止无限跳转
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("rtcclient: too many redirects")
}
return nil
},
},
}
}
// rtcResponse 是 RTC 后端的统一响应结构。
type rtcResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
// FetchStoryByMAC 通过设备 MAC 地址获取随机故事。
// 返回 nil, nil 表示设备/用户/故事不存在(非错误,调用方直接跳过)。
func (c *Client) FetchStoryByMAC(ctx context.Context, mac string) (*StoryInfo, error) {
url := c.baseURL + "/api/v1/devices/stories/"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("rtcclient: build request: %w", err)
}
q := req.URL.Query()
q.Set("mac_address", strings.ToUpper(mac))
req.URL.RawQuery = q.Encode()
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("rtcclient: request failed: %w", err)
}
defer resp.Body.Close()
// 404 表示设备/用户/故事不存在,不是服务器错误
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("rtcclient: unexpected status %d", resp.StatusCode)
}
var result rtcResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("rtcclient: decode response: %w", err)
}
if result.Code != 0 {
return nil, nil // 业务错误(如暂无故事),返回 nil 让调用方处理
}
var story StoryInfo
if err := json.Unmarshal(result.Data, &story); err != nil {
return nil, fmt.Errorf("rtcclient: decode story: %w", err)
}
if story.Title == "" || story.AudioURL == "" {
return nil, nil
}
return &story, nil
}

View File

@ -0,0 +1,142 @@
package rtcclient_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/qy/hw-ws-service/internal/rtcclient"
)
func successBody(title, audioURL string) []byte {
b, _ := json.Marshal(map[string]any{
"code": 0,
"message": "success",
"data": map[string]string{
"title": title,
"audio_url": audioURL,
},
})
return b
}
func errorBody(code int, msg string) []byte {
b, _ := json.Marshal(map[string]any{
"code": code,
"message": msg,
"data": nil,
})
return b
}
func TestFetchStoryByMAC_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/devices/stories/" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write(successBody("小红帽", "https://example.com/story.mp3"))
}))
defer srv.Close()
client := rtcclient.New(srv.URL)
story, err := client.FetchStoryByMAC(context.Background(), "aa:bb:cc:dd:ee:ff")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if story == nil {
t.Fatal("expected story, got nil")
}
if story.Title != "小红帽" {
t.Errorf("title = %q, want %q", story.Title, "小红帽")
}
if story.AudioURL != "https://example.com/story.mp3" {
t.Errorf("audio_url = %q", story.AudioURL)
}
}
// TestFetchStoryByMAC_MACUppercase verifies the client always sends uppercase MAC.
func TestFetchStoryByMAC_MACUppercase(t *testing.T) {
var gotMAC string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMAC = r.URL.Query().Get("mac_address")
w.Header().Set("Content-Type", "application/json")
w.Write(successBody("test", "https://example.com/t.mp3"))
}))
defer srv.Close()
client := rtcclient.New(srv.URL)
client.FetchStoryByMAC(context.Background(), "aa:bb:cc:dd:ee:ff") //nolint:errcheck
if gotMAC != "AA:BB:CC:DD:EE:FF" {
t.Errorf("MAC not uppercased: got %q, want %q", gotMAC, "AA:BB:CC:DD:EE:FF")
}
}
// TestFetchStoryByMAC_NotFound verifies that HTTP 404 returns (nil, nil).
func TestFetchStoryByMAC_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
client := rtcclient.New(srv.URL)
story, err := client.FetchStoryByMAC(context.Background(), "AA:BB:CC:DD:EE:FF")
if err != nil {
t.Fatalf("unexpected error for 404: %v", err)
}
if story != nil {
t.Errorf("expected nil story for 404, got %+v", story)
}
}
// TestFetchStoryByMAC_BusinessError verifies that code != 0 returns (nil, nil).
func TestFetchStoryByMAC_BusinessError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(errorBody(404, "暂无可播放的故事"))
}))
defer srv.Close()
client := rtcclient.New(srv.URL)
story, err := client.FetchStoryByMAC(context.Background(), "AA:BB:CC:DD:EE:FF")
if err != nil {
t.Fatalf("unexpected error for business error response: %v", err)
}
if story != nil {
t.Errorf("expected nil story for business error, got %+v", story)
}
}
// TestFetchStoryByMAC_ServerError verifies that HTTP 5xx returns an error.
func TestFetchStoryByMAC_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
client := rtcclient.New(srv.URL)
_, err := client.FetchStoryByMAC(context.Background(), "AA:BB:CC:DD:EE:FF")
if err == nil {
t.Error("expected error for HTTP 500, got nil")
}
}
// TestFetchStoryByMAC_ContextCanceled verifies that a canceled context returns an error.
func TestFetchStoryByMAC_ContextCanceled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Never respond — let the client time out
<-r.Context().Done()
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
client := rtcclient.New(srv.URL)
_, err := client.FetchStoryByMAC(ctx, "AA:BB:CC:DD:EE:FF")
if err == nil {
t.Error("expected error for canceled context, got nil")
}
}

View File

@ -0,0 +1,216 @@
// Package server 实现 WebSocket 服务器,管理硬件设备连接的生命周期。
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"sync"
"github.com/gorilla/websocket"
"github.com/qy/hw-ws-service/internal/connection"
"github.com/qy/hw-ws-service/internal/handler"
"github.com/qy/hw-ws-service/internal/rtcclient"
)
const (
// maxConnections 最大并发连接数,防止资源耗尽。
maxConnections = 500
// maxMessageBytes WebSocket 单条消息上限4KB防止内存耗尽攻击。
maxMessageBytes = 4 * 1024
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// IoT 设备无浏览器 Origin允许所有来源
CheckOrigin: func(r *http.Request) bool { return true },
}
// Server 管理所有活跃的设备连接。
type Server struct {
client *rtcclient.Client
httpServer *http.Server
mu sync.Mutex
conns map[string]*connection.Connection // key: DeviceID
wg sync.WaitGroup // 跟踪所有连接 goroutine
}
// New 创建 Serveraddr 形如 "0.0.0.0:8888"。
func New(addr string, client *rtcclient.Client) *Server {
s := &Server{
client: client,
conns: make(map[string]*connection.Connection),
}
mux := http.NewServeMux()
mux.HandleFunc("/xiaozhi/v1/healthz", s.handleStatus)
mux.HandleFunc("/xiaozhi/v1/", s.handleConn)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
s.httpServer = &http.Server{
Addr: addr,
Handler: mux,
}
return s
}
// ListenAndServe 启动服务器,阻塞直到服务器关闭。
func (s *Server) ListenAndServe() error {
log.Printf("server: listening on %s", s.httpServer.Addr)
err := s.httpServer.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
// Shutdown 优雅关闭:先停止接受新连接,再等待所有连接 goroutine 退出。
func (s *Server) Shutdown(ctx context.Context) {
log.Println("server: shutting down...")
s.httpServer.Shutdown(ctx) //nolint:errcheck
// 等待所有连接 goroutine 退出(由 ctx 超时兜底)
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
log.Println("server: all connections closed gracefully")
case <-ctx.Done():
log.Println("server: shutdown timeout, forcing close")
}
}
// handleConn 处理单个 WebSocket 连接的完整生命周期。
// URL 格式:/xiaozhi/v1/?device-id=<MAC>&client-id=<UUID>
func (s *Server) handleConn(w http.ResponseWriter, r *http.Request) {
deviceID := r.URL.Query().Get("device-id")
clientID := r.URL.Query().Get("client-id")
if deviceID == "" {
http.Error(w, "missing device-id", http.StatusBadRequest)
return
}
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("server: upgrade failed for %s: %v", deviceID, err)
return
}
// 设置单条消息大小上限
ws.SetReadLimit(maxMessageBytes)
conn := connection.New(ws, deviceID, clientID)
if err := s.register(conn); err != nil {
log.Printf("server: register %s failed: %v", deviceID, err)
ws.Close()
return
}
s.wg.Add(1)
defer func() {
conn.StopPlayback()
s.unregister(deviceID)
ws.Close()
s.wg.Done()
log.Printf("server: device %s disconnected, active=%d", deviceID, s.activeCount())
}()
log.Printf("server: device %s connected, active=%d", deviceID, s.activeCount())
// 消息读取循环
for {
msgType, raw, err := ws.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
if !isNetworkClose(err) {
log.Printf("server: read error for %s: %v", deviceID, err)
}
}
return
}
// 只处理文本消息(二进制为上行音频,本服务暂不处理)
if msgType != websocket.TextMessage {
continue
}
var envelope struct {
Type string `json:"type"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
log.Printf("server: invalid json from %s: %v", deviceID, err)
continue
}
switch envelope.Type {
case "story":
go handler.HandleStory(conn, s.client)
default:
log.Printf("server: unhandled message type %q from %s", envelope.Type, deviceID)
}
}
}
// register 注册连接,若同一设备已有连接则踢掉旧连接。
func (s *Server) register(conn *connection.Connection) error {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.conns) >= maxConnections {
return errors.New("server: max connections reached")
}
// 同一设备同时只允许一个连接
if old, exists := s.conns[conn.DeviceID]; exists {
log.Printf("server: kicking old connection for %s", conn.DeviceID)
old.Close()
}
s.conns[conn.DeviceID] = conn
return nil
}
func (s *Server) unregister(deviceID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.conns, deviceID)
}
func (s *Server) activeCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.conns)
}
// handleStatus 返回服务状态和当前活跃连接数,用于部署后验证。
// GET /xiaozhi/v1/healthz → {"status":"ok","active_connections":N}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
active := s.activeCount()
fmt.Fprintf(w, `{"status":"ok","active_connections":%d}`, active)
}
// isNetworkClose 判断是否为普通的网络关闭错误(不需要打印日志)。
func isNetworkClose(err error) bool {
var netErr *net.OpError
return errors.As(err, &netErr)
}

View File

@ -0,0 +1,82 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: hw-ws-service
labels:
app: hw-ws-service
spec:
replicas: 2
selector:
matchLabels:
app: hw-ws-service
# WebSocket 连接有状态,滚动更新时使用 Recreate 或 RollingUpdate + 优雅关闭
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0 # 始终保持至少 2 个 Pod 可用
maxSurge: 1
template:
metadata:
labels:
app: hw-ws-service
spec:
# 优雅关闭总时限90s服务内部等待 80s留 10s 缓冲)
terminationGracePeriodSeconds: 90
containers:
- name: hw-ws-service
image: ${CI_REGISTRY_IMAGE}/hw-ws-service:latest
imagePullPolicy: Always
ports:
- name: ws
containerPort: 8888
protocol: TCP
env:
- name: HW_WS_HOST
value: "0.0.0.0"
- name: HW_WS_PORT
value: "8888"
- name: HW_RTC_BACKEND_URL
# 集群内部直接访问 rtc-backend Service不走公网
value: "http://rtc-backend-svc:8000"
lifecycle:
preStop:
exec:
# 等待 5s 让 LB/Ingress 将流量从本 Pod 摘除,再开始关闭
command: ["/bin/sh", "-c", "sleep 5"]
# 就绪探针TCP 握手成功才接流量
readinessProbe:
tcpSocket:
port: 8888
initialDelaySeconds: 3
periodSeconds: 5
failureThreshold: 3
# 存活探针:连续失败 3 次才重启(避免短暂抖动误杀)
livenessProbe:
tcpSocket:
port: 8888
initialDelaySeconds: 10
periodSeconds: 15
failureThreshold: 3
# 资源限制(根据实际负载调整)
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
# 优先调度到不同节点,避免单点故障
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: hw-ws-service

View File

@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: hw-ws-svc
labels:
app: hw-ws-service
spec:
type: ClusterIP
selector:
app: hw-ws-service
ports:
- name: websocket
port: 8888
targetPort: 8888
protocol: TCP

251
hw_service_go/test/PLAN.md Normal file
View File

@ -0,0 +1,251 @@
# hw_service_go 本地硬件通讯测试计划
> 目标:用浏览器模拟 ESP32 硬件,验证 `hw_service_go` WebSocket 服务能否正常接收指令、获取故事、推送 Opus 音频。
---
## 一、协议对比分析
### 1.1 小智xiaozhi-servervs 我们的服务
| 维度 | xiaozhi-server | hw_service_go本服务 |
|------|---------------|------------------------|
| **WebSocket URL** | `ws://host:port/xiaozhi/v1/?device-id=&client-id=` | 完全相同 |
| **连接参数** | `device-id`MAC`client-id`UUID| 完全相同 |
| **握手消息** | 需要发送 `hello` JSON | **不需要**,连上即用 |
| **触发指令** | `listen`(语音输入) | **只需发 `{"type":"story"}`** |
| **音频方向** | 双向(硬件上传语音 + 服务下发 TTS| **单向下行**(服务→硬件,推 Opus |
| **Opus 编解码** | 需要编码(麦克风)+ 解码(播放)| **只需解码**(浏览器只播放) |
| **认证** | token 参数 | **无需认证**(仅 device-id 校验) |
| **消息复杂度** | hello/listen/stt/llm/tts/mcp | **只有 tts 系列** |
### 1.2 我们服务的完整消息流
```
浏览器(模拟硬件) hw_service_go Django
│ │ │
│── WS 连接 ──────────────────────────→│ │
│ ?device-id=AA:BB:CC:DD:EE:FF │ │
&client-id=test-001 │ │
│ │ │
│── {"type":"story"} ────────────────→ │ │
│ │── GET /api/v1/devices/ │
│ │ stories/?mac_address │
│ │ =AA:BB:CC:DD:EE:FF → │
│ │ │
│← {"type":"tts","state":"start"} ───── │ │
│ │← {title, audio_url} ── │
│ │ │
│ │── 下载 MP3 ──────────→ CDN
│ │← MP3 二进制流 ─────── │
│ │ │
│ │ ffmpeg 转码 PCM→Opus │
│ │ │
│← {"type":"tts","state":"sentence_start","text":"故事标题"} ─── │
│ │ │
│← [Opus帧1 二进制] ─────────────────── │ 60ms/帧前3帧预缓冲 │
│← [Opus帧2 二进制] ─────────────────── │ │
│← [Opus帧3 二进制] ─────────────────── │ │
│← [Opus帧N 二进制] ─────────────────── │ 按时序流控发送 │
│ │ │
│← {"type":"tts","state":"stop"} ─────── │ │
│ │ │
```
### 1.3 Opus 音频参数(与小智完全一致)
| 参数 | 值 |
|------|----|
| 采样率 | 16000 Hz |
| 声道 | 1单声道|
| 帧时长 | 60ms |
| 每帧采样数 | 960 |
| 编码器 | libopusWASM |
---
## 二、前置条件检查
在开始测试之前,需要满足以下条件:
### 2.1 服务运行状态
- [ ] Django 后端运行在 `http://localhost:8000`
- [ ] `hw_service_go` 运行在 `ws://localhost:8888`
- [ ] 健康检查通过:`curl http://localhost:8888/healthz` 返回 200
### 2.2 Django 数据准备(关键!)
测试必须使用一个在 Django 数据库中**真实存在**的设备 MAC 地址。
Django API 查询逻辑(`GET /api/v1/devices/stories/?mac_address=<MAC>`
- 根据 MAC 查找设备 → 找到设备绑定的用户 → 查找该用户的故事
- 任何一步缺失,服务返回 `null`,硬件不会播放任何内容
**需要在 Django Admin 或 API 中准备:**
1. 注册一个设备,记下其 MAC 地址(格式:`AA:BB:CC:DD:EE:FF`
2. 该设备需已绑定用户owner
3. 该用户名下有至少一个故事(有 `audio_url` 字段)
> **快速验证**`curl "http://localhost:8000/api/v1/devices/stories/?mac_address=你的MAC"` 应返回 `{"code":0,"data":{"title":"...","audio_url":"..."}}`
---
## 三、测试程序设计
### 3.1 技术选型
| 方案 | 优点 | 缺点 |
|------|------|------|
| **纯 HTML+JS推荐** | 零依赖,直接浏览器打开,与小智方案一致 | - |
| Python 脚本 | 简单但无法播放音频 | 无法验证音频播放端到端 |
| Go 命令行 | 需额外音频库 | 环境搭建复杂 |
**选择方案:纯 HTML+JS 单文件**,复用小智项目的 `libopus.js`WASM做解码。
### 3.2 文件结构
```
hw_service_go/test/
├── PLAN.md ← 本文件
├── test.html ← 测试主页面(待实现)
└── libopus.js ← 复制自小智项目Opus WASM 解码库)
```
`libopus.js` 来源:
```
/Users/maidong/Desktop/zyc/jikashe/xiaozhi-server/main/xiaozhi-server/test/libopus.js
```
### 3.3 测试页面功能
```
┌─────────────────────────────────────────────────────┐
│ hw_service_go 硬件通讯测试 │
├─────────────────────────────────────────────────────┤
│ 服务地址: [ws://localhost:8888/xiaozhi/v1/ ] │
│ device-id: [AA:BB:CC:DD:EE:FF ] │
│ client-id: [test-browser-001 ] [随机生成] │
├─────────────────────────────────────────────────────┤
│ [连接] [断开] 状态: ● 未连接 │
├─────────────────────────────────────────────────────┤
│ [▶ 触发故事播放] [⏹ 停止] │
├─────────────────────────────────────────────────────┤
│ 消息日志 [清空] │
│ ┌───────────────────────────────────────────────┐ │
│ │ [10:23:01] → 已连接 │ │
│ │ [10:23:02] → 发送: {"type":"story"} │ │
│ │ [10:23:02] ← 收到: {"type":"tts","state":"start"} │
│ │ [10:23:03] ← 收到: {"type":"tts","state":"sentence_start","text":"..."} │
│ │ [10:23:03] ← 收到: [Binary] Opus帧 #1 (38 bytes) │
│ │ [10:23:03] 🔊 开始播放... │ │
│ │ [10:23:15] ← 收到: {"type":"tts","state":"stop"} │
│ │ [10:23:15] 🔊 播放完毕 │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ 统计: 已收到 85 个Opus帧 | 约 5.1s 音频 │
└─────────────────────────────────────────────────────┘
```
### 3.4 核心实现逻辑
#### 连接流程
```javascript
const ws = new WebSocket(
`ws://localhost:8888/xiaozhi/v1/?device-id=${deviceId}&client-id=${clientId}`
);
ws.binaryType = 'arraybuffer';
```
#### 触发故事
```javascript
ws.send(JSON.stringify({ type: 'story' }));
```
#### 接收消息处理
```javascript
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
// 二进制Opus 音频帧
const opusFrame = new Uint8Array(event.data);
const pcm = opusDecoder.decode(opusFrame); // Int16Array
schedulePlay(pcm); // 排队播放
} else {
// 文本:控制消息
const msg = JSON.parse(event.data);
handleTtsControl(msg); // 处理 start/sentence_start/stop
}
};
```
#### Opus 解码 + 播放(与小智方案完全一致)
- 使用 `libopus.js`WASM初始化解码器16000Hz单声道
- 解码:`Int16Array``Float32Array`
- 使用 `AudioContext` + `AudioBufferSourceNode` 按时序排队播放
- 使用 `BlockingQueue` 缓冲帧,避免播放卡顿
---
## 四、测试用例
### Case 1基础连接测试
- 输入正确的 `device-id``client-id`
- 期望WebSocket 连接建立成功,状态变为"已连接"
### Case 2故事触发测试
- 发送 `{"type":"story"}`
- 期望:
1. 收到 `{"type":"tts","state":"start"}`
2. 收到 `{"type":"tts","state":"sentence_start","text":"<故事标题>"}`
3. 陆续收到多个二进制 Opus 帧
4. 最终收到 `{"type":"tts","state":"stop"}`
### Case 3音频播放验证
- 期望:浏览器实际播放出故事音频,声音正常无杂音、无卡顿
### Case 4设备不存在测试
- 使用未注册的 MAC 地址
- 期望:发送故事指令后立即收到 `{"type":"tts","state":"stop"}`(服务侧找不到故事,直接结束)
### Case 5重复触发测试
- 播放过程中再次点击"触发故事"
- 期望旧播放被打断新故事从头开始hw_service_go 的 `StartPlayback` 会 close 旧 abortCh
### Case 6断线重连测试
- 连接后断开,再重新连接
- 期望:可以正常重新发起故事请求
---
## 五、实现步骤
1. **复制 libopus.js**
```bash
cp /Users/maidong/Desktop/zyc/jikashe/xiaozhi-server/main/xiaozhi-server/test/libopus.js \
/Users/maidong/Desktop/zyc/qy_gitlab/rtc_backend/hw_service_go/test/
```
2. **编写 test.html**(单文件,嵌入所有 JS
- 参考小智 `StreamingContext.js``BlockingQueue.js` 的逻辑
- 去掉录音/编码部分(我们只需解码)
- 保留 Opus 解码 + AudioContext 播放部分
- 添加连接配置 UI 和消息日志面板
3. **浏览器打开测试**
```
直接用浏览器打开 test.htmlfile:// 协议即可)
```
> 注意macOS Safari 对 WebSocket + file:// 可能有限制,建议用 Chrome
4. **按测试用例逐项验证**
---
## 六、已知限制与注意事项
| 问题 | 说明 |
|------|------|
| **device-id 必须真实存在** | MAC 地址若未在 Django 数据库注册,服务会静默返回无故事 |
| **ffmpeg 必须安装** | `hw_service_go` 的转码依赖系统 `ffmpeg`,需提前安装 |
| **audio_url 必须可访问** | 故事的 MP3 链接需要能从本机下载(阿里云 OSS 等) |
| **浏览器 AudioContext 限制** | 需要用户交互(点击)后才能创建 AudioContext不能自动播放 |
| **WASM 加载** | libopus.js 较大844KB首次加载需要等待约 1-2 秒 |

View File

@ -14,6 +14,13 @@ spec:
- host: qiyuan-rtc-api.airlabs.art
http:
paths:
- path: /xiaozhi/v1/
pathType: Prefix
backend:
service:
name: hw-ws-svc
port:
number: 8888
- path: /
pathType: Prefix
backend:

103
run.sh Executable file
View File

@ -0,0 +1,103 @@
#!/bin/bash
# RTC Backend 启动脚本(同时启动 Django + hw_service_go
DJANGO_PORT=${1:-8000}
WS_PORT=${2:-8888}
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
VENV_PYTHON="$PROJECT_DIR/venv/bin/python"
GO_SERVICE_DIR="$PROJECT_DIR/hw_service_go"
echo "=== RTC Backend 启动脚本 ==="
echo "Django 端口: $DJANGO_PORT"
echo "WebSocket 端口: $WS_PORT"
echo "项目: $PROJECT_DIR"
echo ""
# ---- 检查 Go 环境 ----
GO_BIN=$(which go 2>/dev/null)
if [ -z "$GO_BIN" ]; then
# Homebrew 安装的 Go 不在默认 PATH尝试常见位置
for candidate in \
/opt/homebrew/bin/go \
/usr/local/go/bin/go \
/opt/homebrew/Cellar/go/*/libexec/bin/go; do
if [ -x "$candidate" ]; then
GO_BIN="$candidate"
break
fi
done
fi
if [ -z "$GO_BIN" ]; then
echo "[x] 未找到 go 命令,请安装 Go 或检查 PATH"
exit 1
fi
echo "[✓] Go: $GO_BIN"
# ---- 检查虚拟环境 ----
if [ ! -f "$VENV_PYTHON" ]; then
echo "[x] 未找到虚拟环境: $VENV_PYTHON"
exit 1
fi
echo "[✓] 虚拟环境就绪"
# ---- 释放占用端口的函数 ----
free_port() {
local port=$1
local name=$2
local pid
pid=$(lsof -ti :"$port" 2>/dev/null)
if [ -n "$pid" ]; then
echo "[!] 端口 $port ($name) 被占用PID: $pid,正在终止..."
kill -9 "$pid" 2>/dev/null
sleep 1
pid=$(lsof -ti :"$port" 2>/dev/null)
if [ -n "$pid" ]; then
echo "[x] 端口 $port 释放失败,请手动处理"
exit 1
fi
echo "[✓] 端口 $port 已释放"
else
echo "[✓] 端口 $port 空闲"
fi
}
free_port "$DJANGO_PORT" "Django"
free_port "$WS_PORT" "hw_service_go"
# ---- 退出时清理所有子进程 ----
cleanup() {
echo ""
echo "=== 正在关闭所有服务... ==="
kill "$DJANGO_PID" "$WS_PID" 2>/dev/null
wait "$DJANGO_PID" "$WS_PID" 2>/dev/null
echo "=== 所有服务已停止 ==="
}
trap cleanup SIGINT SIGTERM EXIT
# ---- 启动 Django ----
echo ""
echo "=== 启动 Django 开发服务器 (0.0.0.0:$DJANGO_PORT) ==="
cd "$PROJECT_DIR"
$VENV_PYTHON manage.py runserver "0.0.0.0:$DJANGO_PORT" &
DJANGO_PID=$!
echo "[✓] Django PID: $DJANGO_PID"
# ---- 启动 hw_service_go ----
echo ""
echo "=== 启动 hw_service_go WebSocket 服务 (0.0.0.0:$WS_PORT) ==="
cd "$GO_SERVICE_DIR"
HW_RTC_BACKEND_URL="http://localhost:$DJANGO_PORT" \
HW_WS_PORT="$WS_PORT" \
"$GO_BIN" run ./cmd/main.go &
WS_PID=$!
echo "[✓] hw_service_go PID: $WS_PID"
echo ""
echo "=== 所有服务已启动,按 Ctrl+C 停止 ==="
echo " Django: http://localhost:$DJANGO_PORT"
echo " WebSocket: ws://localhost:$WS_PORT/xiaozhi/v1/"
echo " 健康检查: http://localhost:$WS_PORT/healthz"
echo ""
# 等待任意一个进程退出,然后触发 cleanup
wait -n "$DJANGO_PID" "$WS_PID" 2>/dev/null || wait "$DJANGO_PID" "$WS_PID"