fix(privacy): speaker ID 不再裸奔(3 处)

speaker 是火山复刻音色 ID,用户付费资产,不能裸奔。
本次修复 3 处泄漏:

1. controller/tts.go: /metrics label 用 sha1(speaker)[:8] 替代
   - /metrics 无鉴权,Prometheus 可枚举,原值直接暴露是最大的泄漏面
   - 哈希替代保留 per-voice 聚合能力,反推不出原值
   - Admin UI 想看原名通过 /api/voices 拿 name 字段

2. setting/config.go: 启动日志 LogStartupSummary 用 MaskSpeaker
   - API Key 早用 maskAPIKey,speaker 漏了
   - 现在 S_G8tEKnaJ1 形式

3. adapter/volcano/synthesis.go: 合成 debug + 成功日志用 MaskSpeaker
   - 之前 resource_id=xxx speaker=原始ID 直接打
   - 现在打码

新增 telemetry/labels.go:
- SpeakerLabel(s) -> sha1(s)[:8] (8 字符十六进制,稳定、不可逆、同输入同输出)
- MaskSpeaker(s) -> 前 4 + **** + 后 4 (空串返中文未设置,长度 <=4 全打码)
- 空 speaker label -> 'unknown' (Prometheus 禁止空 label)

单测 telemetry/labels_test.go 覆盖:
- SpeakerLabel: 同输入同输出 / 不同输入不同输出 / 空串 unknown / 长度 8 / 全是 hex
- MaskSpeaker: 各种长度边界 + 真实火山 ID 形式

e2e 验证(本机):
- 启动日志看到打码形式,无明文
- 自动化 grep 原始 ID 在 server log: 0 匹配
- go test ./... 全绿

未 push(用户要求先修后等 M2 一起发)
This commit is contained in:
sun
2026-08-29 21:42:57 +08:00
parent dbaee35400
commit 51bd7ae37a
4 changed files with 52 additions and 5 deletions
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/volcano-tts/tts-api/common" "github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/dto" "github.com/volcano-tts/tts-api/dto"
"github.com/volcano-tts/tts-api/telemetry"
) )
// MetricsRecorder 是适配器向上报告埋点的接口。 // MetricsRecorder 是适配器向上报告埋点的接口。
@@ -83,7 +84,7 @@ func Synthesis(
if common.DebugLog { if common.DebugLog {
log.Printf("TTS upstream: resource_id=%s speaker=%s model=%q format=%s sample_rate=%d speech_rate=%d additions=%q", log.Printf("TTS upstream: resource_id=%s speaker=%s model=%q format=%s sample_rate=%d speech_rate=%d additions=%q",
opts.ResourceID, opts.Speaker, opts.Model, opts.Format, opts.SampleRate, opts.SpeechRate, extractAdditionsForLog(body)) opts.ResourceID, telemetry.MaskSpeaker(opts.Speaker), opts.Model, opts.Format, opts.SampleRate, opts.SpeechRate, extractAdditionsForLog(body))
} }
resp, err := client.PostStream(ctx, "https://openspeech.bytedance.com/api/v3/tts/unidirectional", headers, body) resp, err := client.PostStream(ctx, "https://openspeech.bytedance.com/api/v3/tts/unidirectional", headers, body)
@@ -144,7 +145,7 @@ func Synthesis(
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "ok", duration, parsed.FirstChunk, parsed.Chunks, len(finalData), 0) mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "ok", duration, parsed.FirstChunk, parsed.Chunks, len(finalData), 0)
log.Printf("TTS 合成成功 - 音色=%s 格式=%s 文本=%d字 音频=%d字节 分片=%d 耗时=%v", log.Printf("TTS 合成成功 - 音色=%s 格式=%s 文本=%d字 音频=%d字节 分片=%d 耗时=%v",
opts.Speaker, clientFormat, len(text), len(finalData), parsed.Chunks, duration) telemetry.MaskSpeaker(opts.Speaker), clientFormat, len(text), len(finalData), parsed.Chunks, duration)
return &dto.SynthesisResult{ return &dto.SynthesisResult{
AudioData: finalData, AudioData: finalData,
+4 -1
View File
@@ -161,7 +161,10 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
finalLabels := telemetry.Labels{ finalLabels := telemetry.Labels{
"format": clientFormat, "format": clientFormat,
"speaker": opts.Speaker, // speaker 是火山复刻音色 ID(用户付费资产),不能直接出现在 /metrics label 里
//(无鉴权可枚举)。用 sha1[:8] 替代:同 speaker 同 label 保留 per-voice 观测,
//但反推不出原值。Admin UI 想要看原名通过 /api/voices 拿 name 字段。
"speaker": telemetry.SpeakerLabel(opts.Speaker),
"model": opts.Model, "model": opts.Model,
} }
if err != nil { if err != nil {
+3 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/volcano-tts/tts-api/adapter/volcano" "github.com/volcano-tts/tts-api/adapter/volcano"
"github.com/volcano-tts/tts-api/common" "github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/telemetry"
) )
// 全部环境变量读取的单一入口:其它包不允许直接 os.Getenv,只读这里的全局 Config。 // 全部环境变量读取的单一入口:其它包不允许直接 os.Getenv,只读这里的全局 Config。
@@ -318,7 +319,8 @@ func LogStartupSummary() {
checks := []ttsCheck{ checks := []ttsCheck{
{"BYTEDANCE_TTS_API_KEY", maskAPIKey(TTSOptions.APIKey), TTSOptions.APIKey != ""}, {"BYTEDANCE_TTS_API_KEY", maskAPIKey(TTSOptions.APIKey), TTSOptions.APIKey != ""},
{"BYTEDANCE_TTS_RESOURCE_ID", TTSOptions.ResourceID, TTSOptions.ResourceID != ""}, {"BYTEDANCE_TTS_RESOURCE_ID", TTSOptions.ResourceID, TTSOptions.ResourceID != ""},
{"BYTEDANCE_TTS_SPEAKER", TTSOptions.Speaker, TTSOptions.Speaker != ""}, // speaker 是火山复刻音色 ID(用户付费资产),日志里打码,避免明文落盘
{"BYTEDANCE_TTS_SPEAKER", telemetry.MaskSpeaker(TTSOptions.Speaker), TTSOptions.Speaker != ""},
} }
missingCount := 0 missingCount := 0
for _, c := range checks { for _, c := range checks {
+42 -1
View File
@@ -8,11 +8,52 @@
// - Meter 是高层入口,NoopMeter 用于测试。 // - Meter 是高层入口,NoopMeter 用于测试。
package telemetry package telemetry
import "sort" import (
"crypto/sha1"
"encoding/hex"
"sort"
"strings"
)
// Labels 是指标附加的标签集合。Value 在序列化时会按 Prometheus 规范转义。 // Labels 是指标附加的标签集合。Value 在序列化时会按 Prometheus 规范转义。
type Labels map[string]string type Labels map[string]string
// SpeakerLabel 把 speaker ID 转成不可逆的稳定短哈希,作为指标 label。
// 目的:保护火山复刻音色资产(speaker ID 是用户付费 / 隐私敏感);
// 同时仍能按 voice 聚合观测(同 speaker → 同 label)。
//
// 算法: sha1(s)[:8] = 32 bits 空间;典型 <100 个 voice 场景无碰撞风险。
// 空串返回 "unknown",避免 /metrics label 出现空值 (Prometheus 禁止空 label)。
//
// 注意: 这是**不可逆**哈希,不是加密;不可用于需要还原原始 speaker 的场景。
// Admin UI 想要看原名时,通过 /api/voices 拿 name 字段对照。
func SpeakerLabel(s string) string {
if s == "" {
return "unknown"
}
sum := sha1.Sum([]byte(s))
return hex.EncodeToString(sum[:])[:8]
}
// MaskSpeaker 把 speaker ID 部分打码用于日志输出。
// - 空 → "***"
// - 长度 ≤ 4 → 全打码
// - 其它 → 前 4 + **** + 后 4 (保留前缀便于肉眼区分 "S_xx 开头" vs "BV001_...")
// 例子: "S_G8tEKnaJ1" → "S_G8****naJ1"
func MaskSpeaker(s string) string {
if s == "" {
return "(未设置)"
}
if len(s) <= 4 {
return strings.Repeat("*", len(s))
}
// 找前 4 字符中第一个非 [A-Za-z0-9_] 字符做截断,避免截到奇怪位置
// (虽然火山 ID 实际都是 S_xxx 字母数字组合,这里保险)
prefix := s[:4]
suffix := s[len(s)-4:]
return prefix + "****" + suffix
}
// labelKey 计算一组标签的稳定 key,用于在内部 map 中唯一定位 child。 // labelKey 计算一组标签的稳定 key,用于在内部 map 中唯一定位 child。
// 缺失或多余的 label 一律视为空串,以保证 child 数量与 label 名集合一致。 // 缺失或多余的 label 一律视为空串,以保证 child 数量与 label 名集合一致。
func labelKey(names []string, labels Labels) string { func labelKey(names []string, labels Labels) string {