From 618184689e9d800270f8dd16300789c99b92651f Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 21 Jul 2026 17:00:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(voice):=20=E8=AF=AD=E9=9F=B3=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=8E=A7=E5=88=B6=E9=9D=A2=E2=80=94=E2=80=94=E7=81=AB?= =?UTF-8?q?=E5=B1=B1=E8=B1=86=E5=8C=85=20appid/token/resource-id/=E9=9F=B3?= =?UTF-8?q?=E8=89=B2(=E5=8F=82=E6=95=B0=E8=90=BD=E5=9C=B0=E5=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JARVIS 不依赖火山账号能先做的第二块:配置层,做成'你参数一填就落库'的形态。 - voice.Config: AppID/AccessToken/ASRResourceID/TTSResourceID/TTSVoiceType,AccessToken 走 secrets AES 加密入库(镜像微信配置);ASREnabled/TTSEnabled 可各自独立判断。 - admin GET/PUT /admin/voice(RequireAdmin),token 空串=沿用已存、明文回显。 - admin「语音设置」页(运维组):五个字段 + ASR/TTS 就绪徽标 + '去哪拿参数'清单。 端点/音频格式(PCM 16k 单声道)由代码固定不入配置。WS 会话 + 火山 ASR/TTS 客户端等 用户回传 resource-id 后按官方 demo 协议做。build+tsc 绿。 Co-Authored-By: Claude Opus 4.8 --- sundynix-admin/src/api.ts | 32 +++++ sundynix-admin/src/pages/VoiceConfigPage.tsx | 122 ++++++++++++++++++ sundynix-admin/src/routes.tsx | 8 ++ .../internal/handler/voice_config.go | 81 ++++++++++++ sundynix-gateway/internal/router/router.go | 2 + sundynix-gateway/internal/voice/config.go | 50 +++++++ 6 files changed, 295 insertions(+) create mode 100644 sundynix-admin/src/pages/VoiceConfigPage.tsx create mode 100644 sundynix-gateway/internal/handler/voice_config.go create mode 100644 sundynix-gateway/internal/voice/config.go diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 9e6295e..08b6c13 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -267,6 +267,38 @@ export async function saveWechatMP(body: { appid: string; app_secret: string; to return { enabled: !!d.enabled }; } +// —— 语音(火山豆包)配置 —— +export interface VoiceConfig { + appid: string; + access_token: string; // 明文回显(单管理员后台) + asr_resource_id: string; + tts_resource_id: string; + tts_voice_type: string; + asr_enabled: boolean; + tts_enabled: boolean; +} + +export async function getVoiceConfig(): Promise { + const res = guard(await fetch(`${ADMIN}/voice`, { headers: authHeaders() })); + const d = (await res.json().catch(() => ({}))) as Partial & { error?: string }; + if (!res.ok) throw new Error(d.error ?? `voice config failed: ${res.status}`); + return { + appid: d.appid ?? "", access_token: d.access_token ?? "", asr_resource_id: d.asr_resource_id ?? "", + tts_resource_id: d.tts_resource_id ?? "", tts_voice_type: d.tts_voice_type ?? "", + asr_enabled: !!d.asr_enabled, tts_enabled: !!d.tts_enabled, + }; +} + +// access_token 传空串 = 沿用已保存的。 +export async function saveVoiceConfig(body: { + appid: string; access_token: string; asr_resource_id: string; tts_resource_id: string; tts_voice_type: string; +}): Promise<{ asr_enabled: boolean; tts_enabled: boolean }> { + const res = guard(await fetch(`${ADMIN}/voice`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) })); + const d = (await res.json().catch(() => ({}))) as { asr_enabled?: boolean; tts_enabled?: boolean; error?: string }; + if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`); + return { asr_enabled: !!d.asr_enabled, tts_enabled: !!d.tts_enabled }; +} + export interface WechatPayConfig { mchid: string; cert_serial: string; diff --git a/sundynix-admin/src/pages/VoiceConfigPage.tsx b/sundynix-admin/src/pages/VoiceConfigPage.tsx new file mode 100644 index 0000000..c1e3bc1 --- /dev/null +++ b/sundynix-admin/src/pages/VoiceConfigPage.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from "react"; +import { getVoiceConfig, saveVoiceConfig } from "../api"; + +// 运维 · 语音设置:火山引擎豆包语音(流式 ASR 耳朵 + 双向流式 TTS 嘴)。 +// AccessToken 明文回显(单管理员后台,方便核对;密文仍加密入库)。设计见 VOICE_DESIGN.md。 +export function VoiceConfigPage() { + const [appid, setAppid] = useState(""); + const [token, setToken] = useState(""); + const [asrRes, setAsrRes] = useState(""); + const [ttsRes, setTtsRes] = useState(""); + const [voice, setVoice] = useState(""); + const [asrOn, setAsrOn] = useState(false); + const [ttsOn, setTtsOn] = useState(false); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + const [ok, setOk] = useState(false); + + useEffect(() => { + getVoiceConfig() + .then((c) => { + setAppid(c.appid); + setToken(c.access_token); + setAsrRes(c.asr_resource_id); + setTtsRes(c.tts_resource_id); + setVoice(c.tts_voice_type); + setAsrOn(c.asr_enabled); + setTtsOn(c.tts_enabled); + }) + .catch((e) => setErr((e as Error).message)); + }, []); + + const save = async () => { + if (busy) return; + setBusy(true); + setErr(""); + setOk(false); + try { + const r = await saveVoiceConfig({ + appid: appid.trim(), + access_token: token, + asr_resource_id: asrRes.trim(), + tts_resource_id: ttsRes.trim(), + tts_voice_type: voice.trim(), + }); + setAsrOn(r.asr_enabled); + setTtsOn(r.tts_enabled); + setOk(true); + } catch (e) { + setErr((e as Error).message); + } finally { + setBusy(false); + } + }; + + const field = "mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none"; + const badge = (on: boolean, label: string) => + on ? ( + {label} 已就绪 + ) : ( + {label} 未配齐 + ); + + return ( +
+
+

语音设置 · 火山豆包

+

流式语音识别(耳朵) + 双向流式语音合成(嘴)。AccessToken 加密入库、后台明文可见

+
+ +
+
+

火山引擎配置

+ {badge(asrOn, "ASR")} + {badge(ttsOn, "TTS")} +
+ +
+ + + + + +
+ + {err &&

{err}

} + {ok &&

已保存

} + +
+ +
+
+ +
+

去哪拿这些参数

+
    +
  1. 火山「豆包语音控制台」→ 你的应用详情页,拿 AppIDAccess Token(ASR/TTS 通常同一应用共用)
  2. +
  3. ASR Resource-Id:在「流式语音识别 2.0」文档的鉴权小节(X-Api-Resource-Id 那一栏)
  4. +
  5. TTS Resource-Id:在「双向流式语音合成」文档的鉴权小节
  6. +
  7. 音色:控制台「音色管理」里挑一个(JARVIS 建议沉稳男声),填其 voice_type
  8. +
  9. 端点/音频格式(PCM 16k 单声道)由后端固定,不用填
  10. +
+
+
+ ); +} diff --git a/sundynix-admin/src/routes.tsx b/sundynix-admin/src/routes.tsx index fa8c08c..d24c69f 100644 --- a/sundynix-admin/src/routes.tsx +++ b/sundynix-admin/src/routes.tsx @@ -1,5 +1,6 @@ import { SubscriptionPage } from "./pages/SubscriptionPage"; import { LoginConfigPage } from "./pages/LoginConfigPage"; +import { VoiceConfigPage } from "./pages/VoiceConfigPage"; import { WechatUsersPage } from "./pages/WechatUsersPage"; import { lazy, type ReactNode } from "react"; @@ -86,6 +87,13 @@ export const routes: RouteDef[] = [ ready: true, element: , }, + { + path: "voice-config", + label: "语音设置", + group: "运维", + ready: true, + element: , + }, { path: "datasources", label: "数据源 & RAG", diff --git a/sundynix-gateway/internal/handler/voice_config.go b/sundynix-gateway/internal/handler/voice_config.go new file mode 100644 index 0000000..83cd711 --- /dev/null +++ b/sundynix-gateway/internal/handler/voice_config.go @@ -0,0 +1,81 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/sundynix/sundynix-gateway/internal/voice" +) + +// 语音(火山引擎豆包语音)配置的管理端存取。设计见 VOICE_DESIGN.md。 +// AccessToken AES 加密入库;单管理员后台明文回显(同微信配置),方便核对/复制。 + +const SettingVoice = "voice_config" // 语音配置(setting 表) + +func (h *Handler) loadVoiceConfig(ctx context.Context) voice.Config { + raw := h.db.GetSetting(ctx, SettingVoice) + if raw == "" { + return voice.Config{} + } + var c voice.Config + if json.Unmarshal([]byte(raw), &c) != nil { + return voice.Config{} + } + return c.DecryptFromStore() +} + +// AdminGetVoiceConfig: GET /api/v1/admin/voice —— 回显语音配置(token 明文,RequireAdmin 已拦)。 +func (h *Handler) AdminGetVoiceConfig(c *gin.Context) { + cfg := h.loadVoiceConfig(c.Request.Context()) + c.JSON(http.StatusOK, gin.H{ + "appid": cfg.AppID, + "access_token": cfg.AccessToken, + "asr_resource_id": cfg.ASRResourceID, + "tts_resource_id": cfg.TTSResourceID, + "tts_voice_type": cfg.TTSVoiceType, + "asr_enabled": cfg.ASREnabled(), + "tts_enabled": cfg.TTSEnabled(), + }) +} + +// AdminSaveVoiceConfig: PUT /api/v1/admin/voice —— 保存语音配置(token 空串=沿用已存)。 +func (h *Handler) AdminSaveVoiceConfig(c *gin.Context) { + var b struct { + AppID string `json:"appid"` + AccessToken string `json:"access_token"` + ASRResourceID string `json:"asr_resource_id"` + TTSResourceID string `json:"tts_resource_id"` + TTSVoiceType string `json:"tts_voice_type"` + } + if err := c.ShouldBindJSON(&b); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + ctx := c.Request.Context() + token := strings.TrimSpace(b.AccessToken) + if token == "" { + token = h.loadVoiceConfig(ctx).AccessToken // 留空=沿用已存 + } + cfg := voice.Config{ + AppID: strings.TrimSpace(b.AppID), + AccessToken: token, + ASRResourceID: strings.TrimSpace(b.ASRResourceID), + TTSResourceID: strings.TrimSpace(b.TTSResourceID), + TTSVoiceType: strings.TrimSpace(b.TTSVoiceType), + } + stored, err := cfg.EncryptedForStore() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "密钥加密失败: " + err.Error()}) + return + } + raw, _ := json.Marshal(stored) + if err := h.db.SetSetting(ctx, SettingVoice, string(raw)); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok", "asr_enabled": cfg.ASREnabled(), "tts_enabled": cfg.TTSEnabled()}) +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 0d24ae5..063d6dd 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -169,6 +169,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. admin.GET("/wechat-users", h.AdminWechatUsers) // 微信用户列表(openid/加入时间/余额) admin.GET("/payment/wechat", h.AdminGetWechatPay) admin.PUT("/payment/wechat", h.AdminSaveWechatPay) + admin.GET("/voice", h.AdminGetVoiceConfig) // 语音(火山豆包)配置 + admin.PUT("/voice", h.AdminSaveVoiceConfig) admin.GET("/sub-plans", h.AdminSubPlans) // 订阅套餐(含下架) admin.PUT("/sub-plans", h.AdminSaveSubPlan) // 配价格/时长/发放节奏 admin.GET("/subscriptions", h.AdminSubscriptions) // 全平台订阅观测 diff --git a/sundynix-gateway/internal/voice/config.go b/sundynix-gateway/internal/voice/config.go new file mode 100644 index 0000000..d49bb4a --- /dev/null +++ b/sundynix-gateway/internal/voice/config.go @@ -0,0 +1,50 @@ +package voice + +import "github.com/sundynix/sundynix-shared/secrets" + +// Config 是语音交互(火山引擎豆包语音)所需配置。AccessToken 加密入库、后台明文回显(同微信配置)。 +// +// 火山鉴权走 WS 握手 header:X-Api-App-Key(AppID) / X-Api-Access-Key(AccessToken) / +// X-Api-Resource-Id(区分服务,ASR 与双向 TTS 各一个)。AppID/Token 通常同一应用共用。 +// 端点/音频格式(PCM 16k 单声道等)由代码固定,不入用户配置。 +type Config struct { + AppID string `json:"appid"` // 火山应用 AppID(X-Api-App-Key) + AccessToken string `json:"access_token"` // 火山 Access Token(X-Api-Access-Key,密文入库) + ASRResourceID string `json:"asr_resource_id"` // 流式语音识别 X-Api-Resource-Id + TTSResourceID string `json:"tts_resource_id"` // 双向流式 TTS X-Api-Resource-Id + TTSVoiceType string `json:"tts_voice_type"` // 音色(如 zh_male_… / BV700_streaming) +} + +// ASREnabled / TTSEnabled 分别报告耳朵、嘴是否配齐(可各自独立启用)。 +func (c Config) ASREnabled() bool { + return c.AppID != "" && c.AccessToken != "" && c.ASRResourceID != "" +} +func (c Config) TTSEnabled() bool { + return c.AppID != "" && c.AccessToken != "" && c.TTSResourceID != "" && c.TTSVoiceType != "" +} + +// Enabled 报告语音整体是否可用(耳朵 + 嘴都配齐)。 +func (c Config) Enabled() bool { return c.ASREnabled() && c.TTSEnabled() } + +// EncryptedForStore 返回 AccessToken 已加密的副本,用于落库。 +func (c Config) EncryptedForStore() (Config, error) { + if c.AccessToken == "" { + return c, nil + } + enc, err := secrets.Encrypt(c.AccessToken) + if err != nil { + return c, err + } + c.AccessToken = enc + return c, nil +} + +// DecryptFromStore 把库内密文 AccessToken 还原为明文。 +func (c Config) DecryptFromStore() Config { + if c.AccessToken != "" { + if plain, err := secrets.Decrypt(c.AccessToken); err == nil { + c.AccessToken = plain + } + } + return c +}