Compare commits
5
Commits
v0.2.3
...
49f57e4d28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49f57e4d28 | ||
|
|
ad342ec8f1 | ||
|
|
24f8accf71 | ||
|
|
6ac2473099 | ||
|
|
1e2be0cd5b |
@@ -0,0 +1,61 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# 触发条件:
|
||||||
|
# - push 到 develop / main
|
||||||
|
# - 针对 develop / main 的 PR
|
||||||
|
#
|
||||||
|
# 为什么单独有这个 workflow:
|
||||||
|
# 之前仓库里唯一的 workflow 是 docker.yml,它只在打 tag 时构建镜像,
|
||||||
|
# 既不做 go build 也不跑 go vet,提交能不能编译没人自动盯着。
|
||||||
|
#
|
||||||
|
# ⚠️ 本 workflow 的实际覆盖范围(务必知悉):
|
||||||
|
# 测试源码按项目策略**不入库**(见 .gitignore 的 `*_test.go`),因此干净克隆里
|
||||||
|
# 没有任何 *_test.go,下面的 go test 步骤会打印 "no test files" 后通过。
|
||||||
|
# 也就是说:**CI 只能守住"编译通过 + 静态检查通过",守不住行为回归。**
|
||||||
|
# 行为回归要靠开发者在本地(测试文件所在处)自行执行:
|
||||||
|
# go test ./... -count=1
|
||||||
|
# 如果哪天测试重新入库,把下面的 go test 步骤改成会真正跑起来的形态即可。
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop, main]
|
||||||
|
pull_request:
|
||||||
|
branches: [develop, main]
|
||||||
|
|
||||||
|
# 同一分支的重复推送取消上一次未完成的运行,省 CI 时间。
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
# 以 go.mod 里的 go 指令为准,避免两处版本漂移
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: 下载依赖
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: 编译检查
|
||||||
|
run: go build ./...
|
||||||
|
|
||||||
|
- name: 静态检查
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
# 以下两步在"测试不入库"策略下是空转(no test files),保留是为了:
|
||||||
|
# 1. 覆盖 lock 判定 / 损坏回退 / voice 路由等关键路径的测试一旦重新入库,
|
||||||
|
# 无需改 workflow 即可直接生效;
|
||||||
|
# 2. -count=1 禁用测试缓存,避免"改坏了却因缓存仍显示通过"。
|
||||||
|
- name: 测试(测试源码不入库时为空转)
|
||||||
|
run: go test ./... -count=1
|
||||||
|
|
||||||
|
- name: 关键路径汇总(integration)
|
||||||
|
run: go test ./test/integration/ -count=1 -v
|
||||||
|
|
||||||
+12
-7
@@ -4,7 +4,10 @@
|
|||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
# Go test sources (prevent accidental commit; tests live outside the repo by policy)
|
# Go 测试源码:不入库(测试保留在本地磁盘,由开发者自行 go test ./... -count=1)
|
||||||
|
# 理由:测试用例会暴露内部实现细节与断言,不作为交付物外流。
|
||||||
|
# 注意:本规则会把测试文件挡在 git 之外,因此仓库内不提供自动化测试;
|
||||||
|
# .github/workflows/ci.yml 里的测试步骤在纯净克隆上无测试可跑。
|
||||||
*_test.go
|
*_test.go
|
||||||
|
|
||||||
# Editor / OS
|
# Editor / OS
|
||||||
@@ -33,14 +36,16 @@ bin/
|
|||||||
# Local dev scripts (per-user; not part of repo)
|
# Local dev scripts (per-user; not part of repo)
|
||||||
start-local.ps1
|
start-local.ps1
|
||||||
|
|
||||||
|
# Windows / PowerShell 脚本:一律不入库、不外流
|
||||||
|
# ui-handoff/serve.ps1 与 start-local.ps1 是本地起服便利脚本(含本机路径与端口),
|
||||||
|
# 不属于交付物。这两个文件在文件系统层面删除被拒,因此只能在 git 层屏蔽。
|
||||||
|
*.ps1
|
||||||
|
|
||||||
|
# UI handoff bundle(改造前的单文件副本 + 离线 mock 预览;仅本地对照,不部署)
|
||||||
|
ui-handoff/
|
||||||
|
|
||||||
# Runtime database artifacts (created by M1 installer flow)
|
# Runtime database artifacts (created by M1 installer flow)
|
||||||
tts.db
|
tts.db
|
||||||
tts.db-*
|
tts.db-*
|
||||||
tts.db.*
|
tts.db.*
|
||||||
installed.lock
|
installed.lock
|
||||||
|
|
||||||
# UI handoff bundle (for external AI redesign work; not deployed)
|
|
||||||
ui-handoff/
|
|
||||||
|
|
||||||
# Docs (本地草稿,不入库)
|
|
||||||
docs/
|
|
||||||
|
|||||||
@@ -2,6 +2,36 @@
|
|||||||
|
|
||||||
本项目的所有重要变更都记录在本文档。版本号遵循 [SemVer](https://semver.org/)。
|
本项目的所有重要变更都记录在本文档。版本号遵循 [SemVer](https://semver.org/)。
|
||||||
|
|
||||||
|
## [未发布]
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **指标空指针崩溃**: `metrics` 包的全局指标(`UpstreamTotal` 等)默认是 nil,只有 `main` 调过
|
||||||
|
`metrics.Init()` 后才有值。任何**不经过 main** 的调用路径(直接调 handler、复用为库)都会在
|
||||||
|
`controller/tts.go` → `adapter/volcano/synthesis.go` → `metrics.AdapterRecorder` 处 nil 解引用 panic。
|
||||||
|
现在 `telemetry` 的 `Counter.Add` / `Gauge.Set` / `Gauge.Add` / `Histogram.Observe` 一律
|
||||||
|
**空接收者安全**(nil 静默忽略),并在包注释里写成显式设计约定。
|
||||||
|
- **上游 client 空指针**: `volcano.(*HTTPClient).PostStream` 在 client 未初始化时 panic。
|
||||||
|
现在返回错误,由 controller 归一成 5xx 并记日志——装配错误不该拖垮进程。
|
||||||
|
|
||||||
|
> 上面两个崩溃是**本地测试暴露出来的**:直接调用 handler 而不经过 `main` 的路径,
|
||||||
|
> 会跳过 `metrics.Init()` 与 `volcanoClient` 的赋值。生产二进制不受影响。
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- **测试源码不再入库**: `.gitignore` 恢复整体屏蔽 `*_test.go`。测试用例会暴露内部实现
|
||||||
|
细节与断言,不作为交付物外流;测试文件保留在本地磁盘,由开发者自行执行
|
||||||
|
`go test ./... -count=1`。
|
||||||
|
⚠️ 代价必须明确:仓库内**不提供自动化测试**,`.github/workflows/ci.yml` 里的
|
||||||
|
`go test` 步骤在纯净克隆上无测试可跑(会打印 `no test files` 并通过),
|
||||||
|
**CI 只能守住"编译通过 + 静态检查通过",守不住行为回归**。
|
||||||
|
- **新增 CI 校验** (`.github/workflows/ci.yml`): push / PR 到 `develop`、`main` 时跑
|
||||||
|
`go build` + `go vet` + `go test -count=1`。此前仓库唯一的 workflow 只在打 tag 时构建
|
||||||
|
Docker 镜像,不做任何编译或测试校验。
|
||||||
|
- **文档订正**: `docs/UI_HANDOFF.md` 原为"单文件换皮"外包任务书,其中的
|
||||||
|
"admin.html ≤ 35KB / setup.html ≤ 15KB / 只改两个 .html" 等约束在 admin 拆分多页后已作废,
|
||||||
|
现标注为历史文档并补上当前真实文件结构与验收清单。
|
||||||
|
|
||||||
## [0.2.0] - 2026-08-30
|
## [0.2.0] - 2026-08-30
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -29,7 +29,14 @@ func NewHTTPClient() *HTTPClient {
|
|||||||
|
|
||||||
// PostStream 发送一次流式请求,返回带上下文的 *http.Response。
|
// PostStream 发送一次流式请求,返回带上下文的 *http.Response。
|
||||||
// 调用方负责关闭 resp.Body。
|
// 调用方负责关闭 resp.Body。
|
||||||
|
//
|
||||||
|
// 空接收者(nil *HTTPClient)安全:返回错误而不是 panic。未初始化的 client 属于
|
||||||
|
// 装配错误,应当让该次请求以"上游不可用"失败(controller 归一成 5xx 并记日志),
|
||||||
|
// 而不是让整个进程崩掉。
|
||||||
func (h *HTTPClient) PostStream(ctx context.Context, url string, headers map[string]string, body []byte) (*http.Response, error) {
|
func (h *HTTPClient) PostStream(ctx context.Context, url string, headers map[string]string, body []byte) (*http.Response, error) {
|
||||||
|
if h == nil || h.client == nil {
|
||||||
|
return nil, fmt.Errorf("volcano: HTTPClient 未初始化,无法发起上游请求")
|
||||||
|
}
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
# UI 外包 Handoff · 火山 TTS 管理后台
|
||||||
|
|
||||||
|
> 历史文档。本文档描述的是**外包给 Codex 做视觉改造**那一次任务,目标是"现有功能完全不动, 只换皮"。
|
||||||
|
>
|
||||||
|
> ⚠️ **状态: 任务已结束, 且路线已变更。** 该任务之后, admin 后台由提交 `9245c13`
|
||||||
|
> 自行重构成**多页 SPA**(见下面 §0.1),本文档正文里的"单文件 / 大小上限 / 改这两个 .html"
|
||||||
|
> 等约束**均已失效**,保留仅为记录当时的交接内容与设计讨论。
|
||||||
|
> 若要再做视觉改造,请以 §0.1 的当前文件结构为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.1 当前真实结构(以此为准)
|
||||||
|
|
||||||
|
admin 后台已从单文件拆分为多页面,共享一套 CSS/JS,由 `//go:embed` 逐条嵌入:
|
||||||
|
|
||||||
|
| 文件 | 作用 | 大小 |
|
||||||
|
|---|---|---|
|
||||||
|
| `router/admin.html` | 后台外壳(导航 / 布局) | 5.5KB |
|
||||||
|
| `router/admin-login.html` | 登录页 | 2.1KB |
|
||||||
|
| `router/admin-voices.html` | 音色管理页 | 13.5KB |
|
||||||
|
| `router/admin-settings.html` | 设置页 | 14.4KB |
|
||||||
|
| `router/admin.css` | 共享样式(全部页面) | 20.2KB |
|
||||||
|
| `router/admin-shell.js` | 共享脚本: axios 实例 / toast / 外壳挂载 / 401 拦截 | 7.1KB |
|
||||||
|
| `router/setup.html` | 安装引导页(仍是单文件) | 24.0KB |
|
||||||
|
|
||||||
|
- URL 路由(非 hash):`/admin` · `/admin/login` · `/admin/voices` · `/admin/settings`
|
||||||
|
- 公共逻辑集中在 `admin-shell.js`,通过 `window.admin` 暴露
|
||||||
|
`{ http, getKey, setKey, clearKey, toast, mountShell, formatUptime, … }`
|
||||||
|
- **已无"单文件 ≤ 35KB"这类约束**;新增页面请复用 `admin.css` + `admin-shell.js`,不要各写一套。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 任务概览(历史原文)
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 改的文件 | `router/admin.html`、`router/setup.html` |
|
||||||
|
| 改的部分 | 视觉、布局、组件、交互模式(loading/empty/error/反馈)|
|
||||||
|
| 不改的部分 | API 调用、状态管理逻辑、字段名、文案语义 |
|
||||||
|
| 期望产物 | 改完后的两个 .html, 总大小 ≤ 64KB(admin ≤ 35KB, setup ≤ 15KB)|
|
||||||
|
| 验收 | dev 起服, 浏览器走完所有流程无回归 + 视觉明显更好 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 技术栈约束(历史原文;"单文件"部分已失效)
|
||||||
|
|
||||||
|
| 项 | 现状 | 改动空间 |
|
||||||
|
|---|---|---|
|
||||||
|
| 构建 | **无构建步骤**(`//go:embed` 进 binary)| 不可改 |
|
||||||
|
| Vue | 3.4 via `cdn.bootcdn.net` | 可换版本(保持 3.x 即可)|
|
||||||
|
| HTTP | axios 1.6(集中在 `admin-shell.js`)| 可换(fetch / ky / 留 axios 都行)|
|
||||||
|
| CSS | 手写, 走 CSS 变量, 集中在 `admin.css` | 可换 **Tailwind CDN / DaisyUI / Pico / 手写** 都行 |
|
||||||
|
| 图标 | 暂无 | 可加 lucide / heroicons inline SVG / emoji |
|
||||||
|
| 字体 | 系统默认 (`-apple-system, "PingFang SC"...`) | 可加 `Noto Sans SC` via Google Fonts, 但要评估大小 |
|
||||||
|
| 路由 | 拆分后已改为 **URL 路由**(`/admin/voices` 等),不再是 hash 三 tab | 保持 URL 路由 |
|
||||||
|
|
||||||
|
**注意(历史约束,已不适用)**: 当时二进制大小敏感 —— admin.html 31.19KB / setup.html 13.01KB。拆分后 admin 侧总量约 63KB(分散在 6 个文件里,浏览器可分页只加载所需部分),embed 进 binary 后大小影响可接受。**仍然建议**: 不要无脑加 UI 库(Tailwind 全量 + DaisyUI 会显著增大 embed 体积);要加就加在 `admin.css` 一处,所有页面共享。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 路由 & 模式
|
||||||
|
|
||||||
|
### 2.1 三个 URL
|
||||||
|
- `GET /setup` → `router/setup.html`(安装模式才进得去)
|
||||||
|
- `GET /admin` → `router/admin.html`(任何模式都返回 HTML, 鉴权由前端 JS 拦截)
|
||||||
|
- `GET /` → 重定向到 `/setup` 或 `/admin`
|
||||||
|
|
||||||
|
### 2.2 状态机(admin.html)
|
||||||
|
|
||||||
|
```
|
||||||
|
未登录 (无 sessionStorage 'ttsAdminKey')
|
||||||
|
├─ 输入 key + 调 GET /api/admin/overview 验证
|
||||||
|
└─ 验证通过 → 写入 sessionStorage + 切到登录后视图
|
||||||
|
|
||||||
|
已登录
|
||||||
|
├─ 三个 tab: dashboard / voices / settings(hash 路由)
|
||||||
|
├─ 调任意 API 返 401 → 清 sessionStorage + 回到登录页
|
||||||
|
└─ 改完自己 auth_key → 调 PUT /api/settings/auth-key 200 后清 sessionStorage
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 状态机(setup.html)
|
||||||
|
|
||||||
|
```
|
||||||
|
未提交: 表单 4 必填 + voices 至少 1 条
|
||||||
|
└─ onMounted 调 GET /api/setup/prefill 自动填默认值
|
||||||
|
└─ 提交 POST /api/setup, 成功后 location.href = '/admin'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API 契约(前端用的全部)
|
||||||
|
|
||||||
|
### 3.1 鉴权
|
||||||
|
|
||||||
|
| 头 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `Authorization: Bearer <key>` | 所有 /api/* 请求必带(除 /api/setup/*)|
|
||||||
|
| 401 响应 | 清 sessionStorage `ttsAdminKey`, 回到登录页 |
|
||||||
|
|
||||||
|
**axios 实例已配置**:
|
||||||
|
```js
|
||||||
|
const http = axios.create({ baseURL: '/api' });
|
||||||
|
http.interceptors.request.use(c => {
|
||||||
|
if (apiKey.value) c.headers.Authorization = 'Bearer ' + apiKey.value;
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
http.interceptors.response.use(r => r, err => {
|
||||||
|
if (err.response && err.response.status === 401) {
|
||||||
|
sessionStorage.removeItem('ttsAdminKey');
|
||||||
|
apiKey.value = '';
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 端点
|
||||||
|
|
||||||
|
| Method | Path | 请求 body | 响应 | 鉴权 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| GET | `/api/setup/prefill` | — | `{ settings: {default_resource_id, default_speaker, default_format, sample_rate} }` | 否(仅 install 模式)|
|
||||||
|
| POST | `/api/setup` | `{token, settings: {auth_key, api_key, default_resource_id, default_speaker, default_format, sample_rate}, voices: [{name, speaker, resource_id, model, language}]}` | `{ok, redirect, settings, voices}` | 否(仅 install 模式)|
|
||||||
|
| GET | `/api/admin/overview` | — | `{mode, installed, db_path, lock_path, version, commit, uptime_seconds, start_time, voice_count, voice_enabled_count, memory: {goroutines, heap_alloc, heap_inuse}}` | 是 |
|
||||||
|
| GET | `/api/admin/metrics` | — | Prometheus text | 是 |
|
||||||
|
| GET | `/api/voices` | — | `{voices: [{ID, Name, Speaker, ResourceID, Model, Language, Description, Enabled, CreatedAt, UpdatedAt}]}` | 是 |
|
||||||
|
| POST | `/api/voices` | `{name, speaker, resource_id, model, language?, description?}` | 创建的 voice 对象 | 是 |
|
||||||
|
| DELETE | `/api/voices/{name}` | — | `{deleted, ok}` | 是 |
|
||||||
|
| PATCH | `/api/voices/{name}/toggle` | `{enabled: bool}` | 更新后的 voice | 是 |
|
||||||
|
| GET | `/api/settings` | — | 完整 settings 对象(见 3.3)| 是 |
|
||||||
|
| PUT | `/api/settings` | 部分字段(`default_resource_id` / `default_speaker` / `default_format` / `sample_rate` / `model`)| `{ok, updated, updated_at}` | 是 |
|
||||||
|
| PUT | `/api/settings/api-key` | `{api_key: string}` | `{ok}` | 是 |
|
||||||
|
| PUT | `/api/settings/auth-key` | `{auth_key: string}` | `{ok}` | 是 |
|
||||||
|
| PUT | `/api/settings/cors` | `{allow_all?: bool, origins?: string}` | `{ok, allow_all, origins, cors_active}` | 是 |
|
||||||
|
|
||||||
|
### 3.3 GET /api/settings 完整响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_key": "volc****etup", // 打码
|
||||||
|
"api_key_set": true,
|
||||||
|
"auth_key": "my-o****etup", // 打码
|
||||||
|
"auth_key_set": true,
|
||||||
|
"cors_allow_all": false,
|
||||||
|
"cors_origins": "https://app.example.com\nhttps://other.com",
|
||||||
|
"cors_configured": true, // banner 显示控制
|
||||||
|
"default_resource_id": "volc.megatts.icl",
|
||||||
|
"default_speaker": "qian",
|
||||||
|
"default_format": "mp3",
|
||||||
|
"sample_rate": 24000,
|
||||||
|
"model": "seed-tts-2.0-standard",
|
||||||
|
"model_type": 0,
|
||||||
|
"explicit_language": "",
|
||||||
|
"enable_subtitle": false,
|
||||||
|
"updated_at": "2026-08-29T16:07:49Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 错误响应格式
|
||||||
|
|
||||||
|
所有错误(除 OpenAI 兼容的 /v1/audio/speech)用同一格式:
|
||||||
|
```json
|
||||||
|
{ "error": { "code": "bad_request", "message": "...", "type": "invalid_request_error" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
前端用 `e.response?.data?.error?.message` 提取.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 字段语义(写文案 / placeholder 用)
|
||||||
|
|
||||||
|
### 4.1 auth_key
|
||||||
|
"OpenAI 鉴权 Key"
|
||||||
|
- 客户端用这个 Key 调 `/v1/audio/speech`
|
||||||
|
- 登录 `/admin` 也用这个 Key
|
||||||
|
- 自定字符串, 不是火山给的, **完全自己造**
|
||||||
|
- 例: `my-secret-key-2024`
|
||||||
|
|
||||||
|
### 4.2 api_key
|
||||||
|
"火山 TTS API Key" / "上游 API Key"
|
||||||
|
- 调火山 v3 上游用的凭证
|
||||||
|
- 从 https://console.volcengine.com/ 获取
|
||||||
|
- 例: `5b4d7c2a-...`
|
||||||
|
|
||||||
|
### 4.3 default_resource_id
|
||||||
|
- 音色所属的计费资源 ID
|
||||||
|
- `volc.megatts.default` 默认音色 / `volc.megatts.icl` 复刻音色
|
||||||
|
|
||||||
|
### 4.4 default_speaker
|
||||||
|
- 未传 `voice` 字段时用
|
||||||
|
- 必须是"音色" tab 里已添加的 voice name
|
||||||
|
|
||||||
|
### 4.5 voices 字段
|
||||||
|
- `name`: 短英文 id, 唯一, 不可改
|
||||||
|
- `speaker`: 火山给的 speaker id
|
||||||
|
- `resource_id`: 同上 default_resource_id
|
||||||
|
- `model`: `seed-tts-2.0-standard` (默认) / `seed-tts-2.0-expressive` / `seed-tts-2.0-clone`
|
||||||
|
- `language`: ISO 639-1 (`zh` / `en` / `ja` 等)
|
||||||
|
|
||||||
|
### 4.6 CORS origins
|
||||||
|
- 一行一个 origin
|
||||||
|
- 必须 `http://` 或 `https://` 开头
|
||||||
|
- 末尾 `/` 自动 trim
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 当前 UI 的具体问题(按痛感排序)
|
||||||
|
|
||||||
|
### 5.1 admin.html
|
||||||
|
| # | 问题 | 现状 | 期望 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **登录页太空** | 一个输入框 + 一个按钮 | 加 logo / 提示 / 服务名 / 安装状态提示 |
|
||||||
|
| 2 | **dashboard 卡片平** | 4 个数字 + 路径文本, 无图标无图表 | 加 icon, 用 chart mini sparkline (uptime / memory)|
|
||||||
|
| 3 | **voices 表格简陋** | 5 列 + 启停 switch + 删除 | 加排序/搜索/批量操作/分页/拖拽改顺序 |
|
||||||
|
| 4 | **新增 voice 弹窗原始** | 6 个 input 堆叠 | 分组, 加 help text, 加示例值, 加实时校验 |
|
||||||
|
| 5 | **设置页 1 长页** | 4 段串成 1 长 list | 分 tab/分卡片, 加描述/帮助链接/危险操作分离 |
|
||||||
|
| 6 | **改 key 后无 feedback** | 改了直接清 session | 加 toast 通知 "Key 已更新, 请用新 Key 重新登录" |
|
||||||
|
| 7 | **删除无 undo** | confirm 弹原生 confirm() | 加 inline 二次确认 / undo snackbar |
|
||||||
|
| 8 | **loading 无** | 调 API 时按钮不变 | 加 spinner / disabled 状态 / 进度条 |
|
||||||
|
| 9 | **error 无专门显示区** | 用 `actionErr.value` 全局串 | 用 toast / inline error / 错误码对照 |
|
||||||
|
| 10 | **响应式差** | desktop only | 至少平板能用, 关键操作 (tab 切换) 移动端可点 |
|
||||||
|
| 11 | **配色单调** | 1 个 cyan 强调色 | 加二级色, success/warning/danger 用得不够明显 |
|
||||||
|
| 12 | **字体单薄** | 14px 全局 | 大字标题 24-32px, 重要数字 monospace 突出 |
|
||||||
|
|
||||||
|
### 5.2 setup.html
|
||||||
|
| # | 问题 | 现状 | 期望 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **4 字段加 voice 列表一锅烩** | 一长页 | 分步骤 (1.凭证 2.默认 3.音色 4.确认) |
|
||||||
|
| 2 | **voice 行无复用 UI** | 自己手写的 row | drag-to-reorder + 复制/粘贴/从预置模板加 |
|
||||||
|
| 3 | **无进度感** | 一个 "安装" 按钮 | 安装中显示进度 (写 settings → 写 voices → 写 lock) |
|
||||||
|
| 4 | **无"安装后会发生什么"** | 提交后只跳 /admin | 加结果页: 装了什么 / 接下来去 admin 干啥 |
|
||||||
|
| 5 | **无 reset** | 改了一锅填错没法批量重置 | "恢复默认值" 按钮 |
|
||||||
|
| 6 | **移动端糟糕** | 1 长页 | 至少 step indicator 在窄屏可见 |
|
||||||
|
|
||||||
|
### 5.3 共性问题
|
||||||
|
| # | 问题 | 期望 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 无 dark/light toggle | 加 toggle, 默认跟随系统 |
|
||||||
|
| 2 | 无键盘快捷键 | `/` 聚焦搜索, `Esc` 关弹窗, `Ctrl+S` 保存当前表单 |
|
||||||
|
| 3 | 无国际化 | 默认 zh-CN, 可加 i18n 但不强制 |
|
||||||
|
| 4 | 无 a11y 标签 | aria-label, focus ring, 键盘可达 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 设计参考(建议风格, 不是强制)
|
||||||
|
|
||||||
|
| 风格 | 适配场景 | 推荐度 |
|
||||||
|
|---|---|---|
|
||||||
|
| Linear / Vercel dashboard | 后台工具, dark mode 友好 | ⭐⭐⭐⭐⭐ |
|
||||||
|
| Tailscale admin | 简洁, 卡片化, 信息密度高 | ⭐⭐⭐⭐ |
|
||||||
|
| Coolify / CasaOS | 自托管, "家用服务器"感 | ⭐⭐⭐ |
|
||||||
|
| shadcn/ui | 现代 React 风, 移植到 Vue 略费劲 | ⭐⭐⭐ (参考设计, 不直接用) |
|
||||||
|
| Tailadmin | 通用 admin 模板, 选段借鉴 | ⭐⭐ |
|
||||||
|
|
||||||
|
**主调色建议** (如果重做):
|
||||||
|
- 背景: 深色 (`#0a0e1a`) 或浅色 (`#f8fafc`), 配 system pref
|
||||||
|
- 强调色: cyan / indigo / emerald 之一
|
||||||
|
- 字体: 标题用 Inter, 数字用 JetBrains Mono
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 不要做的事
|
||||||
|
|
||||||
|
- ❌ 改 API 路径 / 改字段名 / 改字段语义
|
||||||
|
- ❌ 加任何状态管理库 (Pinia) — 单文件 setup() 够用
|
||||||
|
- ❌ 加 webpack/vite — 必须保持单文件
|
||||||
|
- ❌ 引大 UI 库 (Element Plus / Naive UI) — 单文件塞不下
|
||||||
|
- ❌ 把 admin.html 和 setup.html 合并 — 它们是独立页面
|
||||||
|
- ❌ 改 axios 实例的 baseURL 或 401 拦截逻辑
|
||||||
|
- ❌ 改 hash 路由的 key 命名(`#dashboard` / `#voices` / `#settings`)
|
||||||
|
- ❌ **不要把单文件架构当最终形态** — 这是最小可工作版本(MVP),看板有 `ui-arch-debt` 卡片记录拆分计划。Codex 改造时保持单文件以便本次走通,但**不要引入新依赖让单文件更难拆**。例如:不要把 axios 拦截器写得依赖 10 个全局 ref;不要让 CSS 选择器深嵌套到无法抽离。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 验收清单(已按拆分后的结构更新)
|
||||||
|
|
||||||
|
改完回来, 我会逐项过:
|
||||||
|
|
||||||
|
- [ ] `go build ./...` 通过
|
||||||
|
- [ ] `go vet ./...` 通过
|
||||||
|
- [ ] `go test ./... -count=1` 全绿
|
||||||
|
- [ ] dev 起服后浏览器:
|
||||||
|
- [ ] `/setup` 流程能跑完
|
||||||
|
- [ ] `/admin` 登录 → dashboard → voices → settings → 增改删全通
|
||||||
|
- [ ] 改 auth_key 后自踢 → 重新登录 → 能用新 key
|
||||||
|
- [ ] CORS 未配时顶部 banner 出现, 配后消失
|
||||||
|
- [ ] 401 错误时自动跳回登录
|
||||||
|
- [ ] 视觉: 跟现在的 admin 比有明显提升 (logo / 排版 / 颜色 / 反馈)
|
||||||
|
- [ ] 至少在 1024px 宽度下好看
|
||||||
|
- [ ] 没有 console error / 404
|
||||||
|
- [ ] 新增页面复用了 `admin.css` + `admin-shell.js`,没有复制粘贴出第二套公共逻辑
|
||||||
|
|
||||||
|
> 旧的"`admin.html` ≤ 35KB / `setup.html` ≤ 15KB"两条**已作废**:admin 已拆成多页,
|
||||||
|
> 单文件大小不再是约束。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 回填方式(历史原文) / 当前做法
|
||||||
|
|
||||||
|
历史原文:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 拿回 router/admin.html + router/setup.html
|
||||||
|
2. 放到 D:\项目\Volcano-Engine-TTS-UI\router\
|
||||||
|
3. 我 build + 测, 通过后 commit
|
||||||
|
4. push, 服务器自动部署
|
||||||
|
```
|
||||||
|
|
||||||
|
拆分之后的做法:改动落在 §0.1 表格里对应的**具体文件**上(外壳/登录/音色/设置,以及共享的
|
||||||
|
`admin.css` / `admin-shell.js`),不再有"两个 .html"这一说。改完在**项目里**跑:
|
||||||
|
|
||||||
|
```
|
||||||
|
go build ./...
|
||||||
|
go vet ./...
|
||||||
|
go test ./... -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
三条全过再 commit 到 `develop`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 关联文件(不需要改, 但可读懂逻辑)
|
||||||
|
|
||||||
|
- `controller/admin.go` — admin API 实现
|
||||||
|
- `controller/settings.go` — settings API 实现
|
||||||
|
- `controller/setup.go` — setup API 实现
|
||||||
|
- `router/router.go` — Go 路由 + 挂载点(看 `//go:embed` 用法)
|
||||||
|
- `router/admin-shell.js` — 共享 axios 实例 / toast / 外壳挂载(改交互先看这里)
|
||||||
|
- `middleware/admin_auth.go` — RequireAdmin 实现(理解 401 怎么来)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 当前文件位置(已更新)
|
||||||
|
|
||||||
|
| 文件 | 大小 |
|
||||||
|
|---|---|
|
||||||
|
| `router/admin.html` | 5.5KB |
|
||||||
|
| `router/admin-login.html` | 2.1KB |
|
||||||
|
| `router/admin-voices.html` | 13.5KB |
|
||||||
|
| `router/admin-settings.html` | 14.4KB |
|
||||||
|
| `router/admin.css` | 20.2KB |
|
||||||
|
| `router/admin-shell.js` | 7.1KB |
|
||||||
|
| `router/setup.html` | 24.0KB |
|
||||||
|
|
||||||
|
`ui-handoff/` 目录里保留的是**改造前**的单文件副本与离线 mock 预览,仅供对照,不要回填。
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
# 上游适配器开发指南
|
||||||
|
|
||||||
|
> 面向对象:要为本项目接入**新上游 TTS 服务**的开发者(也包括未来的你)。
|
||||||
|
> 阅读前提:知道 OpenAI `/v1/audio/speech` 的请求/响应形状,能读 Go。
|
||||||
|
>
|
||||||
|
> ⚠️ **本文档分两部分,请先看清区别**:
|
||||||
|
> **第一部分「现状」描述的是代码里已经存在的东西**,可以直接照着读代码。
|
||||||
|
> **第二部分「目标架构」是待实施的设计**,代码里**还不存在** ——
|
||||||
|
> 里面的接口、表结构、目录都是提案,需要先按 §4 的分阶段计划落地。
|
||||||
|
> 不要把第二部分的接口名当成现有 API 去调用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 愿景与现状
|
||||||
|
|
||||||
|
### 1.1 愿景
|
||||||
|
|
||||||
|
把本服务做成**多渠道多上游聚合层**:对外始终是一个 OpenAI 兼容接口,
|
||||||
|
对内可以挂火山、OpenAI、Azure、阿里云、自建 GPT-SoVITS / IndexTTS……等任意上游。
|
||||||
|
调用方不需要知道音频是哪个厂商合成的 —— 换上游对客户端应当**透明**。
|
||||||
|
|
||||||
|
### 1.2 现状(第一部分)
|
||||||
|
|
||||||
|
目前**只有火山一个适配器**,且不是"插件式"的,而是**硬编码进主干**的。
|
||||||
|
这一点必须说清楚,因为它决定了新上游不能"只写一个文件就完事"。
|
||||||
|
|
||||||
|
```
|
||||||
|
入口 controller/tts.go OpenaiTTSHandler
|
||||||
|
│
|
||||||
|
│ opts := setting.GetTTSOptions() ← 类型是 volcano.Options
|
||||||
|
│ volcano.Synthesis(ctx, volcanoClient, opts, ...) ← 直接调实现,无接口
|
||||||
|
▼
|
||||||
|
适配器 adapter/volcano/ Options / Synthesis / ParseStream / UpstreamError
|
||||||
|
▲
|
||||||
|
│ setting.LoadRuntimeConfig 直接构造 volcano.Options / volcano.Additions
|
||||||
|
配置 setting/config.go
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键事实**:主干里有一处 `import "…/adapter/volcano"`,不是注解式的,
|
||||||
|
而是**类型级耦合**。所以"多上游"不是加文件,而是一次重构(见 §4)。
|
||||||
|
|
||||||
|
当前只有火山适配器时,以下代码是合理的;接第二个上游时必须逐个处理,
|
||||||
|
否则会出现"配置是火山的、路由是 OpenAI 的"这种错配。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 现有火山适配器契约(照此实现即可)
|
||||||
|
|
||||||
|
新适配器要能替代火山适配器,就必须满足下面这些**已经被主干依赖**的契约。
|
||||||
|
先读这一节,再读 §3 的接口设计。
|
||||||
|
|
||||||
|
### 2.1 包的职责划分
|
||||||
|
|
||||||
|
| 文件 | 职责 | 新适配器对应物 |
|
||||||
|
|---|---|---|
|
||||||
|
| `adapter/volcano/options.go` | 调用参数集合(`Options`)+ 上游扩展参数(`Additions`)+ `IsZero()` | provider 私有 opts |
|
||||||
|
| `adapter/volcano/request.go` | `Options` → 上游请求体;`buildRequest` 做必填校验;`convertSpeedToSpeechRate` 做 OpenAI speed 换算 | 请求构造 |
|
||||||
|
| `adapter/volcano/client.go` | 复用连接的 `*HTTPClient`(keep-alive 调优);`PostStream` 发流式请求 | 传输层 |
|
||||||
|
| `adapter/volcano/synthesis.go` | **唯一入口** `Synthesis(...)`;编排 埋点 → 构造 → 发送 → 解析 → 收尾 | `Synthesize(...)` |
|
||||||
|
| `adapter/volcano/response.go` | `ParseStream` 解析 NDJSON 流;`ParsedStream` 累计结果 | 响应解析 |
|
||||||
|
| `adapter/volcano/errors.go` | `UpstreamError{Code,Message,Stage,Wrapped}` + `IsAuth()` | 错误类型 |
|
||||||
|
| `adapter/volcano/audio.go` | `WrapWAVHeader` —— 上游只给 PCM 时本地拼标准 wav 头 | 格式收尾 |
|
||||||
|
|
||||||
|
### 2.2 主干对适配器的硬性依赖(必须逐条满足)
|
||||||
|
|
||||||
|
| # | 主干行为 | 位置 | 新适配器必须 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 用包级 `*HTTPClient` 发请求,连接复用 | `controller/tts.go:27,32` | 提供可复用的 client,不要每次 new |
|
||||||
|
| 2 | 可用 `ctx` 控制超时 | `controller/tts.go:203` | `Synthesize` 必须接受 `context.Context` |
|
||||||
|
| 3 | 埋点接口 **3 个方法** | `adapter/volcano/synthesis.go:20-24` | 见 §2.3 |
|
||||||
|
| 4 | 错误按 `Stage` 分类成 status label | `controller/tts.go:242-256` | 错误必须携带阶段:`request`/`http`/`stream`/`wrap` |
|
||||||
|
| 5 | 错误码进 metrics label | `metrics/metrics.go:160-172` | 非零 code 会被聚合成 `client`/`server`/`upstream` |
|
||||||
|
| 6 | 返回结果结构 | `dto.SynthesisResult` | 见 §2.4 |
|
||||||
|
| 7 | 上游不支持的输出格式要**降级** | `synthesis.go:63-65`、`README` 格式表 | 见 §2.5 |
|
||||||
|
|
||||||
|
### 2.3 埋点接口(MetricsRecorder)
|
||||||
|
|
||||||
|
适配器**不 import telemetry 包**,而是由主干注入一个接口实现
|
||||||
|
(`controller/tts.go:28` 注入 `metrics.AdapterRecorder{}`)。这是刻意的解耦,新适配器请沿用。
|
||||||
|
|
||||||
|
```go
|
||||||
|
type MetricsRecorder interface {
|
||||||
|
UpstreamStarted(speaker, model, format string)
|
||||||
|
UpstreamFinished(speaker, model, format, status string,
|
||||||
|
duration, ttfb time.Duration, chunks, audioBytes, errCode int)
|
||||||
|
UpstreamUsage(model string, textWords int)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
现状实现里 `speaker` 是**语料无关的上游音色 ID**,会被 `telemetry.SpeakerLabel`
|
||||||
|
做 `sha1[:8]` 哈希后才当 label(隐私保护,见 `metrics/metrics.go:127`)。
|
||||||
|
新适配器传自己的音色标识即可,主干不解释它的含义。
|
||||||
|
|
||||||
|
`status` 的取值约定(沿用即可,聚合看板依赖它们):
|
||||||
|
`ok` / `request_error` / `transport_error` / `http_<code>` / `stream_error` / `wrap_error`。
|
||||||
|
|
||||||
|
### 2.4 返回结果(dto.SynthesisResult)
|
||||||
|
|
||||||
|
```go
|
||||||
|
type SynthesisResult struct {
|
||||||
|
AudioData []byte // 最终可直接回给客户端的音频字节(已收尾,如 wav 拼好头)
|
||||||
|
Format string // 真实输出格式,决定 Content-Type
|
||||||
|
SampleRate int
|
||||||
|
ReqID string // 回写 X-Request-Id,排障用
|
||||||
|
TextWords int // 上游计费字符数(取不到填 0)
|
||||||
|
Chunks int
|
||||||
|
AudioBytes int
|
||||||
|
TTFB time.Duration // 首字节耗时
|
||||||
|
Duration time.Duration
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Format` 必须是主干认识的取值(见 `controller/tts.go:258` `contentTypeFor`):
|
||||||
|
`mp3` / `wav` / `pcm` / `ogg_opus`(别名 `opus`)。填别的会导致 Content-Type 错。
|
||||||
|
|
||||||
|
### 2.5 格式降级规则(容易踩)
|
||||||
|
|
||||||
|
客户端要的 `response_format` 和上游能给的格式**往往不一致**,必须显式降级:
|
||||||
|
|
||||||
|
| 客户端要 | 火山做法 | 为什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| `wav` | 上游请求 `pcm`,本地 `WrapWAVHeader` 拼头 | 流式 wav 每 chunk 带独立头,拼接即坏 |
|
||||||
|
| `aac` / `flac` | **降级成 mp3**,Content-Type 报 `audio/mpeg` | 火山不支持 |
|
||||||
|
| `opus` | 上游 `ogg_opus` | 命名差异 |
|
||||||
|
|
||||||
|
新适配器同样要回答这个问题:**你要的格式上游给不了时,降级到什么?**
|
||||||
|
并且必须在 `Format` 字段里如实回报真实格式,否则客户端拿到的是"声明 mp3 实际 pcm"。
|
||||||
|
|
||||||
|
### 2.6 实测踩过的坑(上游协议层面)
|
||||||
|
|
||||||
|
这些是火山适配器真实修过的 bug,接新上游时同类问题大概率复现:
|
||||||
|
|
||||||
|
1. **事件分发必须白名单**:`ParseStream` 只把 `event == "sentence"` 或事件为空的帧
|
||||||
|
当音频,未知事件只记日志**绝不拼进音频**(`response.go:71-113`)。
|
||||||
|
早期实现把 `TTSSubtitle` 也当音频,导致音频流被污染。
|
||||||
|
2. **结束帧要提前 break**:上游用 `code == 20000000` 表示流正常结束,收到后要
|
||||||
|
把剩余行读完再 break,否则连接不释放。
|
||||||
|
3. **单行可能很长**:`scanner.Buffer` 上限要放大(火山设到 8MB),否则大 chunk 会 EOF。
|
||||||
|
4. **所有音频帧 base64 解码失败要**返回错误,**不能跳过** —— 跳过等于静默产出半截音频。
|
||||||
|
5. **一个字节音频都没收到要报错**,不能返回空切片当成功(`response.go:125-131`)。
|
||||||
|
6. **错误响应体可能含换行**:直接拼进错误消息会伪造日志行,火山做了
|
||||||
|
`\n`/`\r` 转义(`synthesis.go:99-101`),新适配器照做。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 目标架构:Provider 抽象(待实施)
|
||||||
|
|
||||||
|
以下代码**当前不存在**,是建议落地的设计。
|
||||||
|
|
||||||
|
### 3.1 接口定义
|
||||||
|
|
||||||
|
建议新增 `adapter/provider/provider.go`(新包,**不放在** `adapter/volcano` 里,
|
||||||
|
避免主干继续依赖具体实现):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Provider 是一个上游 TTS 服务的统一抽象。
|
||||||
|
// 实现者只需要关心:把我的能力说清楚、把一次合成做完。
|
||||||
|
type Provider interface {
|
||||||
|
// Name 稳定标识,用于配置键、库表字段、metrics label。
|
||||||
|
// 约定全小写无空格:volcano / openai / azure / aliyun / gpt-sovits。
|
||||||
|
Name() string
|
||||||
|
|
||||||
|
// Capabilities 声明本适配器支持什么。
|
||||||
|
// 主干据此做校验与降级,不靠 if-else 猜实现。
|
||||||
|
Capabilities() Capabilities
|
||||||
|
|
||||||
|
// Synthesize 执行一次合成,阻塞到完成。
|
||||||
|
// 必须遵守:ctx 取消即返回;上游不支持 clientFormat 时按 Capabilities 降级,
|
||||||
|
// 并在 Result.Format 里如实回报真实格式。
|
||||||
|
Synthesize(ctx context.Context, req Request, mtr MetricsRecorder) (*dto.SynthesisResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Capabilities struct {
|
||||||
|
// 上游原生能产出的格式;客户端要的格式不在其中时,主干负责降级或拒绝。
|
||||||
|
Formats []string
|
||||||
|
// 是否支持用倍率控制语速。不支持时主干忽略 speed 而不是报错。
|
||||||
|
Speed bool
|
||||||
|
// 是否按字符计费并回传用量(决定 UpstreamUsage 是否有意义)。
|
||||||
|
Usage bool
|
||||||
|
// 是否支持多音色路由。不支持时该 provider 只能配一个默认音色。
|
||||||
|
Voices bool
|
||||||
|
// 是否需要资源/部署/项目 ID 这类额外维度(火山需要,OpenAI 不需要)。
|
||||||
|
ExtraScopes bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request 是主干交给适配器的、已归一化的合成请求。
|
||||||
|
// 注意:这里刻意不出现任何厂商专属字段。
|
||||||
|
type Request struct {
|
||||||
|
Text string
|
||||||
|
VoiceKey string // voices 表的主键语义(对外 voice 名或上游音色 ID)
|
||||||
|
Model string
|
||||||
|
Format string // 客户端期望格式(主干已归一化:mp3/wav/pcm/ogg_opus)
|
||||||
|
SampleRate int
|
||||||
|
Speed float64 // 1.0 = 原速
|
||||||
|
Language string
|
||||||
|
Extra map[string]string // 厂商专属配置,从 voices/settings 的 JSON 列读
|
||||||
|
|
||||||
|
// Credentials 由 provider 自己解释:
|
||||||
|
// 火山读 api_key + resource_id;OpenAI 只读 api_key。
|
||||||
|
Credentials Credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
type Credentials struct {
|
||||||
|
APIKey string
|
||||||
|
// 厂商额外凭证/路由维度。火山: resource_id;Azure: region;自建: base_url。
|
||||||
|
Scope map[string]string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 注册表
|
||||||
|
|
||||||
|
```go
|
||||||
|
// adapter/provider/registry.go
|
||||||
|
func Register(p Provider) // 通常在包的 init() 里调用
|
||||||
|
func Get(name string) (Provider, bool)
|
||||||
|
func Names() []string // admin UI 下拉框用
|
||||||
|
```
|
||||||
|
|
||||||
|
**要点**:注册要放在**主干之外**。建议 `main.go` 显式空导入:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
_ "github.com/volcano-tts/tts-api/adapter/volcano" // 注册 volcano
|
||||||
|
_ "github.com/volcano-tts/tts-api/adapter/openai" // 注册 openai
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
这样主干只依赖 `provider` 包,**新增上游不需要改主干任何一行** —— 这是本架构的核心收益,
|
||||||
|
请务必守住。`controller/tts.go` 里不应再出现 `adapter/volcano`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 分阶段实施计划
|
||||||
|
|
||||||
|
**强烈建议按阶段做,每阶段结束都能编译、能跑、行为不变。**
|
||||||
|
一次性重构"配置 + 路由 + 库表 + 前端 + metrics"五个面,几乎必然做出半成品。
|
||||||
|
|
||||||
|
### 阶段 0 · 抽出接口,零行为变更(纯重构)
|
||||||
|
|
||||||
|
目标:主干不再依赖具体实现,但**行为完全不变**,回归风险最低。
|
||||||
|
|
||||||
|
- [ ] 新增 `adapter/provider` 包,放 `Provider` / `Capabilities` / `Request` / `Credentials` / `MetricsRecorder`
|
||||||
|
- [ ] 让 `volcano` 包实现 `Provider`(本质是把 `Options` 包一层适配,`Synthesis` 改成方法)
|
||||||
|
- [ ] `controller/tts.go`:把 `volcanoClient` + `volcano.Synthesis` 换成 `provider.Get(name).Synthesize`
|
||||||
|
- [ ] `MetricsRecorder` 从 `adapter/volcano` **移出**到 `provider` 包(否则接口还是火山专属)
|
||||||
|
- [ ] `setting.LoadRuntimeConfig` 暂时继续构造 `volcano.Options`,通过一个
|
||||||
|
「volcano provider 的配置映射函数」转成 `provider.Request`
|
||||||
|
- [ ] 验收:`go build ./... && go vet ./... && go test ./... -count=1` 全绿,
|
||||||
|
且 `/v1/audio/speech` 行为与重构前逐字节一致(拿同一段文本对比音频长度与格式)
|
||||||
|
|
||||||
|
> 这一阶段**不要**动库表、不要动前端。目标只有一个:主干里那行
|
||||||
|
> `import adapter/volcano` 消失。
|
||||||
|
|
||||||
|
### 阶段 1 · 数据结构:provider 维度入库
|
||||||
|
|
||||||
|
> ⚠️ **先纠正一个常见误解:本项目的迁移框架其实没有接线。**
|
||||||
|
> `store/migrate.go` 里定义了 `Migration` 结构体和 `migrations` 切片,但那个切片是
|
||||||
|
> **空的**,`store/db.go` 的 `migrate()` 也从**不**读取它 —— 注释里写得很明白:
|
||||||
|
> "本期 M0 阶段 schemaVersion=1,migrate() 在 db.go 内做基础建表,**未触发 migrations 调度**"。
|
||||||
|
> 所以 `migrate()` 目前**只做 `CREATE TABLE IF NOT EXISTS`**,没有任何版本比较,
|
||||||
|
> 对已存在的表**完全不做变更**。
|
||||||
|
>
|
||||||
|
> 还有一个既有瑕疵:`db.go:148-150` 的 `INSERT OR IGNORE INTO schema_version (version) VALUES (?)`
|
||||||
|
> 语句里既没有字面量也没传参,绑定到 `?` 的是**零值**,所以表里实际写入的版本号是 **0**,
|
||||||
|
> 不是常量 `schemaVersion`(=1)。做 v2 迁移时请一并修掉,否则版本判断从一开始就是错的。
|
||||||
|
>
|
||||||
|
> **结论**:给 `voices` 加列**不会**自动发生,你必须自己写 `ALTER TABLE`,
|
||||||
|
> 并且在启动时能被重复执行而不报错(老库、新库都要能跑)。
|
||||||
|
|
||||||
|
本阶段要做:
|
||||||
|
|
||||||
|
- [ ] 在 `migrate()`(或新启用的迁移调度)里实现**幂等**加列:
|
||||||
|
先 `PRAGMA table_info(voices)` 查现有列,缺哪个 `ALTER TABLE ... ADD COLUMN` 哪个
|
||||||
|
(SQLite 的 `ADD COLUMN` 不支持 `IF NOT EXISTS`,重复执行会报错,必须先查)
|
||||||
|
- [ ] `voices` 表加列(见 §5.1),老库回填 `provider='volcano'`
|
||||||
|
- [ ] 修正 `schema_version` 写入零值的瑕疵,并写出真实版本号
|
||||||
|
- [ ] 若确实要启用 `migrate.go` 的调度框架,则把 `schemaVersion`(db.go,`=1`)
|
||||||
|
与 `schemaVersionRequested`(migrate.go,`=1`)一并提升到 2;
|
||||||
|
注意 `installer/lock.go` 里的 `schemaVersion = "1"`(字符串)**是另一套东西**——
|
||||||
|
它写在 `installed.lock` 里做安装标记,与库表结构版本是两回事,
|
||||||
|
两者要一起提升就一起提升,不要只改一边造成语义分裂
|
||||||
|
- [ ] `store.Voice` 结构体加字段 + JSON tag
|
||||||
|
- [ ] `store.GetVoiceForTTS` 返回值扩展(见 §5.2),`setting.Store` 接口同步改
|
||||||
|
- [ ] `cmd/dumpdb` 输出新列
|
||||||
|
- [ ] 验收:**用一个装了老数据的 `tts.db`** 启动服务(不要用全新库测),
|
||||||
|
确认自动加列成功、数据不丢、`/v1/audio/speech` 仍可用;
|
||||||
|
再重启一次确认迁移幂等(第二次不报错)
|
||||||
|
|
||||||
|
### 阶段 2 · 配置:按 provider 命名空间化
|
||||||
|
|
||||||
|
现状是 `settings` 表用**扁平键**,且键名全是火山语义(`default_resource_id`、
|
||||||
|
`default_speaker`、`model_type`…)。多上游后必须命名空间化。
|
||||||
|
|
||||||
|
- [ ] 保留全局键:`auth_key` / `cors_*` / `timeout` / `default_provider`
|
||||||
|
- [ ] 厂商专属键改为 `provider.<name>.<key>`,例如
|
||||||
|
`provider.volcano.api_key`、`provider.volcano.resource_id`、
|
||||||
|
`provider.openai.api_key`、`provider.openai.base_url`
|
||||||
|
- [ ] `LoadRuntimeConfig` 只装配**当前 `default_provider`** 的 `Request`,
|
||||||
|
不再构造 `volcano.Options`
|
||||||
|
- [ ] `setting.CheckEnvironmentVariables` / `LogStartupSummary` 去掉
|
||||||
|
`BYTEDANCE_TTS_*` 硬编码名,改成遍历 provider 的必填项(否则 `/health` 会误导运维)
|
||||||
|
- [ ] 迁移:老键 `api_key` → `provider.volcano.api_key` 等,一次性搬完并保留旧键只读兜底一轮
|
||||||
|
- [ ] 验收:除火山外没有任何 provider 时,服务行为仍与阶段 1 相同
|
||||||
|
|
||||||
|
### 阶段 3 · 观测:metrics 加 provider 维度
|
||||||
|
|
||||||
|
- [ ] `tts_upstream_total` / `tts_upstream_duration_seconds` / `tts_upstream_errors_total`
|
||||||
|
等**全部上游指标加 `provider` label**
|
||||||
|
- [ ] `metrics.AdapterRecorder` 增加 provider 字段(构造时注入 name)
|
||||||
|
- [ ] 仪表盘 PromQL 补 `by (provider)`
|
||||||
|
- [ ] ⚠️ **破坏性变更**:加 label 会让现有告警规则里的
|
||||||
|
`tts_upstream_total{...}` 聚合口径变化,发布说明里要写明
|
||||||
|
- [ ] 验收:`/metrics` 上能按 provider 拆分成功率与 P95
|
||||||
|
|
||||||
|
### 阶段 4 · 管理界面:provider 选择与音色归属
|
||||||
|
|
||||||
|
- [ ] `/setup` 向导第 1 步加"上游类型"选择,后续步骤按所选 provider 动态渲染字段
|
||||||
|
(火山显示 resource_id,**OpenAI 显示 base_url 且不显示 resource_id**)
|
||||||
|
- [ ] `router/admin-voices.html` 新增/编辑音色时选 provider 与 model
|
||||||
|
- [ ] `/api/voices` 的请求/响应加 `provider` 字段,`controller/admin.go` 校验
|
||||||
|
"provider 必须已注册",否则 400(不要留到合成时才 500)
|
||||||
|
- [ ] 删除 provider 最后一个音色时的保护逻辑(现在的 `ErrInUse` 只管 `default_speaker`)
|
||||||
|
- [ ] 验收:UI 上能并存火山与 OpenAI 音色,各自路由正确
|
||||||
|
|
||||||
|
### 阶段 5 · 第二个真实适配器(OpenAI 兼容)
|
||||||
|
|
||||||
|
**这一步才是对抽象的真正验收**。选 OpenAI 兼容上游(或其代理)最省事,因为
|
||||||
|
本项目对外就是 OpenAI 形状 —— 可以直接验证"请求/响应穿透"是否正确。
|
||||||
|
|
||||||
|
- [ ] 新建 `adapter/openai/`,只实现 §3.1 的接口
|
||||||
|
- [ ] 全程**不改** `controller/` `setting/` `store/` 任何一行
|
||||||
|
- [ ] 如果为了实现它而必须改主干,说明 §3.1 的抽象漏了维度 —— **回头修接口,而不是打补丁**
|
||||||
|
- [ ] 验收:同一份 `voices` 配置里两个 provider 可同时服务,
|
||||||
|
客户端只看到 `voice` 名不同
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 数据模型改动细节
|
||||||
|
|
||||||
|
### 5.1 voices 表
|
||||||
|
|
||||||
|
现状(真实 schema,`store/db.go:129-142`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS voices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
speaker TEXT NOT NULL, -- 上游音色 ID
|
||||||
|
resource_id TEXT NOT NULL, -- ← 火山专属概念,却写成 NOT NULL 通用列
|
||||||
|
model TEXT DEFAULT '',
|
||||||
|
language TEXT DEFAULT '',
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题**:`resource_id` 是火山独有概念(决定模型版本与计费),却成了全表必填列。
|
||||||
|
OpenAI 上游根本没有这个字段 —— 这正是"单上游假设"渗进数据模型的典型症状。
|
||||||
|
|
||||||
|
建议改为:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE voices ADD COLUMN provider TEXT NOT NULL DEFAULT 'volcano';
|
||||||
|
ALTER TABLE voices ADD COLUMN vendor_params TEXT NOT NULL DEFAULT '{}'; -- JSON,厂商私有
|
||||||
|
-- resource_id 保留但放宽为可空(或整体搬进 vendor_params,看迁移成本)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `provider` 建索引:`CREATE INDEX idx_voices_provider ON voices(provider)`
|
||||||
|
- `vendor_params` 存厂商私有键值(火山:`{"resource_id":"seed-icl-2.0","model_type":4}`),
|
||||||
|
主干**不解释**它的内容,原样交给对应 provider
|
||||||
|
- 迁移:`UPDATE voices SET provider='volcano' WHERE provider IS NULL OR provider=''`
|
||||||
|
|
||||||
|
### 5.2 GetVoiceForTTS 的签名
|
||||||
|
|
||||||
|
现状(`setting/config.go:426-436`,刻意用 5 个返回值避免循环 import):
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetVoiceForTTS(name string) (speaker, resourceID, model string, found bool, err error)
|
||||||
|
```
|
||||||
|
|
||||||
|
多上游后要扩成:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 返回该 voice 的 provider 名 + 厂商私有参数,主干据此把请求路由给对应适配器。
|
||||||
|
GetVoiceForTTS(name string) (providerName, speakerID, model string, vendorParams map[string]string, found bool, err error)
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:当年之所以不用 `(VoiceRef, error)`,是为了让 `store` 不 import `setting`
|
||||||
|
(避免循环 import)。加字段时**别再引入新的结构体依赖**,继续用扁平返回值或
|
||||||
|
定义在 `store` 包里的中性结构体。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 新增一个适配器的完整清单
|
||||||
|
|
||||||
|
假设要加 `foo` 上游:
|
||||||
|
|
||||||
|
**代码**
|
||||||
|
|
||||||
|
1. `adapter/foo/foo.go` —— 实现 `provider.Provider`(`Name()` 返回 `"foo"`)
|
||||||
|
2. `adapter/foo/request.go` —— `provider.Request` → foo 的 HTTP 请求体
|
||||||
|
3. `adapter/foo/response.go` —— 解析 foo 的响应;严格遵守 §2.6 的 6 条协议纪律
|
||||||
|
4. `adapter/foo/errors.go` —— 错误要带阶段,`Stage` 用统一枚举
|
||||||
|
5. `adapter/foo/foo_test.go` —— 至少覆盖:正常流、未知事件不污染音频、空音频报错、
|
||||||
|
base64 解码失败报错、格式降级回报正确 `Format`、`ctx` 取消能及时返回
|
||||||
|
(测试文件按项目策略不入库,本地保留并在合并前手动跑)
|
||||||
|
6. 在 `foo.go` 的 `init()` 里 `provider.Register(...)`
|
||||||
|
7. `main.go` 加一行空导入
|
||||||
|
|
||||||
|
**配置**
|
||||||
|
|
||||||
|
8. 定义必填项清单(供 `/health` 与启动摘要显示),不要写死在前端
|
||||||
|
9. 在 `/setup` 向导里加该 provider 的字段定义(阶段 4 之后是数据驱动,只需加配置)
|
||||||
|
|
||||||
|
**验收**
|
||||||
|
|
||||||
|
10. `go build ./... && go vet ./... && go test ./... -count=1` 全绿
|
||||||
|
11. 起服 → `/setup` 选 foo → 填凭证 → 加一个音色 → `curl /v1/audio/speech` 出声
|
||||||
|
12. 故意填错凭证 → 客户端应拿到明确的 4xx/5xx 与 `code`,日志里凭证是**打码**的
|
||||||
|
13. 不支持的 `response_format` → 按 `Capabilities` 降级,且 Content-Type 与真实字节一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 测试策略(注意本项目的特殊约束)
|
||||||
|
|
||||||
|
**本项目测试文件不入库**(`.gitignore` 里的 `*_test.go`),
|
||||||
|
测试保留在本地磁盘。这带来两个直接后果,必须知道:
|
||||||
|
|
||||||
|
1. **CI 跑不到你的测试**。`.github/workflows/ci.yml` 里的 `go test` 步骤在干净克隆上
|
||||||
|
只会打印 `no test files` 然后通过 —— 它只能守住"编译 + vet"。
|
||||||
|
所以适配器测试**必须在本地跑过再合并**:
|
||||||
|
```bash
|
||||||
|
go test ./... -count=1
|
||||||
|
```
|
||||||
|
2. **不要依赖测试文件来传递知识**。协议细节、踩坑记录要写进**代码注释**和本文档,
|
||||||
|
否则对下一个人(以及在 CI 里)等于不存在。
|
||||||
|
|
||||||
|
适配器测试的**最小可用集合**(按 §2.6 的坑逐条覆盖):
|
||||||
|
|
||||||
|
| 用例 | 断言 |
|
||||||
|
|---|---|
|
||||||
|
| 正常流 | 音频字节非空;`Chunks` / `TTFB` / `Format` 合理 |
|
||||||
|
| 未知事件帧 | 不进入音频;不报错;不改变 `Chunks` |
|
||||||
|
| 字幕帧 | 进 `Subtitles`,**不进** `AudioData` |
|
||||||
|
| 全流无音频 | 返回错误,不是"成功 + 空字节" |
|
||||||
|
| base64 损坏 | 返回错误,不是跳过继续 |
|
||||||
|
| 超长单行(>1MB) | 不 EOF |
|
||||||
|
| 上游 HTTP 4xx/5xx | 错误 `Stage == "http"`,携带 code |
|
||||||
|
| `ctx` 取消 | 及时返回,不泄漏 goroutine |
|
||||||
|
| 格式降级 | `Result.Format` 是真实格式,不是客户端要的格式 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 反模式清单(明确不要做)
|
||||||
|
|
||||||
|
- ❌ **不要在 `controller/` 里写 `switch providerName` 分发**。
|
||||||
|
分发只能走注册表,否则每加一个上游都要改主干,抽象就白做了。
|
||||||
|
- ❌ **不要让 `provider` 包 import 任何具体适配器**。依赖方向永远是
|
||||||
|
`具体适配器 → provider 包 ← 主干`。
|
||||||
|
- ❌ **不要在 `provider.Request` 里加厂商专属字段**(如 `ResourceID`)。
|
||||||
|
厂商私有参数一律走 `Credentials.Scope` / `Request.Extra` / `voices.vendor_params`。
|
||||||
|
- ❌ **不要把上游返回的原始音频直接当结果返回而不收尾**。
|
||||||
|
PCM 要拼头,降级要如实回报 `Format`(见 §2.5)。
|
||||||
|
- ❌ **不要静默跳过解析失败的帧**(§2.6 第 4 条)。
|
||||||
|
- ❌ **不要在日志里打印明文凭证或音色 ID**。沿用
|
||||||
|
`telemetry.MaskSpeaker` / `MaskResourceID`,新适配器的私有敏感字段也要加等价打码。
|
||||||
|
- ❌ **不要用一个 `settings` 键名承载多个 provider 的配置**。
|
||||||
|
- ❌ **不要为了省事把 provider 名写进 `settings` 的扁平键里当"事实上的路由"**,
|
||||||
|
路由的唯一权威是 `voices.provider` 与 `default_provider`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 关键文件索引
|
||||||
|
|
||||||
|
| 关注点 | 文件 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 请求入口 / 校验 / 路由 | `controller/tts.go` | `resolveClientFormat`、voice 查库、错误归一 |
|
||||||
|
| 合成编排参考实现 | `adapter/volcano/synthesis.go` | 埋点顺序、格式降级、错误分阶段 |
|
||||||
|
| 流解析参考实现 | `adapter/volcano/response.go` | 事件白名单、结束帧、base64 |
|
||||||
|
| 传输层参考实现 | `adapter/volcano/client.go` | 连接复用、nil 安全 |
|
||||||
|
| 配置装配 | `setting/config.go` | `LoadRuntimeConfig`、`setting.Store` 接口 |
|
||||||
|
| 库表与迁移 | `store/db.go`、`store/migrate.go` | `schemaVersion`、`migrate()`(**迁移框架未接线,见阶段 1 警示**);`installer/lock.go` 的 `schemaVersion` 是另一套 |
|
||||||
|
| 音色模型 | `store/voices.go` | `Voice`、`GetVoiceForTTS` |
|
||||||
|
| 指标 | `metrics/metrics.go` | `AdapterRecorder`、`codeLabel` |
|
||||||
|
| 结果类型 | `dto/tts.go` | `SynthesisResult`、`SubtitleEntry` |
|
||||||
|
| 未来抽象落点 | `adapter/provider/` | **尚不存在,待阶段 0 创建** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档版本:v1 · 对应代码基线:`develop` @ `ad342ec`(2026-10-03)*
|
||||||
|
*本文「现状」部分与代码逐行核对过;「目标架构」为提案,实施时请同步更新本文档。*
|
||||||
@@ -36,8 +36,12 @@ func newCounter(name, help string, labelNames []string) *Counter {
|
|||||||
func (c *Counter) Inc(labels Labels) { c.Add(1, labels) }
|
func (c *Counter) Inc(labels Labels) { c.Add(1, labels) }
|
||||||
|
|
||||||
// Add 累加 v(v 必须 >= 0)。
|
// Add 累加 v(v 必须 >= 0)。
|
||||||
|
//
|
||||||
|
// 空接收者(nil *Counter)安全:未初始化的全局指标(如 metrics.Init() 尚未调用时
|
||||||
|
// 的 metrics.UpstreamTotal)会被静默忽略,而不是 panic。
|
||||||
|
// 这让"直接调用 handler"的测试/复用场景不会因为漏掉指标初始化而崩溃。
|
||||||
func (c *Counter) Add(v float64, labels Labels) {
|
func (c *Counter) Add(v float64, labels Labels) {
|
||||||
if v < 0 {
|
if c == nil || v < 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
child := c.getOrCreate(labels)
|
child := c.getOrCreate(labels)
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ func newGauge(name, help string, labelNames []string) *Gauge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set 直接设置当前值。
|
// Set 直接设置当前值。
|
||||||
|
// 空接收者(nil *Gauge)安全,见 Counter.Add 的说明。
|
||||||
func (g *Gauge) Set(v float64, labels Labels) {
|
func (g *Gauge) Set(v float64, labels Labels) {
|
||||||
|
if g == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
child := g.getOrCreate(labels)
|
child := g.getOrCreate(labels)
|
||||||
child.bits.Store(float64bits(v))
|
child.bits.Store(float64bits(v))
|
||||||
}
|
}
|
||||||
@@ -45,7 +49,11 @@ func (g *Gauge) Inc(labels Labels) { g.Add(1, labels) }
|
|||||||
func (g *Gauge) Dec(labels Labels) { g.Add(-1, labels) }
|
func (g *Gauge) Dec(labels Labels) { g.Add(-1, labels) }
|
||||||
|
|
||||||
// Add 累加 v(可负)。
|
// Add 累加 v(可负)。
|
||||||
|
// 空接收者(nil *Gauge)安全,见 Counter.Add 的说明。
|
||||||
func (g *Gauge) Add(v float64, labels Labels) {
|
func (g *Gauge) Add(v float64, labels Labels) {
|
||||||
|
if g == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
child := g.getOrCreate(labels)
|
child := g.getOrCreate(labels)
|
||||||
for {
|
for {
|
||||||
bits := child.bits.Load()
|
bits := child.bits.Load()
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ func newHistogram(name, help string, buckets []float64, labelNames []string) *Hi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Observe 记录一个观测值。
|
// Observe 记录一个观测值。
|
||||||
|
// 空接收者(nil *Histogram)安全,见 Counter.Add 的说明。
|
||||||
func (h *Histogram) Observe(v float64, labels Labels) {
|
func (h *Histogram) Observe(v float64, labels Labels) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
child := h.getOrCreate(labels)
|
child := h.getOrCreate(labels)
|
||||||
for {
|
for {
|
||||||
bits := child.sumBits.Load()
|
bits := child.sumBits.Load()
|
||||||
|
|||||||
+6
-1
@@ -5,7 +5,12 @@
|
|||||||
// - 零外部依赖,只使用标准库;
|
// - 零外部依赖,只使用标准库;
|
||||||
// - label key 在指标注册时锁定,运行期不可新增(避免 cardinality 爆炸);
|
// - label key 在指标注册时锁定,运行期不可新增(避免 cardinality 爆炸);
|
||||||
// - 所有并发安全由实现保证,调用方无需加锁;
|
// - 所有并发安全由实现保证,调用方无需加锁;
|
||||||
// - Meter 是高层入口,NoopMeter 用于测试。
|
// - Meter 是高层入口,NoopMeter 用于测试;
|
||||||
|
// - **空接收者安全**: 未初始化的 *Counter / *Gauge / *Histogram(即 nil 指针)
|
||||||
|
// 上的 Inc/Add/Set/Observe 一律静默忽略,不 panic。
|
||||||
|
// 这条约定是为 metrics 包的全局指标变量服务的 —— 它们默认为 nil,只有 main
|
||||||
|
// 调用过 metrics.Init() 之后才有值;直接复用 handler 的场景(集成测试、
|
||||||
|
// 未来做成库)不应该因为漏掉指标初始化而崩掉整个请求处理。
|
||||||
package telemetry
|
package telemetry
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
Reference in New Issue
Block a user