feat: admin TTS settings (db-backed) with probe-before-save; worker uses dynamic tts config

This commit is contained in:
flym 2026-09-12 06:08:27 +08:00
parent af5b4afebf
commit 86fc51e486
5 changed files with 291 additions and 14 deletions

View File

@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { getUsageSummary, listAuditLogs, listTtsTasks } from "@/lib/admin";
import { fetchCsrf, getTtsSettings, testTtsSettings, updateTtsSettings, getUsageSummary, listAuditLogs, listTtsTasks, type TtsSettings, type TtsSettingsTestResult } from "@/lib/admin";
import { BrandIcon } from "@/components/brand-icon";
import { StatusCard } from "@/components/ui";
@ -45,10 +45,112 @@ const actionLabel: Record<string, string> = {
membership: "授予/变更会员",
membership_revoke: "撤销会员",
quota_adjustment: "额度调整",
tts_settings_update: "TTS 配置变更",
};
type ConfigForm = { upstream_url: string; api_key: string; timeout_seconds: number; default_model: string; reason: string };
function TtsConfigPanel() {
const [csrf, setCsrf] = useState("");
const [current, setCurrent] = useState<TtsSettings | null>(null);
const [form, setForm] = useState<ConfigForm>({ upstream_url: "", api_key: "", timeout_seconds: 120, default_model: "qwen3-tts", reason: "" });
const [busy, setBusy] = useState(false);
const [testing, setTesting] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [testResult, setTestResult] = useState<TtsSettingsTestResult | null>(null);
async function load() {
try {
const s = await getTtsSettings();
setCurrent(s);
setForm((f) => ({ ...f, upstream_url: s.upstream_url, timeout_seconds: s.timeout_seconds, default_model: s.default_model }));
} catch (e) {
setMessage({ ok: false, text: e instanceof Error ? e.message : "加载配置失败" });
}
}
useEffect(() => {
void fetchCsrf().then(setCsrf).catch(() => undefined);
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function save() {
setBusy(true);
setMessage(null);
try {
await updateTtsSettings({ upstream_url: form.upstream_url.trim(), api_key: form.api_key.trim() || undefined, timeout_seconds: form.timeout_seconds, default_model: form.default_model.trim(), reason: form.reason.trim() }, csrf);
setForm((f) => ({ ...f, api_key: "" }));
setTestResult(null);
setMessage({ ok: true, text: "配置已保存,新任务立即生效" });
await load();
} catch (e) {
setMessage({ ok: false, text: e instanceof Error ? e.message : "保存失败" });
} finally {
setBusy(false);
}
}
async function probe() {
setTesting(true);
setTestResult(null);
try {
const res = await testTtsSettings({ upstream_url: form.upstream_url.trim() || undefined, api_key: form.api_key.trim() || undefined, timeout_seconds: form.timeout_seconds, default_model: form.default_model.trim() || undefined }, csrf);
setTestResult(res);
} catch (e) {
setTestResult({ ok: false, message: e instanceof Error ? e.message : "探测请求失败" });
} finally {
setTesting(false);
}
}
return (
<div className="grid gap-5 lg:grid-cols-2">
<div className="rounded-2xl border border-line bg-panel/50 p-5">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">TTS </p>
<p className="mt-2 text-xs leading-6 text-subtle"> API Key 使</p>
<div className="mt-4 space-y-4">
<div><label className="field-label" htmlFor="tts-url">Base URL</label><input className="field-input" id="tts-url" placeholder="http://43.248.188.28:44480" value={form.upstream_url} onChange={(e) => setForm((f) => ({ ...f, upstream_url: e.target.value }))} /></div>
<div><label className="field-label" htmlFor="tts-key">API Key</label><input className="field-input" id="tts-key" placeholder={current?.api_key_set ? `已配置(${current.api_key_masked}),留空保持不变` : "未配置"} type="password" value={form.api_key} onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))} /></div>
<div className="grid gap-4 sm:grid-cols-2">
<div><label className="field-label" htmlFor="tts-timeout"></label><input className="field-input" id="tts-timeout" min={5} max={600} type="number" value={form.timeout_seconds} onChange={(e) => setForm((f) => ({ ...f, timeout_seconds: Math.max(5, Math.min(600, Number(e.target.value) || 120)) }))} /></div>
<div><label className="field-label" htmlFor="tts-model"></label><input className="field-input" id="tts-model" value={form.default_model} onChange={(e) => setForm((f) => ({ ...f, default_model: e.target.value }))} /></div>
</div>
<div><label className="field-label" htmlFor="tts-reason"></label><input className="field-input" id="tts-reason" maxLength={500} placeholder="例如:切换生产上游地址" value={form.reason} onChange={(e) => setForm((f) => ({ ...f, reason: e.target.value }))} /></div>
<div className="flex flex-wrap gap-3 pt-1">
<button className="button-secondary" disabled={testing || busy} onClick={() => void probe()} type="button"><BrandIcon />{testing ? "探测中…" : "探测当前表单"}</button>
<button className="button-primary" disabled={busy || testing || !csrf} onClick={() => void save()} type="button"><BrandIcon />{busy ? "保存中…" : "保存配置"}</button>
</div>
</div>
{message ? <p className={`mt-4 rounded-lg border px-3 py-2 text-sm ${message.ok ? "border-success/30 bg-success/10 text-success" : "border-danger/30 bg-danger/10 text-danger"}`}>{message.text}</p> : null}
</div>
<div className="space-y-5">
<div className="rounded-2xl border border-line bg-panel/50 p-5">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle"></p>
{current ? (
<dl className="mt-3 space-y-2 text-sm">
<div className="flex justify-between gap-4"><dt className="text-muted"></dt><dd className="text-copy">{current.source === "db" ? "管理后台(数据库)" : "服务器环境变量"}</dd></div>
<div className="flex justify-between gap-4"><dt className="text-muted"></dt><dd className="break-all text-copy">{current.upstream_url || "未配置"}</dd></div>
<div className="flex justify-between gap-4"><dt className="text-muted">API Key</dt><dd className="text-copy">{current.api_key_set ? current.api_key_masked : "未配置"}</dd></div>
<div className="flex justify-between gap-4"><dt className="text-muted"> / </dt><dd className="text-copy">{current.timeout_seconds}s · {current.default_model}</dd></div>
{current.updated_at ? <div className="flex justify-between gap-4"><dt className="text-muted"></dt><dd className="text-subtle">{new Date(current.updated_at).toLocaleString()}</dd></div> : null}
</dl>
) : <p className="mt-3 text-sm text-muted"></p>}
</div>
{testResult ? (
<div className={`rounded-2xl border p-5 ${testResult.ok ? "border-success/30 bg-success/5" : "border-danger/30 bg-danger/5"}`}>
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${testResult.ok ? "text-success" : "text-danger"}`}></p>
<p className="mt-2 text-sm leading-6 text-copy">{testResult.message}</p>
<p className="mt-2 text-xs text-subtle">{[testResult.status_code != null ? `HTTP ${testResult.status_code}` : null, testResult.latency_ms != null ? `耗时 ${testResult.latency_ms}ms` : null, testResult.content_type ? testResult.content_type : null, testResult.size_bytes ? `${(testResult.size_bytes / 1024).toFixed(1)} KB` : null].filter(Boolean).join(" · ")}</p>
</div>
) : null}
</div>
</div>
);
}
export function TtsAuditPanel() {
const [tab, setTab] = useState<"tasks" | "audit">("tasks");
const [tab, setTab] = useState<"tasks" | "config" | "audit">("tasks");
const [status, setStatus] = useState("");
const [taskPage, setTaskPage] = useState(1);
const [tasks, setTasks] = useState<Task[]>([]);
@ -100,10 +202,10 @@ export function TtsAuditPanel() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function switchTab(next: "tasks" | "audit") {
function switchTab(next: "tasks" | "config" | "audit") {
setTab(next);
if (next === "tasks") void loadTasks(1);
else void loadAudit(1);
else if (next === "audit") void loadAudit(1);
}
return (
@ -121,6 +223,9 @@ export function TtsAuditPanel() {
<button className={`rounded-lg px-4 py-2 text-sm font-semibold ${tab === "tasks" ? "bg-blue/10 text-cyan" : "text-muted hover:text-copy"}`} onClick={() => switchTab("tasks")} type="button">
</button>
<button className={`rounded-lg px-4 py-2 text-sm font-semibold ${tab === "config" ? "bg-blue/10 text-cyan" : "text-muted hover:text-copy"}`} onClick={() => switchTab("config")} type="button">
TTS
</button>
<button className={`rounded-lg px-4 py-2 text-sm font-semibold ${tab === "audit" ? "bg-blue/10 text-cyan" : "text-muted hover:text-copy"}`} onClick={() => switchTab("audit")} type="button">
</button>
@ -128,7 +233,7 @@ export function TtsAuditPanel() {
{error ? <div className="rounded-lg border border-danger/30 bg-danger/10 p-3 text-sm text-danger">{error}</div> : null}
{tab === "tasks" ? (
{tab === "config" ? <TtsConfigPanel /> : tab === "tasks" ? (
<div className="rounded-2xl border border-line bg-panel/50">
<div className="grid gap-3 border-b border-line p-5 sm:grid-cols-[1fr_auto]">
<select aria-label="按状态筛选" className="field-input" onChange={(e) => setStatus(e.target.value)} value={status}>

View File

@ -97,6 +97,39 @@ export async function revokeMembership(id: string, reason: string, csrf: string)
return request(`/admin/users/${id}/membership/revoke`, { method: "POST", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify({ reason }) });
}
export type TtsSettings = {
upstream_url: string;
api_key_set: boolean;
api_key_masked: string;
timeout_seconds: number;
default_model: string;
source: "db" | "env";
updated_at: string | null;
updated_by: string | null;
};
export type TtsSettingsTestResult = {
ok: boolean;
status_code?: number;
latency_ms?: number;
content_type?: string;
size_bytes?: number;
error_code?: string;
message: string;
};
export async function getTtsSettings(): Promise<TtsSettings> {
return request<TtsSettings>("/admin/tts/settings");
}
export async function updateTtsSettings(payload: { upstream_url: string; api_key?: string; timeout_seconds: number; default_model: string; reason: string }, csrf: string): Promise<TtsSettings> {
return request<TtsSettings>("/admin/tts/settings", { method: "PUT", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify(payload) });
}
export async function testTtsSettings(payload: { upstream_url?: string; api_key?: string; timeout_seconds?: number; default_model?: string; text?: string }, csrf: string): Promise<TtsSettingsTestResult> {
return request<TtsSettingsTestResult>("/admin/tts/settings/test", { method: "POST", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify(payload) });
}
export async function getUserQuota(id: string) {
return request(`/admin/users/${id}/quota`);
}

View File

@ -33,6 +33,8 @@ from .schemas import (
RegisterRequest,
RegisterResponse,
StatusRequest,
TtsSettingsTest,
TtsSettingsUpdate,
UserPublic,
VerificationConfirmRequest,
VerificationSendRequest,
@ -307,25 +309,70 @@ def cleanup_orphan_audio() -> None:
path.unlink(missing_ok=True)
def effective_tts_config(connection: Connection) -> dict:
row = connection.execute("SELECT * FROM tts_settings WHERE id = 1").fetchone()
url = row["upstream_url"].rstrip("/") if row and row["upstream_url"] else settings.tts_upstream_url
api_key = row["api_key"] if row and row["api_key"] else settings.tts_api_key
timeout = row["timeout_seconds"] if row and row["timeout_seconds"] else settings.tts_timeout_seconds
model = row["default_model"] if row and row["default_model"] else "qwen3-tts"
return {
"upstream_url": url,
"api_key": api_key,
"timeout_seconds": timeout,
"default_model": model,
"source": "db" if row and (row["upstream_url"] or row["api_key"]) else "env",
"updated_at": row["updated_at"] if row else None,
"updated_by": row["updated_by"] if row else None,
}
def read_tts_config() -> dict:
with psycopg.connect(settings.database_url, row_factory=psycopg.rows.dict_row) as connection:
return effective_tts_config(connection)
def mask_secret(value: str) -> str:
if not value:
return ""
if len(value) <= 8:
return "****"
return f"{value[:4]}{value[-4:]}"
def tts_settings_view(connection: Connection) -> dict:
cfg = effective_tts_config(connection)
return {
"upstream_url": cfg["upstream_url"],
"api_key_set": bool(cfg["api_key"]),
"api_key_masked": mask_secret(cfg["api_key"]),
"timeout_seconds": cfg["timeout_seconds"],
"default_model": cfg["default_model"],
"source": cfg["source"],
"updated_at": cfg["updated_at"].isoformat() if cfg["updated_at"] else None,
"updated_by": str(cfg["updated_by"]) if cfg["updated_by"] else None,
}
async def process_task(task: dict) -> None:
if not settings.tts_upstream_url:
cfg = await asyncio.to_thread(read_tts_config)
if not cfg["upstream_url"]:
await asyncio.to_thread(finish_task_failure, task, "UPSTREAM_NOT_CONFIGURED")
return
parameters = task["parameters"]
response_format = parameters.get("format", "wav")
payload = {"model": parameters.get("model", "qwen3-tts"), "input": task["text"], "voice": task["provider_voice_id"], "response_format": response_format, "speed": parameters.get("speed", 1.0)}
payload = {"model": parameters.get("model") or cfg["default_model"], "input": task["text"], "voice": task["provider_voice_id"], "response_format": response_format, "speed": parameters.get("speed", 1.0)}
headers = {"Content-Type": "application/json"}
if settings.tts_api_key:
headers["Authorization"] = f"Bearer {settings.tts_api_key}"
if cfg["api_key"]:
headers["Authorization"] = f"Bearer {cfg['api_key']}"
try:
async with httpx.AsyncClient(timeout=settings.tts_timeout_seconds) as client:
response = await client.post(f"{settings.tts_upstream_url}/v1/audio/speech", headers=headers, json=payload)
if response.status_code == 401:
async with httpx.AsyncClient(timeout=cfg["timeout_seconds"]) as client:
response = await client.post(f"{cfg['upstream_url']}/v1/audio/speech", headers=headers, json=payload)
if response.status_code == 401 or response.status_code == 403:
raise RuntimeError("UPSTREAM_UNAUTHORIZED")
if response.status_code >= 400:
raise RuntimeError(f"UPSTREAM_HTTP_{response.status_code}")
content_type = response.headers.get("content-type", "").split(";", 1)[0].lower()
if content_type not in {"audio/wav", "audio/x-wav"}:
if content_type not in {"audio/wav", "audio/x-wav", "audio/mpeg", "audio/mp3"}:
raise RuntimeError("UPSTREAM_NOT_AUDIO")
declared_size = response.headers.get("content-length")
if declared_size and int(declared_size) > 50 * 1024 * 1024:
@ -335,7 +382,8 @@ async def process_task(task: dict) -> None:
raise RuntimeError("UPSTREAM_AUDIO_INVALID")
if response_format == "wav" and (len(audio) < 12 or audio[:4] != b"RIFF" or audio[8:12] != b"WAVE"):
raise RuntimeError("UPSTREAM_AUDIO_CORRUPT")
await asyncio.to_thread(finish_task_success, task, audio, "audio/wav")
mime_type = "audio/mpeg" if response_format == "mp3" else "audio/wav"
await asyncio.to_thread(finish_task_success, task, audio, mime_type)
except httpx.TimeoutException:
await asyncio.to_thread(finish_task_failure, task, "UPSTREAM_TIMEOUT")
except Exception as exc:
@ -857,6 +905,72 @@ def admin_status(user_id: UUID, payload: StatusRequest, connection: Connection =
return {"id": updated["id"], "status": updated["status"], "sessions_revoked": revoked_count}
@app.get("/api/v1/admin/tts/settings")
def admin_get_tts_settings(connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
return tts_settings_view(connection)
@app.put("/api/v1/admin/tts/settings", dependencies=[Depends(require_csrf)])
def admin_update_tts_settings(payload: TtsSettingsUpdate, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
url = payload.upstream_url.strip().rstrip("/")
if url and not url.startswith(("http://", "https://")):
raise error("TTS_SETTINGS_INVALID", "上游地址必须以 http:// 或 https:// 开头", 422)
current = effective_tts_config(connection)
api_key = payload.api_key.strip() if payload.api_key else current["api_key"]
model = payload.default_model.strip() or "qwen3-tts"
connection.execute(
"""
INSERT INTO tts_settings(id, upstream_url, api_key, timeout_seconds, default_model, updated_by, updated_at)
VALUES (1, %s, %s, %s, %s, %s, now())
ON CONFLICT (id) DO UPDATE SET upstream_url = EXCLUDED.upstream_url, api_key = EXCLUDED.api_key, timeout_seconds = EXCLUDED.timeout_seconds, default_model = EXCLUDED.default_model, updated_by = EXCLUDED.updated_by, updated_at = now()
""",
(url, api_key, payload.timeout_seconds, model, actor["id"]),
)
connection.execute(
"INSERT INTO admin_audit_logs(actor_id, action, target_type, target_id, before_value, after_value, reason) VALUES (%s, 'tts_settings_update', 'tts_settings', '1', %s, %s, %s)",
(
actor["id"],
Json({"upstream_url": current["upstream_url"], "api_key_set": bool(current["api_key"]), "timeout_seconds": current["timeout_seconds"], "default_model": current["default_model"]}),
Json({"upstream_url": url, "api_key_set": bool(api_key), "api_key_changed": bool(payload.api_key), "timeout_seconds": payload.timeout_seconds, "default_model": model}),
payload.reason,
),
)
connection.commit()
return tts_settings_view(connection)
@app.post("/api/v1/admin/tts/settings/test", dependencies=[Depends(require_csrf)])
def admin_test_tts_settings(payload: TtsSettingsTest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
current = effective_tts_config(connection)
url = (payload.upstream_url or current["upstream_url"]).strip().rstrip("/")
api_key = payload.api_key.strip() if payload.api_key else current["api_key"]
timeout = payload.timeout_seconds or min(current["timeout_seconds"], 30)
model = payload.default_model or current["default_model"]
text = (payload.text or "测试").strip()[:50] or "测试"
if not url:
return {"ok": False, "error_code": "UPSTREAM_NOT_CONFIGURED", "message": "尚未填写上游地址"}
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
started = time.perf_counter()
try:
with httpx.Client(timeout=timeout) as client:
response = client.post(f"{url}/v1/audio/speech", headers=headers, json={"model": model, "input": text, "voice": "default", "response_format": "wav", "speed": 1.0})
except httpx.TimeoutException:
return {"ok": False, "error_code": "UPSTREAM_TIMEOUT", "message": f"请求超时({timeout}s请检查地址是否可达、端口是否正确"}
except httpx.HTTPError as exc:
return {"ok": False, "error_code": "UPSTREAM_UNREACHABLE", "message": f"无法连接上游:{exc.__class__.__name__}"}
latency_ms = int((time.perf_counter() - started) * 1000)
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if response.status_code in {401, 403}:
return {"ok": False, "status_code": response.status_code, "latency_ms": latency_ms, "error_code": "UPSTREAM_UNAUTHORIZED", "message": "鉴权失败,请检查 API Key 是否正确、是否过期"}
if response.status_code >= 400:
return {"ok": False, "status_code": response.status_code, "latency_ms": latency_ms, "error_code": f"UPSTREAM_HTTP_{response.status_code}", "message": f"上游返回错误:{response.text[:200]}"}
if content_type in {"audio/wav", "audio/x-wav"} and len(response.content) > 12 and response.content[:4] == b"RIFF":
return {"ok": True, "status_code": response.status_code, "latency_ms": latency_ms, "content_type": content_type, "size_bytes": len(response.content), "message": "探测成功:上游返回有效音频,配置可用"}
return {"ok": False, "status_code": response.status_code, "latency_ms": latency_ms, "content_type": content_type or "unknown", "error_code": "UPSTREAM_NOT_AUDIO", "message": "上游未返回有效音频,请检查地址、模型与参数(探测会消耗上游一次生成额度)"}
@app.delete("/api/v1/admin/users/{user_id}", dependencies=[Depends(require_csrf)])
def admin_delete_user(user_id: UUID, payload: MembershipRevokeRequest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
target = connection.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()

View File

@ -173,6 +173,22 @@ class MembershipRevokeRequest(BaseModel):
reason: str = Field(min_length=1, max_length=500)
class TtsSettingsUpdate(BaseModel):
upstream_url: str = Field(default="", max_length=500)
api_key: str | None = Field(default=None, max_length=2000)
timeout_seconds: int = Field(default=120, ge=5, le=600)
default_model: str = Field(default="qwen3-tts", min_length=1, max_length=100)
reason: str = Field(default="", max_length=500)
class TtsSettingsTest(BaseModel):
upstream_url: str | None = Field(default=None, max_length=500)
api_key: str | None = Field(default=None, max_length=2000)
timeout_seconds: int | None = Field(default=None, ge=5, le=600)
default_model: str | None = Field(default=None, max_length=100)
text: str | None = Field(default=None, max_length=50)
class AdminTaskFilter(BaseModel):
user_id: UUID | None = None
status: Literal["queued", "running", "succeeded", "failed"] | None = None

View File

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS tts_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
upstream_url TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
timeout_seconds INTEGER NOT NULL DEFAULT 120,
default_model TEXT NOT NULL DEFAULT 'qwen3-tts',
updated_by UUID,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);