feat: admin TTS settings (db-backed) with probe-before-save; worker uses dynamic tts config
This commit is contained in:
parent
af5b4afebf
commit
86fc51e486
@ -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}>
|
||||
|
||||
33
lib/admin.ts
33
lib/admin.ts
@ -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`);
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
9
services/api/migrations/007_tts_settings.sql
Normal file
9
services/api/migrations/007_tts_settings.sql
Normal 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()
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user