Merge pull request 'feat(wechat): 关注公众号后自动回复欢迎语(被动回复,可后台配置)' (#12) from feat/site into main
deploy-132 / deploy (push) Successful in 4m18s
deploy-132 / deploy (push) Successful in 4m18s
Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
@@ -71,7 +71,7 @@ export async function me(): Promise<AuthUser | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- 模型配置(id 为雪花字符串)----
|
// ---- 模型配置(id 为雪花字符串)----
|
||||||
export type Kind = "chat" | "embedding";
|
export type Kind = "chat" | "voice" | "embedding";
|
||||||
|
|
||||||
export interface Model {
|
export interface Model {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -232,6 +232,7 @@ export interface WechatMPConfig {
|
|||||||
appid: string;
|
appid: string;
|
||||||
token: string; // 消息推送签名校验用(与公众平台服务器配置一致)
|
token: string; // 消息推送签名校验用(与公众平台服务器配置一致)
|
||||||
app_secret: string; // 单管理员后台:明文回显
|
app_secret: string; // 单管理员后台:明文回显
|
||||||
|
welcome: string; // 关注后自动回复的欢迎语(空=用默认)
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,17 +256,48 @@ export async function getWechatMP(): Promise<WechatMPConfig> {
|
|||||||
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { headers: authHeaders() }));
|
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { headers: authHeaders() }));
|
||||||
const d = (await res.json().catch(() => ({}))) as Partial<WechatMPConfig> & { error?: string };
|
const d = (await res.json().catch(() => ({}))) as Partial<WechatMPConfig> & { error?: string };
|
||||||
if (!res.ok) throw new Error(d.error ?? `wechat-mp failed: ${res.status}`);
|
if (!res.ok) throw new Error(d.error ?? `wechat-mp failed: ${res.status}`);
|
||||||
return { appid: d.appid ?? "", token: d.token ?? "", app_secret: d.app_secret ?? "", enabled: !!d.enabled };
|
return { appid: d.appid ?? "", token: d.token ?? "", app_secret: d.app_secret ?? "", welcome: d.welcome ?? "", enabled: !!d.enabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
// app_secret 传空串 = 沿用已保存的。
|
// app_secret 传空串 = 沿用已保存的。
|
||||||
export async function saveWechatMP(body: { appid: string; app_secret: string; token: string }): Promise<{ enabled: boolean }> {
|
export async function saveWechatMP(body: { appid: string; app_secret: string; token: string; welcome: string }): Promise<{ enabled: boolean }> {
|
||||||
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) }));
|
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) }));
|
||||||
const d = (await res.json().catch(() => ({}))) as { enabled?: boolean; error?: string };
|
const d = (await res.json().catch(() => ({}))) as { enabled?: boolean; error?: string };
|
||||||
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
|
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
|
||||||
return { enabled: !!d.enabled };
|
return { enabled: !!d.enabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— 语音(火山豆包)配置 · 新版 API Key 鉴权 ——
|
||||||
|
export interface VoiceConfig {
|
||||||
|
api_key: string; // 明文回显(单管理员后台)
|
||||||
|
asr_resource_id: string;
|
||||||
|
tts_resource_id: string;
|
||||||
|
tts_voice_type: string;
|
||||||
|
asr_enabled: boolean;
|
||||||
|
tts_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVoiceConfig(): Promise<VoiceConfig> {
|
||||||
|
const res = guard(await fetch(`${ADMIN}/voice`, { headers: authHeaders() }));
|
||||||
|
const d = (await res.json().catch(() => ({}))) as Partial<VoiceConfig> & { error?: string };
|
||||||
|
if (!res.ok) throw new Error(d.error ?? `voice config failed: ${res.status}`);
|
||||||
|
return {
|
||||||
|
api_key: d.api_key ?? "", 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// api_key 传空串 = 沿用已保存的。
|
||||||
|
export async function saveVoiceConfig(body: {
|
||||||
|
api_key: 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 {
|
export interface WechatPayConfig {
|
||||||
mchid: string;
|
mchid: string;
|
||||||
cert_serial: string;
|
cert_serial: string;
|
||||||
@@ -669,11 +701,27 @@ export interface ToolGroup {
|
|||||||
up: boolean;
|
up: boolean;
|
||||||
tools: ToolInfo[] | null;
|
tools: ToolInfo[] | null;
|
||||||
}
|
}
|
||||||
|
export interface NatsStreamHealth {
|
||||||
|
name: string;
|
||||||
|
leader: string;
|
||||||
|
replicas_healthy: number;
|
||||||
|
replicas_total: number;
|
||||||
|
messages: number;
|
||||||
|
}
|
||||||
|
export interface NatsClusterStatus {
|
||||||
|
connected: boolean;
|
||||||
|
connected_to: string;
|
||||||
|
known_servers: number;
|
||||||
|
rtt_ms: number;
|
||||||
|
streams: NatsStreamHealth[] | null;
|
||||||
|
degraded: number;
|
||||||
|
}
|
||||||
export interface SystemStatus {
|
export interface SystemStatus {
|
||||||
checked_at: string;
|
checked_at: string;
|
||||||
infra: StatusItem[];
|
infra: StatusItem[];
|
||||||
services: StatusItem[];
|
services: StatusItem[];
|
||||||
tools: ToolGroup[];
|
tools: ToolGroup[];
|
||||||
|
nats?: NatsClusterStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStatus(): Promise<SystemStatus> {
|
export async function getStatus(): Promise<SystemStatus> {
|
||||||
|
|||||||
@@ -37,10 +37,18 @@ export function ModelManager({
|
|||||||
|
|
||||||
const set = (k: keyof ModelInput, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
const set = (k: keyof ModelInput, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const editing = !!form.id;
|
||||||
|
|
||||||
|
// onEdit 把某行载入表单就地编辑(api_key 留空=沿用现有密钥,后端按 id 更新而非新增)。
|
||||||
|
const onEdit = (m: Model) => {
|
||||||
|
setForm({ id: m.id, kind, provider: m.provider, base_url: m.base_url, api_key: "", model: m.model });
|
||||||
|
setMsg("");
|
||||||
|
};
|
||||||
|
|
||||||
const onSave = async () => {
|
const onSave = async () => {
|
||||||
try {
|
try {
|
||||||
await saveModel({ ...form, kind });
|
await saveModel({ ...form, kind });
|
||||||
setMsg("✓ 已保存");
|
setMsg(editing ? "✓ 已更新并热更新" : "✓ 已保存");
|
||||||
setForm(empty);
|
setForm(empty);
|
||||||
refresh();
|
refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -107,6 +115,12 @@ export function ModelManager({
|
|||||||
激活
|
激活
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(m)}
|
||||||
|
className="rounded border px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 删除不可撤销,且删掉在用模型会直接打断线上推理——所以要二次确认,
|
// 删除不可撤销,且删掉在用模型会直接打断线上推理——所以要二次确认,
|
||||||
@@ -131,7 +145,9 @@ export function ModelManager({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="max-w-xl">
|
<section className="max-w-xl">
|
||||||
<h2 className="mb-3 text-sm font-semibold text-gray-700">登记(开发期:第三方在线 API,OpenAI 兼容)</h2>
|
<h2 className="mb-3 text-sm font-semibold text-gray-700">
|
||||||
|
{editing ? `编辑「${form.model}」(改完保存即热更新)` : "登记(开发期:第三方在线 API,OpenAI 兼容)"}
|
||||||
|
</h2>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<label className="text-xs text-gray-500">
|
<label className="text-xs text-gray-500">
|
||||||
Provider
|
Provider
|
||||||
@@ -170,12 +186,14 @@ export function ModelManager({
|
|||||||
className="mt-1 w-full rounded border px-2 py-1 text-sm font-mono"
|
className="mt-1 w-full rounded border px-2 py-1 text-sm font-mono"
|
||||||
value={form.api_key}
|
value={form.api_key}
|
||||||
onChange={(e) => set("api_key", e.target.value)}
|
onChange={(e) => set("api_key", e.target.value)}
|
||||||
placeholder="sk-…"
|
placeholder={editing ? "留空=沿用现有密钥" : "sk-…"}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
<button onClick={onSave} className="rounded bg-violet-600 px-3 py-1 text-sm text-white">保存</button>
|
<button onClick={onSave} className="rounded bg-violet-600 px-3 py-1 text-sm text-white">
|
||||||
|
{editing ? "更新" : "保存"}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onTest}
|
onClick={onTest}
|
||||||
disabled={testing || !form.base_url}
|
disabled={testing || !form.base_url}
|
||||||
@@ -183,6 +201,14 @@ export function ModelManager({
|
|||||||
>
|
>
|
||||||
{testing ? "测试中…" : "测试连接"}
|
{testing ? "测试中…" : "测试连接"}
|
||||||
</button>
|
</button>
|
||||||
|
{editing && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setForm(empty); setMsg(""); }}
|
||||||
|
className="rounded border px-3 py-1 text-sm text-gray-500 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{msg && <span className="text-xs text-gray-600">{msg}</span>}
|
{msg && <span className="text-xs text-gray-600">{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export function LoginConfigPage() {
|
|||||||
const [appid, setAppid] = useState("");
|
const [appid, setAppid] = useState("");
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [secret, setSecret] = useState(""); // 留空=沿用已存
|
const [secret, setSecret] = useState(""); // 留空=沿用已存
|
||||||
|
const [welcome, setWelcome] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
const [ok, setOk] = useState(false);
|
const [ok, setOk] = useState(false);
|
||||||
@@ -19,6 +20,7 @@ export function LoginConfigPage() {
|
|||||||
setAppid(c.appid);
|
setAppid(c.appid);
|
||||||
setToken(c.token);
|
setToken(c.token);
|
||||||
setSecret(c.app_secret);
|
setSecret(c.app_secret);
|
||||||
|
setWelcome(c.welcome);
|
||||||
})
|
})
|
||||||
.catch((e) => setErr((e as Error).message));
|
.catch((e) => setErr((e as Error).message));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -29,8 +31,8 @@ export function LoginConfigPage() {
|
|||||||
setErr("");
|
setErr("");
|
||||||
setOk(false);
|
setOk(false);
|
||||||
try {
|
try {
|
||||||
const r = await saveWechatMP({ appid: appid.trim(), app_secret: secret, token: token.trim() });
|
const r = await saveWechatMP({ appid: appid.trim(), app_secret: secret, token: token.trim(), welcome: welcome.trim() });
|
||||||
setCfg((c) => (c ? { ...c, appid: appid.trim(), token: token.trim(), app_secret: secret, enabled: r.enabled } : c));
|
setCfg((c) => (c ? { ...c, appid: appid.trim(), token: token.trim(), app_secret: secret, welcome: welcome.trim(), enabled: r.enabled } : c));
|
||||||
setOk(true);
|
setOk(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
@@ -77,6 +79,12 @@ export function LoginConfigPage() {
|
|||||||
<input value={token} onChange={(e) => setToken(e.target.value)} placeholder="自定义一串字母数字"
|
<input value={token} onChange={(e) => setToken(e.target.value)} placeholder="自定义一串字母数字"
|
||||||
className="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" />
|
className="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" />
|
||||||
</label>
|
</label>
|
||||||
|
<label className="text-xs text-gray-500 md:col-span-2">
|
||||||
|
关注欢迎语(用户关注后自动回复。留空则用默认,建议写上下载链接)
|
||||||
|
<textarea value={welcome} onChange={(e) => setWelcome(e.target.value)} rows={3}
|
||||||
|
placeholder={"欢迎关注!\n下载客户端登录即可开始体验。\n下载地址:https://…"}
|
||||||
|
className="mt-1 block w-full resize-y rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
|
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ModelManager } from "../components/ModelManager";
|
import { ModelManager } from "../components/ModelManager";
|
||||||
|
import type { Kind } from "../api";
|
||||||
|
|
||||||
|
// 模型配置页:工作主力(chat) / JARVIS 语音(voice) / 向量化(embedding) 三 Tab 统一管理。
|
||||||
|
// 工作与语音分开:编排/报告用强模型(chat),JARVIS 语音对话用快模型(voice, 低时延)——各配各激活。
|
||||||
|
const TABS: { key: Kind; label: string }[] = [
|
||||||
|
{ key: "chat", label: "工作主力模型" },
|
||||||
|
{ key: "voice", label: "JARVIS 语音模型" },
|
||||||
|
{ key: "embedding", label: "向量化模型" },
|
||||||
|
];
|
||||||
|
|
||||||
// 模型配置页:Chat / Embedding 双 Tab 统一管理。
|
|
||||||
export function ModelsPage() {
|
export function ModelsPage() {
|
||||||
const [tab, setTab] = useState<"chat" | "embedding">("chat");
|
const [tab, setTab] = useState<Kind>("chat");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -11,46 +19,46 @@ export function ModelsPage() {
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-150 pb-4">
|
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-150 pb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-gray-800">模型管理</h3>
|
<h3 className="text-base font-semibold text-gray-800">模型管理</h3>
|
||||||
<p className="text-xs text-gray-400">配置全平台的对话模型(LLM)与向量化模型(Embedding)</p>
|
<p className="text-xs text-gray-400">工作主力模型跑编排/报告(要强)· JARVIS 语音模型跑实时对话(要快)· 向量化模型跑 RAG</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex rounded-lg border border-gray-200 bg-gray-50/50 p-1">
|
<div className="flex rounded-lg border border-gray-200 bg-gray-50/50 p-1">
|
||||||
<button
|
{TABS.map((t) => (
|
||||||
onClick={() => setTab("chat")}
|
<button
|
||||||
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
|
key={t.key}
|
||||||
tab === "chat"
|
onClick={() => setTab(t.key)}
|
||||||
? "bg-white text-violet-700 shadow-sm"
|
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
|
||||||
: "text-gray-500 hover:text-gray-700"
|
tab === t.key ? "bg-white text-violet-700 shadow-sm" : "text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
对话模型 (Chat)
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
<button
|
))}
|
||||||
onClick={() => setTab("embedding")}
|
|
||||||
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
|
|
||||||
tab === "embedding"
|
|
||||||
? "bg-white text-violet-700 shadow-sm"
|
|
||||||
: "text-gray-500 hover:text-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
向量化模型 (Embedding)
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 模型管理组件 */}
|
{/* 模型管理组件 */}
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
{tab === "chat" ? (
|
{tab === "chat" && (
|
||||||
<ModelManager
|
<ModelManager
|
||||||
kind="chat"
|
kind="chat"
|
||||||
title="对话模型配置 (chat → Dispatcher)"
|
title="工作主力模型 (chat → Dispatcher 编排/报告)"
|
||||||
baseUrlHint="https://api.deepseek.com"
|
baseUrlHint="https://api.deepseek.com"
|
||||||
modelHint="deepseek-chat"
|
modelHint="deepseek-v4-pro"
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
|
{tab === "voice" && (
|
||||||
|
<ModelManager
|
||||||
|
kind="voice"
|
||||||
|
title="JARVIS 语音模型 (voice → 语音对话,选快模型抢首字时延)"
|
||||||
|
baseUrlHint="https://api.deepseek.com"
|
||||||
|
modelHint="deepseek-v4-flash"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === "embedding" && (
|
||||||
<ModelManager
|
<ModelManager
|
||||||
kind="embedding"
|
kind="embedding"
|
||||||
title="向量化模型配置 (embedding → mcp-go RAG)"
|
title="向量化模型 (embedding → mcp-go RAG)"
|
||||||
baseUrlHint="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
baseUrlHint="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
modelHint="text-embedding-v3"
|
modelHint="text-embedding-v3"
|
||||||
/>
|
/>
|
||||||
@@ -59,4 +67,3 @@ export function ModelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -330,6 +330,9 @@ export function StatusPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{item.up && item.latency_ms != null && item.latency_ms > 0 && (
|
||||||
|
<span className="font-mono text-[10px] text-slate-400 tabular-nums">{item.latency_ms}ms</span>
|
||||||
|
)}
|
||||||
<code className="text-[10px] text-slate-400 font-mono bg-slate-100 px-1.5 py-0.5 rounded">
|
<code className="text-[10px] text-slate-400 font-mono bg-slate-100 px-1.5 py-0.5 rounded">
|
||||||
:{meta?.port || "—"}
|
:{meta?.port || "—"}
|
||||||
</code>
|
</code>
|
||||||
@@ -344,6 +347,68 @@ export function StatusPage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* NATS 集群 · JetStream Raft 副本健康 */}
|
||||||
|
{data.nats && (
|
||||||
|
<section className="rounded-2xl border border-gray-200 bg-white p-5">
|
||||||
|
<div className="border-b border-slate-100 pb-3.5 mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-indigo-500"></span>
|
||||||
|
NATS 消息骨干 · 集群
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] text-slate-400 mt-0.5">JetStream Raft 副本健康——计费/状态/评测等持久流不丢的保证</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-5 text-xs">
|
||||||
|
{[
|
||||||
|
{ k: "节点", v: String(data.nats.known_servers) },
|
||||||
|
{ k: "RTT", v: `${data.nats.rtt_ms}ms` },
|
||||||
|
{ k: "连接", v: data.nats.connected_to || "—" },
|
||||||
|
].map((m) => (
|
||||||
|
<div key={m.k} className="text-right">
|
||||||
|
<div className="text-[9px] uppercase text-slate-400 tracking-wider font-semibold">{m.k}</div>
|
||||||
|
<div className="font-bold text-slate-700 font-mono truncate max-w-[140px]" title={m.v}>{m.v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-[10px] uppercase tracking-wide text-slate-400">
|
||||||
|
<th className="pb-2 font-medium">持久流</th>
|
||||||
|
<th className="pb-2 font-medium">Leader</th>
|
||||||
|
<th className="pb-2 font-medium text-right">副本健康</th>
|
||||||
|
<th className="pb-2 font-medium text-right">消息数</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(data.nats.streams ?? []).map((s) => {
|
||||||
|
const ok = s.replicas_healthy >= s.replicas_total;
|
||||||
|
return (
|
||||||
|
<tr key={s.name} className="border-t border-slate-50">
|
||||||
|
<td className="py-2 font-mono text-slate-700">{s.name}</td>
|
||||||
|
<td className="py-2 font-mono text-[10px] text-slate-500 truncate max-w-[160px]" title={s.leader}>{s.leader || "—"}</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<span className={`font-bold font-mono ${ok ? "text-emerald-600" : "text-amber-600"}`}>
|
||||||
|
{s.replicas_healthy}/{s.replicas_total}
|
||||||
|
</span>
|
||||||
|
{!ok && <span className="ml-1 text-[9px] text-amber-600">降级</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right font-mono text-slate-500 tabular-nums">{s.messages.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(data.nats.streams ?? []).length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="py-4 text-center text-[10px] text-slate-400">暂无流信息</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* MCP 工具注册箱 */}
|
{/* MCP 工具注册箱 */}
|
||||||
<section className="rounded-2xl border border-gray-200 bg-white p-6 space-y-4">
|
<section className="rounded-2xl border border-gray-200 bg-white p-6 space-y-4">
|
||||||
<div className="border-b border-slate-100 pb-3 flex flex-wrap items-center justify-between gap-3">
|
<div className="border-b border-slate-100 pb-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getVoiceConfig, saveVoiceConfig } from "../api";
|
||||||
|
|
||||||
|
// 运维 · 语音设置:火山引擎豆包语音(流式 ASR 耳朵 + 双向流式 TTS 嘴)。
|
||||||
|
// AccessToken 明文回显(单管理员后台,方便核对;密文仍加密入库)。设计见 VOICE_DESIGN.md。
|
||||||
|
export function VoiceConfigPage() {
|
||||||
|
const [apiKey, setApiKey] = 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) => {
|
||||||
|
setApiKey(c.api_key);
|
||||||
|
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({
|
||||||
|
api_key: apiKey,
|
||||||
|
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 ? (
|
||||||
|
<span className="rounded bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-600">{label} 已就绪</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500">{label} 未配齐</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="border-b border-gray-200 pb-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800">语音设置 · 火山豆包</h3>
|
||||||
|
<p className="text-xs text-gray-400">流式语音识别(耳朵) + 双向流式语音合成(嘴)。AccessToken 加密入库、后台明文可见</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700">火山引擎配置</h4>
|
||||||
|
{badge(asrOn, "ASR")}
|
||||||
|
{badge(ttsOn, "TTS")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<label className="text-xs text-gray-500 md:col-span-2">
|
||||||
|
API Key(新版鉴权,Authorization: Bearer <APIKey>)
|
||||||
|
<input value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="火山控制台生成的 API Key" className={field} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-500">
|
||||||
|
ASR Resource-Id
|
||||||
|
<input value={asrRes} onChange={(e) => setAsrRes(e.target.value)} placeholder="流式语音识别的 X-Api-Resource-Id" className={field} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-500">
|
||||||
|
TTS Resource-Id
|
||||||
|
<input value={ttsRes} onChange={(e) => setTtsRes(e.target.value)} placeholder="双向流式 TTS 的 X-Api-Resource-Id" className={field} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-500 md:col-span-2">
|
||||||
|
音色 voice_type
|
||||||
|
<input value={voice} onChange={(e) => setVoice(e.target.value)} placeholder="从标准音色里挑一个(如 zh_male_…)" className={field} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
|
||||||
|
{ok && <p className="mt-2 text-xs text-emerald-600">已保存</p>}
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<button onClick={() => void save()} disabled={busy} className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
|
||||||
|
{busy ? "保存中…" : "保存"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-amber-100 bg-amber-50/40 p-5 text-xs leading-relaxed text-gray-600">
|
||||||
|
<h4 className="mb-2 text-sm font-semibold text-gray-700">去哪拿这些参数(新版 API Key 鉴权)</h4>
|
||||||
|
<ol className="list-decimal space-y-1.5 pl-4">
|
||||||
|
<li>火山控制台生成 <b>API Key</b>(新版鉴权,一个 Key 走 Authorization 头,不再用旧版 appid+token)</li>
|
||||||
|
<li><b>ASR Resource-Id</b>:在「流式语音识别 2.0」文档的鉴权小节(X-Api-Resource-Id 那一栏)</li>
|
||||||
|
<li><b>TTS Resource-Id</b>:在「双向流式语音合成」文档的鉴权小节</li>
|
||||||
|
<li><b>音色</b>:控制台「音色管理」里挑一个(JARVIS 建议沉稳男声),填其 voice_type</li>
|
||||||
|
<li>端点/音频格式(PCM 16k 单声道)由后端固定,不用填</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { SubscriptionPage } from "./pages/SubscriptionPage";
|
import { SubscriptionPage } from "./pages/SubscriptionPage";
|
||||||
import { LoginConfigPage } from "./pages/LoginConfigPage";
|
import { LoginConfigPage } from "./pages/LoginConfigPage";
|
||||||
|
import { VoiceConfigPage } from "./pages/VoiceConfigPage";
|
||||||
import { WechatUsersPage } from "./pages/WechatUsersPage";
|
import { WechatUsersPage } from "./pages/WechatUsersPage";
|
||||||
import { lazy, type ReactNode } from "react";
|
import { lazy, type ReactNode } from "react";
|
||||||
|
|
||||||
@@ -86,6 +87,13 @@ export const routes: RouteDef[] = [
|
|||||||
ready: true,
|
ready: true,
|
||||||
element: <LoginConfigPage />,
|
element: <LoginConfigPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "voice-config",
|
||||||
|
label: "语音设置",
|
||||||
|
group: "运维",
|
||||||
|
ready: true,
|
||||||
|
element: <VoiceConfigPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "datasources",
|
path: "datasources",
|
||||||
label: "数据源 & RAG",
|
label: "数据源 & RAG",
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ const ICON_MAP: Record<string, React.ReactNode> = {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v10a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v10a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
usage: (
|
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 8h6m-5 0a3 3 0 110 6m0-6V7a1 1 0 112 0v1m-1 5a1.5 1.5 0 100-3m0 3v1m0-1a1.5 1.5 0 100-3m-3-3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
models: (
|
models: (
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
@@ -54,6 +49,21 @@ const ICON_MAP: Record<string, React.ReactNode> = {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
"login-config": (
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"voice-config": (
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"wechat-users": (
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
tenants: (
|
tenants: (
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
@@ -80,6 +90,11 @@ const ICON_MAP: Record<string, React.ReactNode> = {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 14l2 2 4-4M7 21h10a2 2 0 002-2V7l-4-4H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 14l2 2 4-4M7 21h10a2 2 0 002-2V7l-4-4H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
"payment/subscription": (
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 二级菜单(parent)自己的图标,按 parent 名索引。
|
// 二级菜单(parent)自己的图标,按 parent 名索引。
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>JARVIS 语音助手需要麦克风来听你说话。</string>
|
||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
<string>true</string>
|
<string>true</string>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
<string>0.1.1</string>
|
<string>0.1.1</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0.0</string>
|
<string>12.0.0</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>JARVIS 语音助手需要麦克风来听你说话。</string>
|
||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
<string>true</string>
|
<string>true</string>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
"build:dev": "vite build --mode development",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { TopBar } from "./shell/TopBar";
|
|||||||
import { LeftNav, type ViewKey } from "./shell/LeftNav";
|
import { LeftNav, type ViewKey } from "./shell/LeftNav";
|
||||||
import { ApprovalBar } from "./shell/ApprovalBar";
|
import { ApprovalBar } from "./shell/ApprovalBar";
|
||||||
import { SpaceMembers } from "./shell/SpaceMembers";
|
import { SpaceMembers } from "./shell/SpaceMembers";
|
||||||
|
import { InviteMembers } from "./shell/InviteMembers";
|
||||||
import { StudioView } from "./studio/StudioView";
|
import { StudioView } from "./studio/StudioView";
|
||||||
import { MemoryView } from "./views/MemoryView";
|
import { MemoryView } from "./views/MemoryView";
|
||||||
import { KbView } from "./views/KbView";
|
import { KbView } from "./views/KbView";
|
||||||
@@ -15,6 +16,7 @@ import { Home } from "./views/Home";
|
|||||||
import { CommandPalette, type Command } from "./components/CommandPalette";
|
import { CommandPalette, type Command } from "./components/CommandPalette";
|
||||||
import { UpdateBanner } from "./components/UpdateBanner";
|
import { UpdateBanner } from "./components/UpdateBanner";
|
||||||
import { Login } from "./views/Login";
|
import { Login } from "./views/Login";
|
||||||
|
import { VoiceDock } from "./shell/VoiceDock";
|
||||||
import { submitTask, generateReport, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, spaceCurrent, mySpaces, switchSpace, createSpace, type Identity, type AuthUser, type TenantCtx, type MyTenant, type SpaceCtx, type MySpace } from "./lib/api";
|
import { submitTask, generateReport, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, spaceCurrent, mySpaces, switchSpace, createSpace, type Identity, type AuthUser, type TenantCtx, type MyTenant, type SpaceCtx, type MySpace } from "./lib/api";
|
||||||
import type { TaskDsl } from "./lib/dsl";
|
import type { TaskDsl } from "./lib/dsl";
|
||||||
import { emptyRun, type RunState } from "./lib/run";
|
import { emptyRun, type RunState } from "./lib/run";
|
||||||
@@ -46,6 +48,7 @@ export default function App() {
|
|||||||
const [run, setRun] = useState<RunState>(emptyRun);
|
const [run, setRun] = useState<RunState>(emptyRun);
|
||||||
const [cmdOpen, setCmdOpen] = useState(false);
|
const [cmdOpen, setCmdOpen] = useState(false);
|
||||||
const [membersOpen, setMembersOpen] = useState(false);
|
const [membersOpen, setMembersOpen] = useState(false);
|
||||||
|
const [inviteOpen, setInviteOpen] = useState(false);
|
||||||
const closeRef = useRef<(() => void) | null>(null);
|
const closeRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
const execCloseRef = useRef<(() => void) | null>(null);
|
const execCloseRef = useRef<(() => void) | null>(null);
|
||||||
@@ -281,6 +284,22 @@ export default function App() {
|
|||||||
[identity, attachRun],
|
[identity, attachRun],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 语音任务:后端已在 ASR 转写后自行提交(走 SubmitTask 同一关卡),前端只需挂回它的运行流。
|
||||||
|
// 与 onRun 的区别:不再 submit,直接 attach(task 已在后端跑)——跳「运行·观测」看轨迹/输出。
|
||||||
|
const onVoiceTask = useCallback(
|
||||||
|
(taskId: string) => {
|
||||||
|
closeRef.current?.();
|
||||||
|
execCloseRef.current?.();
|
||||||
|
stopPoll();
|
||||||
|
const t0 = Date.now();
|
||||||
|
setRun({ phase: "streaming", taskId, output: "", events: [{ t: 0, label: `语音任务 ${taskId}` }], exec: [] });
|
||||||
|
setFocusRun(null);
|
||||||
|
setView("runs");
|
||||||
|
attachRun(taskId, t0, "语音任务已跑完");
|
||||||
|
},
|
||||||
|
[attachRun],
|
||||||
|
);
|
||||||
|
|
||||||
// 恢复在途待审任务:登录后若存在 waiting 任务且当前无 live run,挂回它 → 全局审批条重现,
|
// 恢复在途待审任务:登录后若存在 waiting 任务且当前无 live run,挂回它 → 全局审批条重现,
|
||||||
// 用户刷新页面/重开 app 也能继续批准(HITL 持久化中断后审批可跨重启、可等数小时)。
|
// 用户刷新页面/重开 app 也能继续批准(HITL 持久化中断后审批可跨重启、可等数小时)。
|
||||||
const restoredRef = useRef(false);
|
const restoredRef = useRef(false);
|
||||||
@@ -326,7 +345,7 @@ export default function App() {
|
|||||||
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
||||||
/>
|
/>
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} onManageMembers={() => setMembersOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} onManageMembers={() => setMembersOpen(true)} onInviteMembers={() => setInviteOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
||||||
<ApprovalBar run={run} />
|
<ApprovalBar run={run} />
|
||||||
<div className="relative flex min-h-0 flex-1">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
<LeftNav active={view} onSelect={setView} />
|
<LeftNav active={view} onSelect={setView} />
|
||||||
@@ -357,6 +376,8 @@ export default function App() {
|
|||||||
canManage={space?.role === "owner" || space?.role === "admin"}
|
canManage={space?.role === "owner" || space?.role === "admin"}
|
||||||
selfUserId={user?.id ?? ""}
|
selfUserId={user?.id ?? ""}
|
||||||
/>
|
/>
|
||||||
|
<InviteMembers open={inviteOpen} onClose={() => setInviteOpen(false)} tenantName={tenant?.tenant?.name ?? ""} />
|
||||||
|
<VoiceDock onTask={onVoiceTask} />
|
||||||
</div>
|
</div>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -271,6 +271,47 @@ export const setSpaceMemberRole = (spaceId: string, userId: string, role: string
|
|||||||
export const removeSpaceMember = (spaceId: string, userId: string) =>
|
export const removeSpaceMember = (spaceId: string, userId: string) =>
|
||||||
spaceMemberWrite("DELETE", `/api/v1/spaces/${spaceId}/members/${userId}`);
|
spaceMemberWrite("DELETE", `/api/v1/spaces/${spaceId}/members/${userId}`);
|
||||||
|
|
||||||
|
// ---- 租户成员「二维码邀请」(可复用团队码;扫码关注即入组)----
|
||||||
|
export interface TenantInvite {
|
||||||
|
id: string;
|
||||||
|
role: string; // member/viewer/admin
|
||||||
|
qr_image: string; // 微信二维码图 URL
|
||||||
|
expires_at: string;
|
||||||
|
max_uses: number; // 0=不限
|
||||||
|
used_count: number;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInvites(): Promise<TenantInvite[]> {
|
||||||
|
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites`, { headers: bearer() }));
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(d.error ?? `invites failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
const d = (await res.json()) as { invites?: TenantInvite[] };
|
||||||
|
return d.invites ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInvite(role: string, expiresDays: number, maxUses: number): Promise<TenantInvite> {
|
||||||
|
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...bearer() },
|
||||||
|
body: JSON.stringify({ role, expires_days: expiresDays, max_uses: maxUses }),
|
||||||
|
}));
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { invite?: TenantInvite; error?: string };
|
||||||
|
if (!res.ok || !d.invite) throw new Error(d.error ?? `create invite failed: ${res.status}`);
|
||||||
|
return d.invite;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeInvite(id: string): Promise<void> {
|
||||||
|
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites/${id}`, { method: "DELETE", headers: bearer() }));
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(d.error ?? `revoke failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)----
|
// ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)----
|
||||||
export interface UsageDay {
|
export interface UsageDay {
|
||||||
day: string; // YYYYMMDD
|
day: string; // YYYYMMDD
|
||||||
@@ -334,15 +375,23 @@ export async function approveTask(taskId: string, approved: boolean, opts?: { no
|
|||||||
if (!res.ok) throw new Error(`approve failed: ${res.status} ${await res.text()}`);
|
if (!res.ok) throw new Error(`approve failed: ${res.status} ${await res.text()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tokenQuery 给 EventSource / 下载链接这类**带不了 Authorization 头**的 URL 附上 JWT,
|
||||||
|
// 后端 AuthFromHeaderOrQuery 从 ?token= 取它做鉴权 + 归属校验(公开 by-id 端点不再裸奔)。
|
||||||
|
function tokenQuery(url: string): string {
|
||||||
|
const t = getToken();
|
||||||
|
if (!t) return url;
|
||||||
|
return url + (url.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(t);
|
||||||
|
}
|
||||||
|
|
||||||
// streamTokens: 订阅 SSE /api/v1/tasks/:id/stream,逐 token 回调,done 收尾。
|
// streamTokens: 订阅 SSE /api/v1/tasks/:id/stream,逐 token 回调,done 收尾。
|
||||||
// 返回关闭函数。注意 EventSource 无法带请求头,但流按 task_id 寻址,无需身份头。
|
// 返回关闭函数。EventSource 无法带请求头,故 JWT 走 ?token=(tokenQuery)。
|
||||||
export function streamTokens(
|
export function streamTokens(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
onToken: (t: string) => void,
|
onToken: (t: string) => void,
|
||||||
onDone: () => void,
|
onDone: () => void,
|
||||||
onError?: (e: unknown) => void,
|
onError?: (e: unknown) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
const es = new EventSource(`${GATEWAY}/api/v1/tasks/${taskId}/stream`);
|
const es = new EventSource(tokenQuery(`${GATEWAY}/api/v1/tasks/${taskId}/stream`));
|
||||||
es.addEventListener("token", (e) => onToken((e as MessageEvent).data));
|
es.addEventListener("token", (e) => onToken((e as MessageEvent).data));
|
||||||
es.addEventListener("done", () => {
|
es.addEventListener("done", () => {
|
||||||
es.close();
|
es.close();
|
||||||
@@ -374,7 +423,7 @@ export function streamExec(
|
|||||||
onDone: () => void,
|
onDone: () => void,
|
||||||
onError?: (e: unknown) => void,
|
onError?: (e: unknown) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
const es = new EventSource(`${GATEWAY}/api/v1/tasks/${taskId}/exec`);
|
const es = new EventSource(tokenQuery(`${GATEWAY}/api/v1/tasks/${taskId}/exec`));
|
||||||
es.addEventListener("exec", (e) => onEvent(JSON.parse((e as MessageEvent).data) as ExecEvent));
|
es.addEventListener("exec", (e) => onEvent(JSON.parse((e as MessageEvent).data) as ExecEvent));
|
||||||
es.addEventListener("done", () => {
|
es.addEventListener("done", () => {
|
||||||
es.close();
|
es.close();
|
||||||
@@ -553,7 +602,7 @@ export function streamIngest(
|
|||||||
onDone: () => void,
|
onDone: () => void,
|
||||||
onError?: () => void,
|
onError?: () => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
const es = new EventSource(`${GATEWAY}/api/v1/kb/ingest/${jobId}/stream`);
|
const es = new EventSource(tokenQuery(`${GATEWAY}/api/v1/kb/ingest/${jobId}/stream`));
|
||||||
es.addEventListener("progress", (e) => onEvent(JSON.parse((e as MessageEvent).data) as IngestEvent));
|
es.addEventListener("progress", (e) => onEvent(JSON.parse((e as MessageEvent).data) as IngestEvent));
|
||||||
es.addEventListener("done", () => {
|
es.addEventListener("done", () => {
|
||||||
es.close();
|
es.close();
|
||||||
@@ -614,7 +663,7 @@ export async function generateReport(id: Identity, topic: string, kb?: string):
|
|||||||
|
|
||||||
// reportDownloadUrl: 渲染好的 Word(.docx) 下载地址(兼容旧入口)。
|
// reportDownloadUrl: 渲染好的 Word(.docx) 下载地址(兼容旧入口)。
|
||||||
export function reportDownloadUrl(taskId: string): string {
|
export function reportDownloadUrl(taskId: string): string {
|
||||||
return `${GATEWAY}/api/v1/reports/${taskId}/download`;
|
return tokenQuery(`${GATEWAY}/api/v1/reports/${taskId}/download`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// reportExportUrl: 按需导出报告地址(format=docx|md;后端现渲染)。PDF 由前端打印预览生成。
|
// reportExportUrl: 按需导出报告地址(format=docx|md;后端现渲染)。PDF 由前端打印预览生成。
|
||||||
@@ -625,7 +674,7 @@ export function reportFilename(topic: string, id: string, ext: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function reportExportUrl(taskId: string, format: "docx" | "md"): string {
|
export function reportExportUrl(taskId: string, format: "docx" | "md"): string {
|
||||||
return `${GATEWAY}/api/v1/reports/${taskId}/export?format=${format}`;
|
return tokenQuery(`${GATEWAY}/api/v1/reports/${taskId}/export?format=${format}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// setMemory: PUT /api/v1/memory,登记一条用户偏好(→ mcp-go memory_upsert)。
|
// setMemory: PUT /api/v1/memory,登记一条用户偏好(→ mcp-go memory_upsert)。
|
||||||
@@ -740,3 +789,38 @@ export async function runReplay(taskId: string): Promise<{ output: string; exec:
|
|||||||
const d = (await res.json()) as { output?: string; exec?: ExecEvent[] };
|
const d = (await res.json()) as { output?: string; exec?: ExecEvent[] };
|
||||||
return { output: d.output ?? "", exec: d.exec ?? [] };
|
return { output: d.output ?? "", exec: d.exec ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 每用户 JARVIS 设置(名字 / 人设 / 自带豆包配置)----
|
||||||
|
export interface JarvisConfig {
|
||||||
|
name: string;
|
||||||
|
persona: string;
|
||||||
|
asr_resource_id: string;
|
||||||
|
tts_resource_id: string;
|
||||||
|
tts_voice_type: string;
|
||||||
|
api_key: string; // 读取时为脱敏值;保存留空/掩码=沿用已存
|
||||||
|
has_own_voice: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMyJarvis(): Promise<JarvisConfig> {
|
||||||
|
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/jarvis`, { headers: bearer() }));
|
||||||
|
if (!res.ok) throw new Error("读取 JARVIS 设置失败");
|
||||||
|
return res.json() as Promise<JarvisConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveMyJarvis(cfg: {
|
||||||
|
name: string;
|
||||||
|
persona: string;
|
||||||
|
api_key?: string;
|
||||||
|
asr_resource_id?: string;
|
||||||
|
tts_resource_id?: string;
|
||||||
|
tts_voice_type?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const res = guard401(
|
||||||
|
await fetch(`${GATEWAY}/api/v1/me/jarvis`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", ...bearer() },
|
||||||
|
body: JSON.stringify(cfg),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error || "保存失败");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
// 语音会话客户端(JARVIS):一条 WebSocket 承载上行麦克风音频 + 下行转写 + 下行 TTS 音频。
|
||||||
|
// 协议与网关 internal/voice/protocol.go 对齐:
|
||||||
|
// - 二进制帧:上行=麦克风 PCM 16k;下行=TTS PCM 24k
|
||||||
|
// - 文本帧(JSON):控制/事件(ClientMsg / ServerMsg)
|
||||||
|
//
|
||||||
|
// 麦克风采集用 ScriptProcessorNode(WKWebView 通吃,无需单独 worklet 文件),48k→16k 降采样、
|
||||||
|
// Float32→Int16。下行播放用 AudioBufferSourceNode 排队调度,做无缝连续朗读。
|
||||||
|
|
||||||
|
import { GATEWAY, getToken } from "./api";
|
||||||
|
|
||||||
|
export type VoiceState = "idle" | "connecting" | "ready" | "listening" | "thinking" | "speaking";
|
||||||
|
|
||||||
|
export interface VoiceCallbacks {
|
||||||
|
onState?: (s: VoiceState) => void;
|
||||||
|
onTranscript?: (text: string, final: boolean) => void;
|
||||||
|
onTask?: (taskId: string) => void;
|
||||||
|
onReply?: (deltaText: string) => void; // Agent 回答增量文本(打字机,逐 token)
|
||||||
|
onError?: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UP_SAMPLE_RATE = 16000; // 上行 ASR 采样率(与网关 voice.AudioSampleRate 一致)
|
||||||
|
const DOWN_SAMPLE_RATE = 24000; // 下行 TTS 采样率(与网关 voice.TTSSampleRate 一致)
|
||||||
|
|
||||||
|
function wsURL(): string {
|
||||||
|
const base = GATEWAY.replace(/^http/, "ws");
|
||||||
|
const t = getToken();
|
||||||
|
const q = t ? `?token=${encodeURIComponent(t)}` : "";
|
||||||
|
return `${base}/api/v1/voice/stream${q}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// float32 → int16 PCM(小端)。
|
||||||
|
function floatToPCM16(f32: Float32Array): ArrayBuffer {
|
||||||
|
const out = new DataView(new ArrayBuffer(f32.length * 2));
|
||||||
|
for (let i = 0; i < f32.length; i++) {
|
||||||
|
let s = Math.max(-1, Math.min(1, f32[i]));
|
||||||
|
out.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||||
|
}
|
||||||
|
return out.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 线性降采样到 16k(浏览器 AudioContext 常跑 48k,火山 ASR 要 16k)。
|
||||||
|
function downsampleTo16k(input: Float32Array, inRate: number): Float32Array {
|
||||||
|
if (inRate === UP_SAMPLE_RATE) return input;
|
||||||
|
const ratio = inRate / UP_SAMPLE_RATE;
|
||||||
|
const outLen = Math.floor(input.length / ratio);
|
||||||
|
const out = new Float32Array(outLen);
|
||||||
|
for (let i = 0; i < outLen; i++) out[i] = input[Math.floor(i * ratio)];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VoiceClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private cb: VoiceCallbacks;
|
||||||
|
private state: VoiceState = "idle";
|
||||||
|
|
||||||
|
// 上行采集
|
||||||
|
private micCtx: AudioContext | null = null;
|
||||||
|
private micStream: MediaStream | null = null;
|
||||||
|
private micNode: ScriptProcessorNode | null = null;
|
||||||
|
private micAnalyser: AnalyserNode | null = null;
|
||||||
|
|
||||||
|
// 下行播放
|
||||||
|
private playCtx: AudioContext | null = null;
|
||||||
|
private nextStart = 0; // 下一段音频的调度起点(连续朗读用)
|
||||||
|
private sources: AudioBufferSourceNode[] = [];
|
||||||
|
private playAnalyser: AnalyserNode | null = null;
|
||||||
|
private lvlBuf = new Uint8Array(512); // 复用的时域采样缓冲(level() 每帧读,别每帧新建)
|
||||||
|
|
||||||
|
constructor(cb: VoiceCallbacks) {
|
||||||
|
this.cb = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(s: VoiceState) {
|
||||||
|
this.state = s;
|
||||||
|
this.cb.onState?.(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): VoiceState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// connect 建立 WS,等待服务端 ready。
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
||||||
|
this.setState("connecting");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(wsURL());
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
this.ws = ws;
|
||||||
|
ws.onmessage = (e) => this.onMessage(e);
|
||||||
|
ws.onerror = () => {
|
||||||
|
this.cb.onError?.("语音连接出错");
|
||||||
|
reject(new Error("ws error"));
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
this.stopMic();
|
||||||
|
if (this.state !== "idle") this.setState("idle");
|
||||||
|
};
|
||||||
|
ws.onopen = () => resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMessage(e: MessageEvent) {
|
||||||
|
if (typeof e.data === "string") {
|
||||||
|
let m: { type: string; text?: string; final?: boolean; task_id?: string; msg?: string };
|
||||||
|
try {
|
||||||
|
m = JSON.parse(e.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (m.type) {
|
||||||
|
case "ready":
|
||||||
|
this.setState("ready");
|
||||||
|
break;
|
||||||
|
case "transcript":
|
||||||
|
this.cb.onTranscript?.(m.text ?? "", !!m.final);
|
||||||
|
if (m.final) this.setState("thinking");
|
||||||
|
break;
|
||||||
|
case "task":
|
||||||
|
if (m.task_id) this.cb.onTask?.(m.task_id);
|
||||||
|
break;
|
||||||
|
case "reply":
|
||||||
|
if (m.text) this.cb.onReply?.(m.text); // 打字机:回答增量文本
|
||||||
|
break;
|
||||||
|
case "speaking":
|
||||||
|
this.setState("speaking");
|
||||||
|
break;
|
||||||
|
case "tts_end":
|
||||||
|
this.resetPlayback();
|
||||||
|
this.setState("ready");
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
this.cb.onError?.(m.msg ?? "语音出错");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 二进制帧 = 下行 TTS 音频(PCM 24k int16)
|
||||||
|
this.enqueueAudio(e.data as ArrayBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 上行:麦克风 ----
|
||||||
|
|
||||||
|
// startListening 开一轮:连接(若需)→ 若正在朗读先打断 → 采麦克风 → 发 start。
|
||||||
|
async startListening(graph?: string): Promise<void> {
|
||||||
|
await this.connect();
|
||||||
|
// 关键:在**用户手势**(点麦克风)里就把播放 AudioContext 建好并 resume——否则它在 WS 回调里
|
||||||
|
// 惰性创建会处于 suspended 态,TTS 音频静默播不出(Chrome/WKWebView 的自动播放策略)。
|
||||||
|
this.ensurePlayCtx();
|
||||||
|
if (this.state === "speaking") this.bargeIn();
|
||||||
|
this.send({ type: "start", graph });
|
||||||
|
await this.startMic();
|
||||||
|
this.setState("listening");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensurePlayCtx 建/复用下行播放 AudioContext,并在 suspended 时 resume(自动播放策略要求手势内唤醒)。
|
||||||
|
private ensurePlayCtx(): AudioContext {
|
||||||
|
if (!this.playCtx) this.playCtx = new AudioContext();
|
||||||
|
if (!this.playAnalyser) {
|
||||||
|
// 下行电平探针:所有 TTS 音频经它再到扬声器(驱动 HUD 的"说话"频谱)。
|
||||||
|
const an = this.playCtx.createAnalyser();
|
||||||
|
an.fftSize = 512;
|
||||||
|
an.connect(this.playCtx.destination);
|
||||||
|
this.playAnalyser = an;
|
||||||
|
}
|
||||||
|
if (this.playCtx.state === "suspended") void this.playCtx.resume();
|
||||||
|
return this.playCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// level 返回当前"活跃"音频电平 0..1:说话读下行 TTS,其余读上行麦克风;无探针则 0。
|
||||||
|
// 供 JARVIS HUD 每帧读取,让可视化随真实声音起伏。
|
||||||
|
level(): number {
|
||||||
|
const a = this.state === "speaking" ? this.playAnalyser : this.micAnalyser;
|
||||||
|
if (!a) return 0;
|
||||||
|
a.getByteTimeDomainData(this.lvlBuf);
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < this.lvlBuf.length; i++) {
|
||||||
|
const v = (this.lvlBuf[i] - 128) / 128;
|
||||||
|
s += v * v;
|
||||||
|
}
|
||||||
|
return Math.min(1, Math.sqrt(s / this.lvlBuf.length) * 3.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopListening 结束本轮说话:停麦克风 + 发 end(服务端拿最终转写→提交任务)。
|
||||||
|
stopListening(): void {
|
||||||
|
this.stopMic();
|
||||||
|
this.send({ type: "end" });
|
||||||
|
if (this.state === "listening") this.setState("thinking");
|
||||||
|
}
|
||||||
|
|
||||||
|
// bargeIn 打断当前朗读(用户又开口)。
|
||||||
|
bargeIn(): void {
|
||||||
|
this.send({ type: "barge_in" });
|
||||||
|
this.resetPlayback();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startMic(): Promise<void> {
|
||||||
|
if (this.micNode) return;
|
||||||
|
// WKWebView(Wails 原生壳)等非安全上下文里 navigator.mediaDevices 可能未暴露——
|
||||||
|
// 直接调用会抛 "undefined is not an object" 崩掉。先探测,给出可读提示而非崩溃。
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
throw new Error("此窗口暂不支持麦克风(原生壳需授权/安全上下文)。用浏览器打开 localhost:5173 可直接说话。");
|
||||||
|
}
|
||||||
|
// 先看有没有麦克风设备:Mac Studio/Mini 等无内置麦克风的机器上,getUserMedia 会以
|
||||||
|
// OverconstrainedError "Invalid constraint" 报错(误导),提前给准确提示。
|
||||||
|
let devs: MediaDeviceInfo[] = [];
|
||||||
|
try {
|
||||||
|
devs = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
} catch {
|
||||||
|
/* 拿不到设备列表就跳过预检,直接试 getUserMedia */
|
||||||
|
}
|
||||||
|
if (devs.length > 0 && !devs.some((d) => d.kind === "audioinput")) {
|
||||||
|
throw new Error("没检测到麦克风设备。这台机器(如 Mac Studio/Mini)可能无内置麦克风——插个麦克风(带麦耳机 / USB 麦 / AirPods)再试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 依次尝试多种约束形式:WKWebView(原生壳)对约束挑剔,不同实现接受的形式不同。
|
||||||
|
// 全失败则把**真实错误名**抛出来(区分是"约束不认"Overconstrained 还是"权限被拒"NotAllowed)。
|
||||||
|
const tries: MediaStreamConstraints[] = [
|
||||||
|
{ audio: true },
|
||||||
|
{ audio: {} },
|
||||||
|
{ audio: { echoCancellation: true, noiseSuppression: true } },
|
||||||
|
];
|
||||||
|
let stream: MediaStream | null = null;
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (const c of tries) {
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia(c);
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!stream) {
|
||||||
|
const err = lastErr as { name?: string; message?: string };
|
||||||
|
const detail = `${err?.name ?? "Error"}: ${err?.message ?? String(lastErr)}`;
|
||||||
|
if (err?.name === "NotAllowedError" || err?.name === "SecurityError") {
|
||||||
|
throw new Error("麦克风权限被拒。请到 系统设置 → 隐私与安全性 → 麦克风 里允许本应用。");
|
||||||
|
}
|
||||||
|
throw new Error("麦克风打开失败:" + detail);
|
||||||
|
}
|
||||||
|
this.micStream = stream;
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
this.micCtx = ctx;
|
||||||
|
if (ctx.state === "suspended") await ctx.resume(); // 防采集上下文挂起(无回调=不上行音频)
|
||||||
|
const src = ctx.createMediaStreamSource(stream);
|
||||||
|
const an = ctx.createAnalyser(); // 上行电平探针(驱动 JARVIS HUD 的"聆听"反应)
|
||||||
|
an.fftSize = 512;
|
||||||
|
src.connect(an);
|
||||||
|
this.micAnalyser = an;
|
||||||
|
const node = ctx.createScriptProcessor(4096, 1, 1);
|
||||||
|
node.onaudioprocess = (ev) => {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const f32 = ev.inputBuffer.getChannelData(0);
|
||||||
|
const ds = downsampleTo16k(f32, ctx.sampleRate);
|
||||||
|
this.ws.send(floatToPCM16(ds));
|
||||||
|
};
|
||||||
|
src.connect(node);
|
||||||
|
node.connect(ctx.destination); // ScriptProcessor 需接上目的地才触发回调(下游静音)
|
||||||
|
this.micNode = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopMic(): void {
|
||||||
|
this.micNode?.disconnect();
|
||||||
|
this.micNode = null;
|
||||||
|
this.micAnalyser = null;
|
||||||
|
this.micStream?.getTracks().forEach((t) => t.stop());
|
||||||
|
this.micStream = null;
|
||||||
|
this.micCtx?.close().catch(() => {});
|
||||||
|
this.micCtx = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 下行:TTS 音频播放(排队调度,无缝连续)----
|
||||||
|
|
||||||
|
private enqueueAudio(buf: ArrayBuffer): void {
|
||||||
|
if (buf.byteLength === 0) return;
|
||||||
|
const ctx = this.ensurePlayCtx(); // 建/复用并 resume(防 WS 回调里上下文仍 suspended → 静默)
|
||||||
|
|
||||||
|
const i16 = new Int16Array(buf);
|
||||||
|
const f32 = new Float32Array(i16.length);
|
||||||
|
for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 0x8000;
|
||||||
|
const audioBuf = ctx.createBuffer(1, f32.length, DOWN_SAMPLE_RATE);
|
||||||
|
audioBuf.getChannelData(0).set(f32);
|
||||||
|
|
||||||
|
const node = ctx.createBufferSource();
|
||||||
|
node.buffer = audioBuf;
|
||||||
|
node.connect(this.playAnalyser ?? ctx.destination); // 经探针再到扬声器(读得到电平)
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
if (this.nextStart < now) this.nextStart = now;
|
||||||
|
node.start(this.nextStart);
|
||||||
|
this.nextStart += audioBuf.duration;
|
||||||
|
node.onended = () => {
|
||||||
|
this.sources = this.sources.filter((s) => s !== node);
|
||||||
|
};
|
||||||
|
this.sources.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetPlayback(): void {
|
||||||
|
this.sources.forEach((s) => {
|
||||||
|
try {
|
||||||
|
s.stop();
|
||||||
|
} catch {
|
||||||
|
/* 已停忽略 */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.sources = [];
|
||||||
|
this.nextStart = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(m: { type: string; graph?: string }): void {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
// close 彻底关闭会话(发 bye、停采集/播放、断 WS)。
|
||||||
|
close(): void {
|
||||||
|
this.send({ type: "bye" });
|
||||||
|
this.stopMic();
|
||||||
|
this.resetPlayback();
|
||||||
|
this.playCtx?.close().catch(() => {});
|
||||||
|
this.playCtx = null;
|
||||||
|
this.playAnalyser = null;
|
||||||
|
this.ws?.close();
|
||||||
|
this.ws = null;
|
||||||
|
this.setState("idle");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { QrCode, Trash2, Loader2 } from "lucide-react";
|
||||||
|
import { Dialog, Button, Select, Badge, useToast } from "../ui";
|
||||||
|
import { listInvites, createInvite, revokeInvite, type TenantInvite } from "../lib/api";
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<string, string> = { admin: "管理员", member: "成员", viewer: "只读" };
|
||||||
|
|
||||||
|
// InviteMembers 租户成员「二维码邀请」弹窗:owner/admin 生成可复用团队码,
|
||||||
|
// 成员用微信扫码关注即自动入组(后端 WxMPEvent inv_ 分支)。三道闸:有效期 + 人数 + 撤销。
|
||||||
|
// 作用于当前活跃租户(后端取 ctx 租户,前端不传 id)。
|
||||||
|
export function InviteMembers({ open, onClose, tenantName }: { open: boolean; onClose: () => void; tenantName: string }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [rows, setRows] = useState<TenantInvite[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [role, setRole] = useState("member");
|
||||||
|
const [days, setDays] = useState(7);
|
||||||
|
const [maxUses, setMaxUses] = useState(0);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [fresh, setFresh] = useState<TenantInvite | null>(null); // 刚生成的,置顶大图展示
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setRows(await listInvites());
|
||||||
|
} catch (e) {
|
||||||
|
toast.push("error", (e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFresh(null);
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
}, [open, refresh]);
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const inv = await createInvite(role, days, maxUses);
|
||||||
|
setFresh(inv);
|
||||||
|
toast.push("success", "已生成邀请二维码");
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast.push("error", (e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async (inv: TenantInvite) => {
|
||||||
|
if (!window.confirm("撤销这张邀请码?已加入的成员不受影响,但此码将无法再扫码加入。")) return;
|
||||||
|
try {
|
||||||
|
await revokeInvite(inv.id);
|
||||||
|
if (fresh?.id === inv.id) setFresh(null);
|
||||||
|
toast.push("success", "已撤销");
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast.push("error", (e as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const usesLabel = (inv: TenantInvite) => (inv.max_uses > 0 ? `${inv.used_count}/${inv.max_uses} 人` : `${inv.used_count} 人(不限)`);
|
||||||
|
const expLabel = (iso: string) => new Date(iso).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }) + " 到期";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} title={`邀请成员 · ${tenantName}`}>
|
||||||
|
{/* 生成表单 */}
|
||||||
|
<div className="mb-3 flex items-end gap-2">
|
||||||
|
<label className="text-[11px] text-slate-500">
|
||||||
|
角色
|
||||||
|
<Select className="mt-1 h-8 w-20 text-xs" value={role} onChange={(e) => setRole(e.target.value)}>
|
||||||
|
<option value="member">成员</option>
|
||||||
|
<option value="viewer">只读</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label className="text-[11px] text-slate-500">
|
||||||
|
有效期
|
||||||
|
<Select className="mt-1 h-8 w-20 text-xs" value={days} onChange={(e) => setDays(Number(e.target.value))}>
|
||||||
|
<option value={1}>1 天</option>
|
||||||
|
<option value={7}>7 天</option>
|
||||||
|
<option value={30}>30 天</option>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label className="text-[11px] text-slate-500">
|
||||||
|
人数上限
|
||||||
|
<Select className="mt-1 h-8 w-24 text-xs" value={maxUses} onChange={(e) => setMaxUses(Number(e.target.value))}>
|
||||||
|
<option value={0}>不限</option>
|
||||||
|
<option value={1}>1 人</option>
|
||||||
|
<option value={5}>5 人</option>
|
||||||
|
<option value={20}>20 人</option>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<Button variant="primary" size="sm" icon={busy ? Loader2 : QrCode} onClick={generate} disabled={busy}>
|
||||||
|
生成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 刚生成的码:大图展示,便于当场扫 */}
|
||||||
|
{fresh && (
|
||||||
|
<div className="mb-3 flex flex-col items-center rounded-lg border border-brand/40 bg-ink-800/60 p-3">
|
||||||
|
{fresh.qr_image ? (
|
||||||
|
<img src={fresh.qr_image} alt="邀请二维码" className="h-44 w-44 rounded bg-white p-1" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-44 w-44 items-center justify-center text-xs text-slate-500">二维码加载中…</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-center text-[11px] text-slate-400">
|
||||||
|
让对方用<b className="text-slate-200">微信扫一扫</b>关注公众号即自动加入
|
||||||
|
<br />
|
||||||
|
身份:{ROLE_LABEL[fresh.role] ?? fresh.role} · {usesLabel(fresh)} · {expLabel(fresh.expires_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 有效邀请码列表 */}
|
||||||
|
<div className="mb-1 text-[11px] text-slate-500">有效邀请码</div>
|
||||||
|
<ul className="max-h-56 space-y-1 overflow-auto">
|
||||||
|
{loading && <li className="px-1 py-2 text-[11px] text-slate-500">加载中…</li>}
|
||||||
|
{!loading && rows.length === 0 && <li className="px-1 py-2 text-[11px] text-slate-600">还没有邀请码,生成一张发给团队。</li>}
|
||||||
|
{rows.map((inv) => (
|
||||||
|
<li key={inv.id} className="flex items-center gap-2 rounded-md border border-line bg-ink-800/60 px-2.5 py-2">
|
||||||
|
<button onClick={() => setFresh(inv)} title="查看二维码" className="shrink-0 text-slate-500 transition hover:text-brand-400">
|
||||||
|
<QrCode className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-200">
|
||||||
|
<Badge tone={inv.role === "admin" ? "accent" : inv.role === "viewer" ? "warn" : "neutral"}>{ROLE_LABEL[inv.role] ?? inv.role}</Badge>
|
||||||
|
<span className="text-[11px] text-slate-400">{usesLabel(inv)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[10px] text-slate-500">{expLabel(inv.expires_at)}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => revoke(inv)} className="text-slate-600 transition hover:text-danger" title="撤销">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import type { VoiceState } from "../lib/voice";
|
||||||
|
|
||||||
|
// 全屏「JARVIS 模式」HUD:弧反应堆核心 + 随真实声音反应的环形频谱 + 遥测 + 解码文字 + 开机自检。
|
||||||
|
// 视觉数据全来自真实语音会话:level 由 VoiceClient 的麦克风/TTS 探针实时给出(getLevel),
|
||||||
|
// state 是会话状态机;transcript/reply 是当轮问答。点中心说话,右上角/ESC 退出。
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
state: VoiceState;
|
||||||
|
getLevel: () => number;
|
||||||
|
transcript: string;
|
||||||
|
reply: string;
|
||||||
|
hint: string;
|
||||||
|
onMic: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Conf = { accent: number[]; activity: number; ringSpin: number; label: string };
|
||||||
|
const CY = [56, 225, 255];
|
||||||
|
const AM = [255, 182, 56];
|
||||||
|
const CONF: Record<VoiceState, Conf> = {
|
||||||
|
idle: { accent: CY, activity: 0.18, ringSpin: 0.1, label: "STANDBY" },
|
||||||
|
connecting: { accent: CY, activity: 0.22, ringSpin: 0.5, label: "LINKING" },
|
||||||
|
ready: { accent: CY, activity: 0.18, ringSpin: 0.1, label: "READY" },
|
||||||
|
listening: { accent: CY, activity: 0.6, ringSpin: 0.35, label: "LISTENING" },
|
||||||
|
thinking: { accent: AM, activity: 0.3, ringSpin: 0.8, label: "PROCESSING" },
|
||||||
|
speaking: { accent: CY, activity: 0.85, ringSpin: 0.22, label: "SPEAKING" },
|
||||||
|
};
|
||||||
|
const GLYPH = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%@*<>/\\";
|
||||||
|
|
||||||
|
export function JarvisHud({ name, state, getLevel, transcript, reply, hint, onMic, onClose }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const decRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const sigRef = useRef<HTMLElement>(null);
|
||||||
|
const latRef = useRef<HTMLElement>(null);
|
||||||
|
const gainRef = useRef<HTMLElement>(null);
|
||||||
|
const [booting, setBooting] = useState(true);
|
||||||
|
// 用 ref 让 RAF 闭包读到最新 props,无需重启动画。
|
||||||
|
const P = useRef({ state, getLevel, transcript, reply });
|
||||||
|
P.current = { state, getLevel, transcript, reply };
|
||||||
|
|
||||||
|
// 开机自检:进场放一段 INITIALIZING…ONLINE,约 2.4s 后淡出(reduced-motion 直接跳过)。
|
||||||
|
useEffect(() => {
|
||||||
|
if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||||
|
setBooting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = setTimeout(() => setBooting(false), 2400);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cv = canvasRef.current!;
|
||||||
|
const ctx = cv.getContext("2d")!;
|
||||||
|
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
let W = 0, H = 0, raf = 0;
|
||||||
|
const resize = () => {
|
||||||
|
const r = cv.getBoundingClientRect();
|
||||||
|
const dpr = Math.min(2, devicePixelRatio || 1);
|
||||||
|
W = r.width; H = r.height;
|
||||||
|
cv.width = W * dpr; cv.height = H * dpr;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
const ro = new ResizeObserver(resize);
|
||||||
|
ro.observe(cv);
|
||||||
|
|
||||||
|
const rgba = (c: number[], a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
|
||||||
|
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||||
|
const accent = CY.slice();
|
||||||
|
let level = 0.18, think = 0, t = 0, last = performance.now();
|
||||||
|
const parts = Array.from({ length: 64 }, () => ({ a: Math.random() * 6.28, r: Math.random(), s: 0.3 + Math.random() * 0.9 }));
|
||||||
|
// 解码文字状态:shown=已定稿字数,scr=当前字的乱码累积。
|
||||||
|
let decShown = 0, scr = 0;
|
||||||
|
|
||||||
|
const synth = (s: VoiceState) => (s === "thinking" ? 0.2 + 0.06 * Math.sin(t * 3) : 0.16 + 0.05 * Math.sin(t * 1.6));
|
||||||
|
|
||||||
|
const draw = (now: number) => {
|
||||||
|
const dt = Math.min(0.05, (now - last) / 1000); last = now; t += dt;
|
||||||
|
const st = P.current.state;
|
||||||
|
const c = CONF[st] ?? CONF.idle;
|
||||||
|
const activeAudio = st === "listening" || st === "speaking";
|
||||||
|
const tgt = activeAudio ? Math.max(P.current.getLevel(), 0) : synth(st);
|
||||||
|
level += (tgt - level) * Math.min(1, dt * (activeAudio ? 16 : 6));
|
||||||
|
for (let i = 0; i < 3; i++) accent[i] = lerp(accent[i], c.accent[i], Math.min(1, dt * 4));
|
||||||
|
think += ((st === "thinking" ? 1 : 0) - think) * Math.min(1, dt * 3);
|
||||||
|
|
||||||
|
const cx = W / 2, cy = H / 2, R = Math.min(W, H) * 0.16;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
|
||||||
|
let g = ctx.createRadialGradient(cx, cy, R * 0.2, cx, cy, R * 3.4);
|
||||||
|
g.addColorStop(0, rgba(accent, 0.1 + level * 0.1));
|
||||||
|
g.addColorStop(1, rgba(accent, 0));
|
||||||
|
ctx.fillStyle = g; ctx.fillRect(cx - R * 4, cy - R * 4, R * 8, R * 8);
|
||||||
|
|
||||||
|
const spin = reduce ? 0 : t * c.ringSpin;
|
||||||
|
[1.35, 1.7, 2.05, 2.5, 3.0].forEach((rr, i) => {
|
||||||
|
const rad = R * rr, dir = i % 2 ? -1 : 1, a0 = spin * dir * (1 + i * 0.25);
|
||||||
|
ctx.lineWidth = i === 0 ? 2.2 : 1.2;
|
||||||
|
ctx.strokeStyle = rgba(accent, (0.5 - i * 0.06) * (0.6 + level * 0.5));
|
||||||
|
ctx.shadowColor = rgba(accent, 0.8); ctx.shadowBlur = 12;
|
||||||
|
const segs: [number, number][] = i === 0 ? [[0, 5.0]] : [[a0, 1.3], [a0 + 2.3, 2.0], [a0 + 5.0, 0.8]];
|
||||||
|
segs.forEach(([s, len]) => { ctx.beginPath(); ctx.arc(cx, cy, rad, s, s + len); ctx.stroke(); });
|
||||||
|
});
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
const NB = 104, inner = R * 1.12;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
for (let i = 0; i < NB; i++) {
|
||||||
|
const ang = (i / NB) * 6.28 - Math.PI / 2 + spin * 0.15;
|
||||||
|
const seed = Math.sin(i * 12.9898) * 43758.5453; const nz = seed - Math.floor(seed);
|
||||||
|
const wob = 0.5 + 0.5 * Math.sin(t * 3 + i * 0.5);
|
||||||
|
const h = R * (0.06 + (0.1 + c.activity * 0.9 * level) * (0.35 + 0.65 * wob) * (0.5 + nz));
|
||||||
|
const x1 = cx + Math.cos(ang) * inner, y1 = cy + Math.sin(ang) * inner;
|
||||||
|
const x2 = cx + Math.cos(ang) * (inner + h), y2 = cy + Math.sin(ang) * (inner + h);
|
||||||
|
const bright = 0.35 + 0.65 * (h / (R * 0.9));
|
||||||
|
ctx.strokeStyle = rgba(accent, 0.25 + bright * 0.6);
|
||||||
|
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (think > 0.02) {
|
||||||
|
parts.forEach((p) => {
|
||||||
|
if (!reduce) p.a += dt * p.s * 1.2;
|
||||||
|
const rad = R * (1.15 + p.r * 1.7), x = cx + Math.cos(p.a) * rad, y = cy + Math.sin(p.a) * rad;
|
||||||
|
ctx.fillStyle = rgba(AM, 0.7 * think);
|
||||||
|
ctx.shadowColor = rgba(AM, 0.9); ctx.shadowBlur = 8;
|
||||||
|
ctx.beginPath(); ctx.arc(x, y, 1.6, 0, 6.28); ctx.fill();
|
||||||
|
});
|
||||||
|
const sa = t * 2.2;
|
||||||
|
ctx.save(); ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, R * 2.9, sa, sa + 0.5); ctx.closePath();
|
||||||
|
ctx.fillStyle = rgba(AM, 0.1 * think); ctx.fill(); ctx.restore();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cr = R * (0.62 + level * 0.3);
|
||||||
|
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr);
|
||||||
|
cg.addColorStop(0, rgba([255, 255, 255], 0.95));
|
||||||
|
cg.addColorStop(0.25, rgba(accent, 0.95));
|
||||||
|
cg.addColorStop(0.7, rgba(accent, 0.28));
|
||||||
|
cg.addColorStop(1, rgba(accent, 0));
|
||||||
|
ctx.fillStyle = cg; ctx.beginPath(); ctx.arc(cx, cy, cr, 0, 6.28); ctx.fill();
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
ctx.lineWidth = 1; ctx.strokeStyle = rgba([255, 255, 255], 0.5 - k * 0.13);
|
||||||
|
ctx.beginPath(); ctx.arc(cx, cy, R * (0.3 + k * 0.14), 0, 6.28); ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.strokeStyle = rgba([255, 255, 255], 0.85); ctx.lineWidth = 1.5; ctx.beginPath();
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
const a = spin * 2 + k * 2.094, rr = R * 0.22;
|
||||||
|
const x = cx + Math.cos(a) * rr, y = cy + Math.sin(a) * rr;
|
||||||
|
k ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.closePath(); ctx.stroke();
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
|
||||||
|
// 遥测读数
|
||||||
|
if (sigRef.current) sigRef.current.textContent = String(Math.round(level * 100)).padStart(2, "0");
|
||||||
|
if (latRef.current) latRef.current.textContent = String(90 + Math.round(level * 60 + Math.sin(t * 9) * 8)).padStart(3, "0");
|
||||||
|
if (gainRef.current) gainRef.current.textContent = (0.8 + level * 2.4).toFixed(1);
|
||||||
|
|
||||||
|
// 解码式回答:让文字逐字"定稿",前沿几个字符跳乱码。
|
||||||
|
const rep = P.current.reply;
|
||||||
|
if (decShown > rep.length) decShown = 0; // 新一轮回答,重置
|
||||||
|
if (decShown < rep.length) { scr += 0.5; if (scr >= 1) { scr = 0; decShown++; } }
|
||||||
|
if (decRef.current) {
|
||||||
|
let out = rep.slice(0, decShown);
|
||||||
|
const frontier = Math.min(6, rep.length - decShown);
|
||||||
|
for (let i = 0; i < frontier; i++) out += `<span class="jhud-scr">${GLYPH[(Math.random() * GLYPH.length) | 0]}</span>`;
|
||||||
|
decRef.current.innerHTML = out;
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(draw);
|
||||||
|
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const c = CONF[state] ?? CONF.idle;
|
||||||
|
const amber = state === "thinking";
|
||||||
|
const NAME = (name || "JARVIS").toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="jhud" role="dialog" aria-label="JARVIS 全屏模式">
|
||||||
|
<style>{HUD_CSS}</style>
|
||||||
|
<canvas ref={canvasRef} className="jhud-canvas" />
|
||||||
|
|
||||||
|
<span className="jhud-corner tl" /><span className="jhud-corner tr" />
|
||||||
|
<span className="jhud-corner bl" /><span className="jhud-corner br" />
|
||||||
|
|
||||||
|
<div className="jhud-status">
|
||||||
|
<div className="jhud-brand">{NAME}</div>
|
||||||
|
<div className="jhud-row">
|
||||||
|
<span className={"jhud-dot" + (state === "listening" ? " live" : amber ? " amber" : "")} />
|
||||||
|
<b>{c.label}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="jhud-tel">
|
||||||
|
<div>SIGNAL <b ref={sigRef}>00</b>%</div>
|
||||||
|
<div>LATENCY <b ref={latRef}>000</b>ms</div>
|
||||||
|
<div>GAIN <b ref={gainRef}>0.0</b></div>
|
||||||
|
<div>SESSION <b>#7F2A</b></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="jhud-close" onClick={onClose} aria-label="退出 JARVIS 模式">
|
||||||
|
<X className="h-4 w-4" /> ESC
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="jhud-core" onClick={onMic} aria-label={hint} title={hint} />
|
||||||
|
<div className={"jhud-word" + (amber ? " amber" : state === "listening" ? " live" : "")}>{hint}</div>
|
||||||
|
|
||||||
|
<div className="jhud-decode">
|
||||||
|
{transcript && <p className="jhud-me"><span>我</span>{transcript}</p>}
|
||||||
|
<p className="jhud-reply" style={{ visibility: reply ? "visible" : "hidden" }}>
|
||||||
|
<span>{NAME}</span><span ref={decRef} />
|
||||||
|
{(state === "thinking" || state === "speaking") && <i className="jhud-cursor" />}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{booting && (
|
||||||
|
<div className="jhud-boot" aria-hidden="true">
|
||||||
|
<div><span className="b">›</span> INITIALIZING {NAME} CORE</div>
|
||||||
|
<div><span className="b">›</span> AUDIO SUBSYSTEM <span className="ok">… OK</span></div>
|
||||||
|
<div><span className="b">›</span> NEURAL UPLINK <span className="ok">… OK</span></div>
|
||||||
|
<div><span className="b">›</span> VOICE MODEL · v4-flash <span className="ok">… OK</span></div>
|
||||||
|
<div className="big">ONLINE</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HUD_CSS = `
|
||||||
|
.jhud{position:fixed;inset:0;z-index:60;overflow:hidden;color:#d6f2fc;user-select:none;
|
||||||
|
background:radial-gradient(120% 80% at 50% 44%,#08131d 0%,#04070d 62%,#02040a 100%);
|
||||||
|
font-family:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;animation:jhudIn .5s ease}
|
||||||
|
@keyframes jhudIn{from{opacity:0}to{opacity:1}}
|
||||||
|
.jhud::before{content:"";position:absolute;inset:0;pointer-events:none;opacity:.5;
|
||||||
|
background-image:linear-gradient(transparent 0 3px,rgba(56,225,255,.035) 3px 4px),linear-gradient(90deg,transparent 0 3px,rgba(56,225,255,.028) 3px 4px);
|
||||||
|
background-size:100% 4px,4px 100%;mask-image:radial-gradient(100% 100% at 50% 45%,#000 55%,transparent 100%)}
|
||||||
|
.jhud::after{content:"";position:absolute;inset:0;pointer-events:none;z-index:3;
|
||||||
|
background:linear-gradient(rgba(56,225,255,0),rgba(56,225,255,.05) 50%,rgba(56,225,255,0));height:26%;animation:jhudSweep 6.5s linear infinite;opacity:.55}
|
||||||
|
@keyframes jhudSweep{0%{transform:translateY(-30%)}100%{transform:translateY(430%)}}
|
||||||
|
.jhud-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:1}
|
||||||
|
.jhud-corner{position:absolute;width:44px;height:44px;border:1.5px solid #0e5a70;z-index:4;opacity:.8}
|
||||||
|
.jhud-corner.tl{top:20px;left:20px;border-right:0;border-bottom:0}
|
||||||
|
.jhud-corner.tr{top:20px;right:20px;border-left:0;border-bottom:0}
|
||||||
|
.jhud-corner.bl{bottom:20px;left:20px;border-right:0;border-top:0}
|
||||||
|
.jhud-corner.br{bottom:20px;right:20px;border-left:0;border-top:0}
|
||||||
|
.jhud-status{position:absolute;top:30px;left:38px;z-index:5;letter-spacing:.14em;font-size:12px;line-height:1.9}
|
||||||
|
.jhud-brand{color:#38e1ff;font-size:15px;letter-spacing:.28em;text-shadow:0 0 14px rgba(56,225,255,.55)}
|
||||||
|
.jhud-row{display:flex;gap:8px;align-items:center;color:#5f8496}
|
||||||
|
.jhud-row b{color:#d6f2fc;font-weight:500}
|
||||||
|
.jhud-dot{width:7px;height:7px;border-radius:50%;background:#38e1ff;box-shadow:0 0 10px #38e1ff}
|
||||||
|
.jhud-dot.live{background:#ff4d5e;box-shadow:0 0 10px #ff4d5e;animation:jhudBlink 1s steps(2) infinite}
|
||||||
|
.jhud-dot.amber{background:#ffb638;box-shadow:0 0 10px #ffb638}
|
||||||
|
@keyframes jhudBlink{50%{opacity:.25}}
|
||||||
|
.jhud-tel{position:absolute;top:66px;right:40px;z-index:5;text-align:right;font-size:11px;line-height:2.1;letter-spacing:.14em;color:#5f8496}
|
||||||
|
.jhud-tel b{color:#38e1ff;font-weight:500;font-variant-numeric:tabular-nums}
|
||||||
|
.jhud-close{position:absolute;top:26px;right:34px;z-index:6;display:flex;align-items:center;gap:6px;
|
||||||
|
background:rgba(8,22,32,.6);border:1px solid #0e2a38;color:#7fa6b6;border-radius:999px;padding:7px 13px;
|
||||||
|
font-family:inherit;font-size:11px;letter-spacing:.14em;cursor:pointer;backdrop-filter:blur(6px);transition:.18s}
|
||||||
|
.jhud-close:hover{border-color:#38e1ff;color:#d6f2fc}
|
||||||
|
.jhud-core{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:26vmin;height:26vmin;border-radius:50%;background:transparent;border:0;cursor:pointer;z-index:5}
|
||||||
|
.jhud-word{position:absolute;left:50%;top:calc(50% + 20vmin);transform:translateX(-50%);z-index:5;font-size:13px;letter-spacing:.5em;color:#38e1ff;text-shadow:0 0 16px rgba(56,225,255,.5);text-transform:uppercase;white-space:nowrap}
|
||||||
|
.jhud-word.amber{color:#ffb638;text-shadow:0 0 16px rgba(255,182,56,.5)}
|
||||||
|
.jhud-word.live{color:#ff4d5e;text-shadow:0 0 16px rgba(255,77,94,.5)}
|
||||||
|
.jhud-decode{position:absolute;left:50%;bottom:56px;transform:translateX(-50%);z-index:5;width:min(760px,88vw);text-align:center;line-height:1.55}
|
||||||
|
.jhud-me{color:#5f8496;font-size:13px;margin:0 0 10px}
|
||||||
|
.jhud-me span,.jhud-reply>span:first-child{font-size:10px;letter-spacing:.24em;margin-right:8px;opacity:.75}
|
||||||
|
.jhud-reply{color:#eaf8ff;font-size:16px;margin:0;min-height:24px;text-shadow:0 0 10px rgba(56,225,255,.25)}
|
||||||
|
.jhud-reply>span:first-child{color:#38e1ff}
|
||||||
|
.jhud-scr{color:#38e1ff;opacity:.9}
|
||||||
|
.jhud-cursor{display:inline-block;width:8px;height:2px;background:#38e1ff;margin-left:3px;vertical-align:middle;box-shadow:0 0 8px #38e1ff;animation:jhudBlink .8s steps(2) infinite}
|
||||||
|
.jhud-boot{position:absolute;inset:0;z-index:8;background:#02040a;display:flex;flex-direction:column;justify-content:center;padding-left:12%;gap:6px;font-size:14px;letter-spacing:.06em;color:#38e1ff;animation:jhudBootOut .6s ease 1.8s forwards}
|
||||||
|
.jhud-boot>div{opacity:0;animation:jhudBootLine .3s ease forwards}
|
||||||
|
.jhud-boot>div:nth-child(1){animation-delay:.1s}
|
||||||
|
.jhud-boot>div:nth-child(2){animation-delay:.5s}
|
||||||
|
.jhud-boot>div:nth-child(3){animation-delay:.9s}
|
||||||
|
.jhud-boot>div:nth-child(4){animation-delay:1.2s}
|
||||||
|
.jhud-boot .b{color:#3d6475}.jhud-boot .ok{color:#38e1ff}
|
||||||
|
.jhud-boot .big{font-size:26px;letter-spacing:.4em;margin-top:14px;color:#eaf8ff;text-shadow:0 0 20px rgba(56,225,255,.6);animation-delay:1.5s}
|
||||||
|
@keyframes jhudBootLine{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:none}}
|
||||||
|
@keyframes jhudBootOut{to{opacity:0}}
|
||||||
|
@media (prefers-reduced-motion: reduce){.jhud::after,.jhud-boot{display:none}.jhud-dot.live,.jhud-cursor{animation:none}}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Dialog } from "../ui/Dialog";
|
||||||
|
import { Button } from "../ui/Button";
|
||||||
|
import { useToast } from "../ui/Toast";
|
||||||
|
import { getMyJarvis, saveMyJarvis, type JarvisConfig } from "../lib/api";
|
||||||
|
|
||||||
|
// 每用户 JARVIS 设置:名字 / 人设 / (高级)自带豆包配置。
|
||||||
|
// 名字与人设归用户自己;豆包配置齐全则语音走用户的账号,否则走系统兜底。
|
||||||
|
export function JarvisSettings({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [cfg, setCfg] = useState<JarvisConfig | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [advanced, setAdvanced] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setCfg(null);
|
||||||
|
getMyJarvis()
|
||||||
|
.then(setCfg)
|
||||||
|
.catch((e) => toast.push("error", (e as Error).message));
|
||||||
|
}, [open, toast]);
|
||||||
|
|
||||||
|
const set = (k: keyof JarvisConfig, v: string) => setCfg((c) => (c ? { ...c, [k]: v } : c));
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
if (!cfg) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await saveMyJarvis({
|
||||||
|
name: cfg.name,
|
||||||
|
persona: cfg.persona,
|
||||||
|
api_key: cfg.api_key.includes("•") ? "" : cfg.api_key, // 掩码=没改动,留空沿用已存
|
||||||
|
asr_resource_id: cfg.asr_resource_id,
|
||||||
|
tts_resource_id: cfg.tts_resource_id,
|
||||||
|
tts_voice_type: cfg.tts_voice_type,
|
||||||
|
});
|
||||||
|
toast.push("success", "已保存,下次说话即生效");
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
toast.push("error", (e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="JARVIS 设置"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" onClick={onSave} disabled={saving || !cfg}>
|
||||||
|
{saving ? "保存中…" : "保存"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!cfg ? (
|
||||||
|
<div className="text-slate-500">加载中…</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-slate-400">助手名字</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded-md border border-line bg-ink-850 px-3 py-2 text-slate-100 focus:border-brand focus:outline-none"
|
||||||
|
value={cfg.name}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
placeholder="JARVIS(留空用默认;可改成星期五、小助手…)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-slate-400">语气 / 人设</span>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
className="mt-1 w-full resize-none rounded-md border border-line bg-ink-850 px-3 py-2 text-slate-100 focus:border-brand focus:outline-none"
|
||||||
|
value={cfg.persona}
|
||||||
|
onChange={(e) => set("persona", e.target.value)}
|
||||||
|
placeholder="例:简洁专业、平和有礼;或活泼幽默一点。留空用默认平和语气。"
|
||||||
|
/>
|
||||||
|
<span className="mt-1 block text-[11px] text-slate-500">只作用于语音助手,和你的主偏好记忆分开、互不影响。</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-slate-400 transition hover:text-slate-200"
|
||||||
|
onClick={() => setAdvanced((v) => !v)}
|
||||||
|
>
|
||||||
|
{advanced ? "▾" : "▸"} 高级:用我自己的豆包(火山)配置
|
||||||
|
<span className="ml-1 text-slate-500">{cfg.has_own_voice ? "(已启用)" : "(默认走系统)"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{advanced && (
|
||||||
|
<div className="space-y-3 rounded-md border border-line bg-ink-900/50 p-3">
|
||||||
|
<p className="text-[11px] leading-relaxed text-slate-500">
|
||||||
|
填齐这四项就用你自己的豆包账号跑语音(识别 + 合成);任一留空则整体回落系统配置。
|
||||||
|
</p>
|
||||||
|
<Field label="API Key" type="password" value={cfg.api_key} onChange={(v) => set("api_key", v)} placeholder="留空 = 沿用已存 / 系统" />
|
||||||
|
<Field label="ASR Resource-Id" value={cfg.asr_resource_id} onChange={(v) => set("asr_resource_id", v)} placeholder="volc.bigasr.sauc.duration" />
|
||||||
|
<Field label="TTS Resource-Id" value={cfg.tts_resource_id} onChange={(v) => set("tts_resource_id", v)} placeholder="seed-tts-2.0" />
|
||||||
|
<Field label="音色 voice_type" value={cfg.tts_voice_type} onChange={(v) => set("tts_voice_type", v)} placeholder="zh_male_m191_uranus_bigtts" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
type?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-slate-400">{label}</span>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className="mt-1 w-full rounded-md border border-line bg-ink-850 px-3 py-1.5 font-mono text-sm text-slate-100 focus:border-brand focus:outline-none"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus, Globe, UserCog } from "lucide-react";
|
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus, Globe, UserCog, QrCode } from "lucide-react";
|
||||||
import type { AuthUser, TenantCtx, MyTenant, SpaceCtx, MySpace } from "../lib/api";
|
import type { AuthUser, TenantCtx, MyTenant, SpaceCtx, MySpace } from "../lib/api";
|
||||||
import { useHealth } from "../lib/health";
|
import { useHealth } from "../lib/health";
|
||||||
import { isMacDesktop } from "../lib/desktop";
|
import { isMacDesktop } from "../lib/desktop";
|
||||||
@@ -124,7 +124,7 @@ function SpaceSwitcher({ spaces, activeId, onSwitch, onCreate, onEnableTenantSpa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 顶栏:品牌 · 垂直切换 · 健康灯 · 租户/工作区切换 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
|
// 顶栏:品牌 · 垂直切换 · 健康灯 · 租户/工作区切换 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
|
||||||
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spaces = [], onSwitchSpace, onCreateSpace, onEnableTenantSpace, onManageMembers, tenantRole, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; tenants?: MyTenant[]; onSwitchTenant?: (id: string) => void; space?: SpaceCtx | null; spaces?: MySpace[]; onSwitchSpace?: (id: string) => void; onCreateSpace?: (name: string) => void; onEnableTenantSpace?: () => void; onManageMembers?: () => void; tenantRole?: string; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
|
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spaces = [], onSwitchSpace, onCreateSpace, onEnableTenantSpace, onManageMembers, onInviteMembers, tenantRole, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; tenants?: MyTenant[]; onSwitchTenant?: (id: string) => void; space?: SpaceCtx | null; spaces?: MySpace[]; onSwitchSpace?: (id: string) => void; onCreateSpace?: (name: string) => void; onEnableTenantSpace?: () => void; onManageMembers?: () => void; onInviteMembers?: () => void; tenantRole?: string; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
|
||||||
const h = useHealth();
|
const h = useHealth();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
return (
|
return (
|
||||||
@@ -154,6 +154,15 @@ export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spac
|
|||||||
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
||||||
<TenantSwitcher tenants={tenants} activeId={tenant?.tenant?.id} onSwitch={onSwitchTenant} />
|
<TenantSwitcher tenants={tenants} activeId={tenant?.tenant?.id} onSwitch={onSwitchTenant} />
|
||||||
<SpaceSwitcher spaces={spaces} activeId={space?.space?.id} onSwitch={onSwitchSpace} onCreate={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} tenantRole={tenantRole} />
|
<SpaceSwitcher spaces={spaces} activeId={space?.space?.id} onSwitch={onSwitchSpace} onCreate={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} tenantRole={tenantRole} />
|
||||||
|
{(tenantRole === "owner" || tenantRole === "admin") && (
|
||||||
|
<button
|
||||||
|
onClick={onInviteMembers}
|
||||||
|
title="邀请成员加入本租户(微信扫码)"
|
||||||
|
className="flex items-center rounded-md border border-line bg-ink-800 px-1.5 py-1 text-slate-400 transition hover:border-ink-600 hover:text-slate-200"
|
||||||
|
>
|
||||||
|
<QrCode className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{space?.space && space.space.kind !== "personal" && (
|
{space?.space && space.space.kind !== "personal" && (
|
||||||
<button
|
<button
|
||||||
onClick={onManageMembers}
|
onClick={onManageMembers}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Mic, Square, Loader2, Volume2, X, Settings2, Maximize2 } from "lucide-react";
|
||||||
|
import { VoiceClient, type VoiceState } from "../lib/voice";
|
||||||
|
import { JarvisSettings } from "./JarvisSettings";
|
||||||
|
import { JarvisHud } from "./JarvisHud";
|
||||||
|
import { getMyJarvis } from "../lib/api";
|
||||||
|
import { useToast } from "../ui/Toast";
|
||||||
|
import { cn } from "../ui/cn";
|
||||||
|
|
||||||
|
// JARVIS 语音坞:右下角悬浮麦克风。点按说话 → 转写 → 提交任务 → 跳运行页 → 朗读回答。
|
||||||
|
// onTask 把语音触发的 task_id 交回 App,接入既有运行·观测流(与键盘提交同一条路)。
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onTask: (taskId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HINT: Record<VoiceState, string> = {
|
||||||
|
idle: "点击说话",
|
||||||
|
connecting: "连接中…",
|
||||||
|
ready: "点击说话",
|
||||||
|
listening: "聆听中,再点结束",
|
||||||
|
thinking: "思考中…",
|
||||||
|
speaking: "朗读中,点击打断",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VoiceDock({ onTask }: Props) {
|
||||||
|
const toast = useToast();
|
||||||
|
const clientRef = useRef<VoiceClient | null>(null);
|
||||||
|
const [state, setState] = useState<VoiceState>("idle");
|
||||||
|
const [transcript, setTranscript] = useState(""); // 我说的(ASR 转写)
|
||||||
|
const [reply, setReply] = useState(""); // JARVIS 回答(打字机,逐 token 累加)
|
||||||
|
const [open, setOpen] = useState(false); // 是否展开对话气泡
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false); // JARVIS 设置弹窗
|
||||||
|
const [fullscreen, setFullscreen] = useState(false); // 全屏 JARVIS 模式
|
||||||
|
const [name, setName] = useState("JARVIS"); // 助手名(HUD 品牌位)
|
||||||
|
|
||||||
|
// 懒建客户端(首次点按时,带上用户手势→AudioContext 才能启动)。
|
||||||
|
const ensureClient = useCallback((): VoiceClient => {
|
||||||
|
if (!clientRef.current) {
|
||||||
|
clientRef.current = new VoiceClient({
|
||||||
|
onState: setState,
|
||||||
|
onTranscript: (text, final) => {
|
||||||
|
setTranscript(text);
|
||||||
|
if (final) setOpen(true);
|
||||||
|
},
|
||||||
|
onTask: (taskId) => {
|
||||||
|
onTask(taskId);
|
||||||
|
setOpen(true); // 留着气泡显示打字机回答
|
||||||
|
},
|
||||||
|
onReply: (delta) => {
|
||||||
|
setReply((r) => r + delta); // 打字机:增量拼接,LLM 首 token 即刻可见
|
||||||
|
setOpen(true);
|
||||||
|
},
|
||||||
|
onError: (msg) => toast.push("error", msg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return clientRef.current;
|
||||||
|
}, [onTask, toast]);
|
||||||
|
|
||||||
|
useEffect(() => () => clientRef.current?.close(), []);
|
||||||
|
|
||||||
|
// Esc 退出全屏 JARVIS 模式。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fullscreen) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setFullscreen(false);
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [fullscreen]);
|
||||||
|
|
||||||
|
const onMic = useCallback(async () => {
|
||||||
|
const c = ensureClient();
|
||||||
|
try {
|
||||||
|
if (state === "listening") {
|
||||||
|
c.stopListening();
|
||||||
|
} else {
|
||||||
|
setTranscript("");
|
||||||
|
setReply(""); // 新一轮:清上一轮的回答
|
||||||
|
setOpen(true);
|
||||||
|
await c.startListening(); // speaking 中会先打断再开新一轮
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.push("error", (e as Error).message || "麦克风启动失败(检查权限)");
|
||||||
|
}
|
||||||
|
}, [ensureClient, state, toast]);
|
||||||
|
|
||||||
|
// 进全屏 JARVIS 模式:建好客户端(这样 HUD 能读实时电平),拉一次助手名做品牌位。
|
||||||
|
const openFullscreen = useCallback(() => {
|
||||||
|
ensureClient();
|
||||||
|
getMyJarvis()
|
||||||
|
.then((j) => setName(j.name || "JARVIS"))
|
||||||
|
.catch(() => {});
|
||||||
|
setFullscreen(true);
|
||||||
|
}, [ensureClient]);
|
||||||
|
|
||||||
|
const active = state === "listening";
|
||||||
|
const busy = state === "connecting" || state === "thinking";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="pointer-events-none fixed bottom-6 right-6 z-40 flex flex-col items-end gap-2">
|
||||||
|
{/* 小工具:全屏 JARVIS 模式 + 设置 */}
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={openFullscreen}
|
||||||
|
title="全屏 JARVIS 模式"
|
||||||
|
aria-label="全屏 JARVIS 模式"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-line bg-ink-850/90 text-slate-400 shadow-md backdrop-blur transition hover:border-brand hover:text-brand-300"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
title="JARVIS 设置(名字 / 人设 / 我的豆包)"
|
||||||
|
aria-label="JARVIS 设置"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-line bg-ink-850/90 text-slate-400 shadow-md backdrop-blur transition hover:border-ink-600 hover:text-slate-200"
|
||||||
|
>
|
||||||
|
<Settings2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 对话气泡:我说的(转写)+ JARVIS 回答(打字机) */}
|
||||||
|
{open && (transcript || reply) && (
|
||||||
|
<div className="pointer-events-auto max-w-xs rounded-2xl border border-line bg-ink-850/95 px-4 py-3 text-sm shadow-xl backdrop-blur">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
{transcript && (
|
||||||
|
<p className="leading-relaxed text-slate-400">
|
||||||
|
<span className="mr-1 text-[11px] text-slate-500">我</span>
|
||||||
|
{transcript}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{reply && (
|
||||||
|
<p className="leading-relaxed text-slate-100">
|
||||||
|
<span className="mr-1 text-[11px] text-brand-300">JARVIS</span>
|
||||||
|
{reply}
|
||||||
|
{state === "thinking" && <span className="ml-0.5 inline-block h-3.5 w-[2px] translate-y-[2px] animate-pulse bg-brand-300 align-middle" />}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="mt-0.5 text-slate-500 hover:text-slate-300" onClick={() => setOpen(false)} aria-label="收起">
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 麦克风按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onMic}
|
||||||
|
title={HINT[state]}
|
||||||
|
aria-label={HINT[state]}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto relative flex h-14 w-14 items-center justify-center rounded-full shadow-xl transition",
|
||||||
|
"focus:outline-none focus-visible:ring-2 focus-visible:ring-brand/60",
|
||||||
|
active
|
||||||
|
? "bg-danger text-white"
|
||||||
|
: state === "speaking"
|
||||||
|
? "bg-brand text-white"
|
||||||
|
: "bg-brand text-white hover:bg-brand-500 active:scale-95",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active && <span className="absolute inset-0 animate-ping rounded-full bg-danger/40" />}
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
) : state === "speaking" ? (
|
||||||
|
<Volume2 className="h-6 w-6" />
|
||||||
|
) : active ? (
|
||||||
|
<Square className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Mic className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 状态提示 */}
|
||||||
|
<span className="pointer-events-none rounded-full bg-ink-900/80 px-2.5 py-0.5 text-[11px] text-slate-400">
|
||||||
|
{HINT[state]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<JarvisSettings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
|
{fullscreen && (
|
||||||
|
<JarvisHud
|
||||||
|
name={name}
|
||||||
|
state={state}
|
||||||
|
getLevel={() => clientRef.current?.level() ?? 0}
|
||||||
|
transcript={transcript}
|
||||||
|
reply={reply}
|
||||||
|
hint={HINT[state]}
|
||||||
|
onMic={onMic}
|
||||||
|
onClose={() => setFullscreen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ export function Login({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
|
|||||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" autoFocus={!isRegister} onKeyDown={(e) => e.key === "Enter" && submit()} />
|
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" autoFocus={!isRegister} onKeyDown={(e) => e.key === "Enter" && submit()} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="密码">
|
<Field label="密码">
|
||||||
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={isRegister ? "至少 6 位" : "••••••••"} onKeyDown={(e) => e.key === "Enter" && submit()} />
|
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={isRegister ? "8–72 位" : "••••••••"} onKeyDown={(e) => e.key === "Enter" && submit()} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/sundynix/sundynix-dispatcher/internal/llm"
|
"github.com/sundynix/sundynix-dispatcher/internal/llm"
|
||||||
dnats "github.com/sundynix/sundynix-dispatcher/internal/nats"
|
dnats "github.com/sundynix/sundynix-dispatcher/internal/nats"
|
||||||
"github.com/sundynix/sundynix-shared/contract"
|
"github.com/sundynix/sundynix-shared/contract"
|
||||||
|
"github.com/sundynix/sundynix-shared/health"
|
||||||
"github.com/sundynix/sundynix-shared/otelx"
|
"github.com/sundynix/sundynix-shared/otelx"
|
||||||
"github.com/sundynix/sundynix-shared/prompts"
|
"github.com/sundynix/sundynix-shared/prompts"
|
||||||
"github.com/sundynix/sundynix-shared/secrets"
|
"github.com/sundynix/sundynix-shared/secrets"
|
||||||
@@ -33,7 +34,8 @@ func main() {
|
|||||||
|
|
||||||
natsURL := envOr("NATS_URL", "nats://localhost:4222")
|
natsURL := envOr("NATS_URL", "nats://localhost:4222")
|
||||||
|
|
||||||
pool := llm.NewPool() // LLM Pool: vLLM / Ollama 集群
|
pool := llm.NewPool() // 工作主力模型池(chat)
|
||||||
|
voicePool := llm.NewPool() // JARVIS 语音模型池(voice;未配置则空、语音任务回落 pool)
|
||||||
breaker := harness.NewCircuitBreaker() // Harness: 熔断降级中心
|
breaker := harness.NewCircuitBreaker() // Harness: 熔断降级中心
|
||||||
// Harness: LLM 自动化评测(规则 + LLM-as-judge,模型就绪时启用)。
|
// Harness: LLM 自动化评测(规则 + LLM-as-judge,模型就绪时启用)。
|
||||||
llmChat := func(ctx context.Context, sys, user string) (string, error) {
|
llmChat := func(ctx context.Context, sys, user string) (string, error) {
|
||||||
@@ -52,6 +54,12 @@ func main() {
|
|||||||
log.Printf("[dispatcher] subscribe model config: %v", err)
|
log.Printf("[dispatcher] subscribe model config: %v", err)
|
||||||
}
|
}
|
||||||
go sub.FetchModelConfigWithRetry(context.Background(), pool.SetConfig)
|
go sub.FetchModelConfigWithRetry(context.Background(), pool.SetConfig)
|
||||||
|
// 语音模型(JARVIS)配置:同机制热更新 + 后台重试。未配置语音模型时拉不到、voicePool 保持空,
|
||||||
|
// 语音任务在 agentPool 里透明回落工作模型池——不影响现有功能。
|
||||||
|
if _, err := sub.SubscribeVoiceConfigUpdated(voicePool.SetConfig); err != nil {
|
||||||
|
log.Printf("[dispatcher] subscribe voice model config: %v", err)
|
||||||
|
}
|
||||||
|
go sub.FetchVoiceConfigWithRetry(context.Background(), voicePool.SetConfig)
|
||||||
|
|
||||||
// Prompt 控制面:拉激活集覆盖内置默认 + 订阅热更新(管理端激活某版即生效,不重启)。
|
// Prompt 控制面:拉激活集覆盖内置默认 + 订阅热更新(管理端激活某版即生效,不重启)。
|
||||||
go sub.FetchPromptsWithRetry(context.Background(), prompts.ApplyOverrides)
|
go sub.FetchPromptsWithRetry(context.Background(), prompts.ApplyOverrides)
|
||||||
@@ -65,8 +73,9 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[dispatcher] build eino graph: %v", err)
|
log.Fatalf("[dispatcher] build eino graph: %v", err)
|
||||||
}
|
}
|
||||||
orch.SetGuardian(guardian) // 输入护栏 Tier2
|
orch.SetGuardian(guardian) // 输入护栏 Tier2
|
||||||
orch.SetUsageSink(sub) // 成本护栏:token 用量回写网关累计/计费
|
orch.SetUsageSink(sub) // 成本护栏:token 用量回写网关累计/计费
|
||||||
|
orch.SetVoicePool(voicePool) // JARVIS 语音任务用快模型(未配置则回落工作模型)
|
||||||
|
|
||||||
// HITL 持久化中断/恢复:开 checkpoint 存储 + 审批决定流,审批节点改走中断模型
|
// HITL 持久化中断/恢复:开 checkpoint 存储 + 审批决定流,审批节点改走中断模型
|
||||||
// (compose.Interrupt 落盘释放 goroutine、抗 dispatcher 重启)。任一步失败则降级回阻塞模型。
|
// (compose.Interrupt 落盘释放 goroutine、抗 dispatcher 重启)。任一步失败则降级回阻塞模型。
|
||||||
@@ -103,6 +112,15 @@ func main() {
|
|||||||
defer func() { _ = unsub() }()
|
defer func() { _ = unsub() }()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTP 健康探针:给 k8s/LB 直接探(此前只有 NATS ServeHealth,编排器够不着)。
|
||||||
|
// readiness = NATS 连接可用(能收任务);liveness = 进程能应答。
|
||||||
|
healthShutdown := health.Serve("dispatcher", envOr("DISPATCHER_HEALTH_ADDR", ":8091"), sub.IsConnected)
|
||||||
|
defer func() {
|
||||||
|
sctx, scancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer scancel()
|
||||||
|
healthShutdown(sctx)
|
||||||
|
}()
|
||||||
|
|
||||||
// 监听退出信号,优雅停止消费。
|
// 监听退出信号,优雅停止消费。
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type boardSnapshot struct {
|
|||||||
UID string `json:"uid,omitempty"`
|
UID string `json:"uid,omitempty"`
|
||||||
SID string `json:"sid,omitempty"`
|
SID string `json:"sid,omitempty"`
|
||||||
Query string `json:"query,omitempty"`
|
Query string `json:"query,omitempty"`
|
||||||
|
UseVoice bool `json:"use_voice,omitempty"`
|
||||||
Profile string `json:"profile,omitempty"`
|
Profile string `json:"profile,omitempty"`
|
||||||
History []*schema.Message `json:"history,omitempty"`
|
History []*schema.Message `json:"history,omitempty"`
|
||||||
KB string `json:"kb,omitempty"`
|
KB string `json:"kb,omitempty"`
|
||||||
@@ -35,6 +36,7 @@ func (b *board) MarshalJSON() ([]byte, error) {
|
|||||||
UID: b.uid,
|
UID: b.uid,
|
||||||
SID: b.sid,
|
SID: b.sid,
|
||||||
Query: b.query,
|
Query: b.query,
|
||||||
|
UseVoice: b.useVoice,
|
||||||
Profile: b.profile,
|
Profile: b.profile,
|
||||||
History: b.history,
|
History: b.history,
|
||||||
KB: b.kb,
|
KB: b.kb,
|
||||||
@@ -56,6 +58,7 @@ func (b *board) UnmarshalJSON(data []byte) error {
|
|||||||
b.uid = s.UID
|
b.uid = s.UID
|
||||||
b.sid = s.SID
|
b.sid = s.SID
|
||||||
b.query = s.Query
|
b.query = s.Query
|
||||||
|
b.useVoice = s.UseVoice
|
||||||
b.profile = s.Profile
|
b.profile = s.Profile
|
||||||
b.history = s.History
|
b.history = s.History
|
||||||
b.kb = s.KB
|
b.kb = s.KB
|
||||||
|
|||||||
@@ -56,15 +56,18 @@ func (o *Orchestrator) execComposeGraph(ctx context.Context, t *contract.Task, t
|
|||||||
flow, ferr := dsl.Parse(t.Graph)
|
flow, ferr := dsl.Parse(t.Graph)
|
||||||
plan := dsl.Compile(t.Graph)
|
plan := dsl.Compile(t.Graph)
|
||||||
b := &board{
|
b := &board{
|
||||||
uid: meta(t, contract.MetaUserID),
|
uid: meta(t, contract.MetaUserID),
|
||||||
sid: meta(t, contract.MetaSessionID),
|
sid: meta(t, contract.MetaSessionID),
|
||||||
query: plan.Query,
|
query: plan.Query,
|
||||||
|
useVoice: meta(t, contract.MetaModelProfile) == contract.ModelProfileVoice, // 语音任务 → 走语音模型池
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无图/空图:退化为 compose 单轮对话。
|
// 无图/空图:退化为 compose 单轮对话。
|
||||||
if ferr != nil || flow == nil || len(flow.Nodes) == 0 {
|
if ferr != nil || flow == nil || len(flow.Nodes) == 0 {
|
||||||
tr.info("task", "system", "无结构化图", "按单轮对话执行(compose)")
|
tr.info("task", "system", "无结构化图", "按单轮对话执行(compose)")
|
||||||
b.profile = o.fetchMemory(ctx, b.uid, b.query)
|
if !b.useVoice { // 语音用用户设的 JARVIS persona,不拉主偏好记忆(两者分开,互不污染)
|
||||||
|
b.profile = o.fetchMemory(ctx, b.uid, b.query)
|
||||||
|
}
|
||||||
b.history = o.fetchHistory(ctx, b.sid)
|
b.history = o.fetchHistory(ctx, b.sid)
|
||||||
o.runComposeConversation(ctx, t.ID, b, plan.System, tr, "agent", "模型流式推理")
|
o.runComposeConversation(ctx, t.ID, b, plan.System, tr, "agent", "模型流式推理")
|
||||||
return b.answer, refsOf(b), b.fatalErr // 模型失败 → 上抛判 failed(对齐 graph.go)
|
return b.answer, refsOf(b), b.fatalErr // 模型失败 → 上抛判 failed(对齐 graph.go)
|
||||||
@@ -100,7 +103,9 @@ func (o *Orchestrator) execComposeGraph(ctx context.Context, t *contract.Task, t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hasMemory {
|
if !hasMemory {
|
||||||
b.profile = o.fetchMemory(ctx, b.uid, b.query)
|
if !b.useVoice { // 语音不拉主偏好记忆,改用用户为 JARVIS 单设的 persona(系统提示里注入)
|
||||||
|
b.profile = o.fetchMemory(ctx, b.uid, b.query)
|
||||||
|
}
|
||||||
b.history = o.fetchHistory(ctx, b.sid)
|
b.history = o.fetchHistory(ctx, b.sid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
// START → ChatModel 节点 → END,编译为 Runnable 后流式执行;可观测经 callbacks 桥到 ExecEvent。
|
// START → ChatModel 节点 → END,编译为 Runnable 后流式执行;可观测经 callbacks 桥到 ExecEvent。
|
||||||
// 模型未就绪 / 编译失败时降级回 runAgent(同样的流式回流,保证不回归)。
|
// 模型未就绪 / 编译失败时降级回 runAgent(同样的流式回流,保证不回归)。
|
||||||
func (o *Orchestrator) runComposeConversation(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node, label string) {
|
func (o *Orchestrator) runComposeConversation(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node, label string) {
|
||||||
cm := o.pool.ChatModel()
|
cm := o.agentPool(b).ChatModel() // 语音任务走语音模型池
|
||||||
if cm == nil {
|
if cm == nil {
|
||||||
o.runAgent(ctx, taskID, b, system, tr, node, label) // 无模型 → 降级桩
|
o.runAgent(ctx, taskID, b, system, tr, node, label) // 无模型 → 降级桩
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ func (o *Orchestrator) buildSpecialists(ctx context.Context, specs []specialistS
|
|||||||
usedFuncNames := map[string]bool{}
|
usedFuncNames := map[string]bool{}
|
||||||
for i, spec := range specs {
|
for i, spec := range specs {
|
||||||
sys := firstNonEmpty(spec.System, defaultAgentSystem) + specialistCondensedSuffix
|
sys := firstNonEmpty(spec.System, defaultAgentSystem) + specialistCondensedSuffix
|
||||||
run := o.specialistRunner(ctx, spec, sys, byName)
|
run := o.specialistRunner(ctx, b, spec, sys, byName)
|
||||||
if run == nil {
|
if run == nil {
|
||||||
tr.info("coordinator", "system", "专家跳过", "无可用模型:"+spec.Name)
|
tr.info("coordinator", "system", "专家跳过", "无可用模型:"+spec.Name)
|
||||||
continue
|
continue
|
||||||
@@ -175,7 +175,7 @@ func (o *Orchestrator) buildSpecialists(ctx context.Context, specs []specialistS
|
|||||||
|
|
||||||
// specialistRunner 构造专家执行闭包:带工具且模型支持函数调用→react.Agent.Generate;否则→ChatModel.Generate。
|
// specialistRunner 构造专家执行闭包:带工具且模型支持函数调用→react.Agent.Generate;否则→ChatModel.Generate。
|
||||||
// 无可用模型返回 nil(该专家被跳过)。
|
// 无可用模型返回 nil(该专家被跳过)。
|
||||||
func (o *Orchestrator) specialistRunner(ctx context.Context, spec specialistSpec, sys string, byName map[string]tool.BaseTool) func(context.Context, string) (string, error) {
|
func (o *Orchestrator) specialistRunner(ctx context.Context, b *board, spec specialistSpec, sys string, byName map[string]tool.BaseTool) func(context.Context, string) (string, error) {
|
||||||
var tools []tool.BaseTool
|
var tools []tool.BaseTool
|
||||||
for _, tn := range spec.Tools {
|
for _, tn := range spec.Tools {
|
||||||
if t, ok := byName[tn]; ok {
|
if t, ok := byName[tn]; ok {
|
||||||
@@ -183,7 +183,7 @@ func (o *Orchestrator) specialistRunner(ctx context.Context, spec specialistSpec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(tools) > 0 {
|
if len(tools) > 0 {
|
||||||
if tcm := o.pool.ToolCallingModel(); tcm != nil {
|
if tcm := o.agentPool(b).ToolCallingModel(); tcm != nil {
|
||||||
ag, err := react.NewAgent(ctx, &react.AgentConfig{
|
ag, err := react.NewAgent(ctx, &react.AgentConfig{
|
||||||
ToolCallingModel: tcm,
|
ToolCallingModel: tcm,
|
||||||
ToolsConfig: compose.ToolsNodeConfig{Tools: tools},
|
ToolsConfig: compose.ToolsNodeConfig{Tools: tools},
|
||||||
@@ -202,7 +202,7 @@ func (o *Orchestrator) specialistRunner(ctx context.Context, spec specialistSpec
|
|||||||
}
|
}
|
||||||
// 工具型专家但模型不支持函数调用 → 退纯对话(下方)
|
// 工具型专家但模型不支持函数调用 → 退纯对话(下方)
|
||||||
}
|
}
|
||||||
cm := o.pool.ChatModel()
|
cm := o.agentPool(b).ChatModel()
|
||||||
if cm == nil {
|
if cm == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ func (o *Orchestrator) specialistRunner(ctx context.Context, spec specialistSpec
|
|||||||
// Anthropic 配方(分解→定制简报→并行派发→综合)。无 ToolCallingModel / 0 可用专家 → 降级单 agent。
|
// Anthropic 配方(分解→定制简报→并行派发→综合)。无 ToolCallingModel / 0 可用专家 → 降级单 agent。
|
||||||
func (o *Orchestrator) runCoordinator(ctx context.Context, taskID string, b *board, system string, n dsl.Node, tr *execTracer, node string) {
|
func (o *Orchestrator) runCoordinator(ctx context.Context, taskID string, b *board, system string, n dsl.Node, tr *execTracer, node string) {
|
||||||
specs := parseSpecialists(n.Config)
|
specs := parseSpecialists(n.Config)
|
||||||
tcm := o.pool.ToolCallingModel()
|
tcm := o.agentPool(b).ToolCallingModel()
|
||||||
if tcm == nil || len(specs) == 0 {
|
if tcm == nil || len(specs) == 0 {
|
||||||
tr.info(node, "system", "协调者降级", "模型不支持函数调用或未配置专家,退回普通对话")
|
tr.info(node, "system", "协调者降级", "模型不支持函数调用或未配置专家,退回普通对话")
|
||||||
o.runAgent(ctx, taskID, b, system, tr, node, labelOf(n, "多智能体协调"))
|
o.runAgent(ctx, taskID, b, system, tr, node, labelOf(n, "多智能体协调"))
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const defaultAgentSystem = "你是 sundynix-agentix 平台的 AI 助手。"
|
|||||||
type board struct {
|
type board struct {
|
||||||
uid, sid string
|
uid, sid string
|
||||||
query string
|
query string
|
||||||
|
useVoice bool // 该任务用 JARVIS 语音模型池(网关 Meta[model_profile]==voice)
|
||||||
profile string
|
profile string
|
||||||
history []*schema.Message
|
history []*schema.Message
|
||||||
kb string // 最近一个检索节点的 owner 作用域库名(供 map 并行各项检索)
|
kb string // 最近一个检索节点的 owner 作用域库名(供 map 并行各项检索)
|
||||||
@@ -178,10 +179,11 @@ func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, sy
|
|||||||
var reasoning strings.Builder
|
var reasoning strings.Builder
|
||||||
onReasoning := func(s string) { reasoning.WriteString(s) }
|
onReasoning := func(s string) { reasoning.WriteString(s) }
|
||||||
var err error
|
var err error
|
||||||
if o.pool.Ready() {
|
pool := o.agentPool(b) // 语音任务走语音模型池(否则工作池)
|
||||||
err = o.pool.ChatStream(ctx, toChatMessages(msgs), send, onReasoning)
|
if pool.Ready() {
|
||||||
|
err = pool.ChatStream(ctx, toChatMessages(msgs), send, onReasoning)
|
||||||
} else {
|
} else {
|
||||||
err = o.pool.StreamText(ctx, replyFor(msgs), func(tok []byte) { send(string(tok)) })
|
err = pool.StreamText(ctx, replyFor(msgs), func(tok []byte) { send(string(tok)) })
|
||||||
}
|
}
|
||||||
if rc := reasoning.String(); rc != "" {
|
if rc := reasoning.String(); rc != "" {
|
||||||
tr.info(node, "model", "推理过程", fmt.Sprintf("思考 %d 字:%s", len([]rune(rc)), truncate(rc, 200)))
|
tr.info(node, "model", "推理过程", fmt.Sprintf("思考 %d 字:%s", len([]rune(rc)), truncate(rc, 200)))
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ const specialistTimeout = 3 * time.Minute
|
|||||||
// Orchestrator 把每个 DSL 任务动态编译为 Eino 图并执行(记忆召回 → 工具节点 → 注入 → 流式)。
|
// Orchestrator 把每个 DSL 任务动态编译为 Eino 图并执行(记忆召回 → 工具节点 → 注入 → 流式)。
|
||||||
type Orchestrator struct {
|
type Orchestrator struct {
|
||||||
pool LLM
|
pool LLM
|
||||||
|
voicePool LLM // JARVIS 语音模型池(可为 nil → 语音任务回落 pool)
|
||||||
breaker *harness.CircuitBreaker
|
breaker *harness.CircuitBreaker
|
||||||
eval *harness.Evaluator
|
eval *harness.Evaluator
|
||||||
sink TokenSink
|
sink TokenSink
|
||||||
@@ -122,6 +123,19 @@ func NewOrchestrator(pool LLM, breaker *harness.CircuitBreaker, eval *harness.Ev
|
|||||||
return &Orchestrator{pool: pool, breaker: breaker, eval: eval, sink: sink, tools: tools, exec: exec, status: status, approval: approval, evalSink: evalSink}, nil
|
return &Orchestrator{pool: pool, breaker: breaker, eval: eval, sink: sink, tools: tools, exec: exec, status: status, approval: approval, evalSink: evalSink}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetVoicePool 注入 JARVIS 语音模型池(可选)。注入且就绪时,标了 model_profile==voice 的任务
|
||||||
|
// 用它跑,抢首字时延;不注入或未就绪则语音任务透明回落工作模型池(o.pool)。
|
||||||
|
func (o *Orchestrator) SetVoicePool(p LLM) { o.voicePool = p }
|
||||||
|
|
||||||
|
// agentPool 按黑板选本任务该用的模型池:语音任务且语音池就绪 → 语音池;否则工作池。
|
||||||
|
// 只用于面向用户的 agent 生成(对话/协作);报告/护栏/记忆抽取等固定走工作池。
|
||||||
|
func (o *Orchestrator) agentPool(b *board) LLM {
|
||||||
|
if b != nil && b.useVoice && o.voicePool != nil && o.voicePool.Ready() {
|
||||||
|
return o.voicePool
|
||||||
|
}
|
||||||
|
return o.pool
|
||||||
|
}
|
||||||
|
|
||||||
// SetGuardian 注入输入护栏 Tier2 的 LLM 分类器(可选;不注入则灰区任务直接放行执行)。
|
// SetGuardian 注入输入护栏 Tier2 的 LLM 分类器(可选;不注入则灰区任务直接放行执行)。
|
||||||
func (o *Orchestrator) SetGuardian(c *harness.Classifier) { o.guard = c }
|
func (o *Orchestrator) SetGuardian(c *harness.Classifier) { o.guard = c }
|
||||||
|
|
||||||
@@ -158,8 +172,18 @@ func (o *Orchestrator) emitUsage(t *contract.Task, b *harness.Budget) {
|
|||||||
}
|
}
|
||||||
uid, _ := t.Meta[contract.MetaUserID].(string)
|
uid, _ := t.Meta[contract.MetaUserID].(string)
|
||||||
tid, _ := t.Meta[contract.MetaTenantID].(string)
|
tid, _ := t.Meta[contract.MetaTenantID].(string)
|
||||||
|
// 记录本任务**实际所用模型**(语音任务=语音池,其余=工作池),让计费按真实模型定价,
|
||||||
|
// 别把语音快模型的账记到工作模型头上(否则 SaveUsageEvent 会拿激活 chat 模型兜底,错价)。
|
||||||
|
pool := o.pool
|
||||||
|
if meta(t, contract.MetaModelProfile) == contract.ModelProfileVoice && o.voicePool != nil && o.voicePool.Ready() {
|
||||||
|
pool = o.voicePool
|
||||||
|
}
|
||||||
|
model := ""
|
||||||
|
if mn, ok := pool.(interface{ ModelName() string }); ok {
|
||||||
|
model = mn.ModelName()
|
||||||
|
}
|
||||||
if err := o.usageSink.PublishUsage(&contract.UsageEvent{
|
if err := o.usageSink.PublishUsage(&contract.UsageEvent{
|
||||||
TaskID: t.ID, UserID: uid, TenantID: tid, PromptTok: p, CompTok: c, TotalTok: total,
|
TaskID: t.ID, UserID: uid, TenantID: tid, Model: model, PromptTok: p, CompTok: c, TotalTok: total,
|
||||||
Exceeded: b.Exceeded(), TS: time.Now().UnixMilli(),
|
Exceeded: b.Exceeded(), TS: time.Now().UnixMilli(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("[usage] 回写用量失败 task=%s: %v", t.ID, err)
|
log.Printf("[usage] 回写用量失败 task=%s: %v", t.ID, err)
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ func (o *Orchestrator) discoverTools(subject func(string) string, b *board, task
|
|||||||
// runReactAgent 执行带"自主工具"的 agent 节点:模型在 ReAct 循环里自行决定调哪些 MCP 工具。
|
// runReactAgent 执行带"自主工具"的 agent 节点:模型在 ReAct 循环里自行决定调哪些 MCP 工具。
|
||||||
// 模型不支持函数调用 / 无工具时降级回普通 runAgent。最终答复流式回流;工具调用由适配器落轨迹。
|
// 模型不支持函数调用 / 无工具时降级回普通 runAgent。最终答复流式回流;工具调用由适配器落轨迹。
|
||||||
func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *board, system string, n dsl.Node, tr *execTracer, node string) {
|
func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *board, system string, n dsl.Node, tr *execTracer, node string) {
|
||||||
tcm := o.pool.ToolCallingModel()
|
tcm := o.agentPool(b).ToolCallingModel()
|
||||||
tools := o.agentTools(b, taskID, tr)
|
tools := o.agentTools(b, taskID, tr)
|
||||||
if tcm == nil || len(tools) == 0 {
|
if tcm == nil || len(tools) == 0 {
|
||||||
tr.info(node, "system", "ReAct 降级", "模型不支持函数调用或无可用工具,退回普通对话")
|
tr.info(node, "system", "ReAct 降级", "模型不支持函数调用或无可用工具,退回普通对话")
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ type Subscriber struct {
|
|||||||
inner *sharedbus.Bus
|
inner *sharedbus.Bus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsConnected 报告 NATS 此刻是否可用(供 readiness 探针)。
|
||||||
|
func (s *Subscriber) IsConnected() bool { return s.inner.IsConnected() }
|
||||||
|
|
||||||
// MustConnect 接入 NATS 并确保任务流存在(消费者声明在 Consume 时完成)。
|
// MustConnect 接入 NATS 并确保任务流存在(消费者声明在 Consume 时完成)。
|
||||||
func MustConnect(url string) *Subscriber {
|
func MustConnect(url string) *Subscriber {
|
||||||
inner, err := sharedbus.Connect(url)
|
inner, err := sharedbus.Connect(url)
|
||||||
@@ -143,6 +146,16 @@ func (s *Subscriber) FetchModelConfigWithRetry(ctx context.Context, apply func(*
|
|||||||
s.inner.RequestConfigWithRetry(ctx, contract.ConfigKindChat, apply)
|
s.inner.RequestConfigWithRetry(ctx, contract.ConfigKindChat, apply)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubscribeVoiceConfigUpdated 订阅 JARVIS 语音模型配置热更新(可空——未配置语音模型时不生效)。
|
||||||
|
func (s *Subscriber) SubscribeVoiceConfigUpdated(onUpdate func(*contract.ModelConfig)) (func() error, error) {
|
||||||
|
return s.inner.SubscribeConfigUpdated(contract.ConfigKindVoice, onUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchVoiceConfigWithRetry 后台重试拉取初始语音模型配置(未配置则一直拿不到,语音任务回落工作模型)。
|
||||||
|
func (s *Subscriber) FetchVoiceConfigWithRetry(ctx context.Context, apply func(*contract.ModelConfig)) {
|
||||||
|
s.inner.RequestConfigWithRetry(ctx, contract.ConfigKindVoice, apply)
|
||||||
|
}
|
||||||
|
|
||||||
// FetchPromptsWithRetry 后台重试拉取初始激活 prompt 集(覆盖内置默认)。
|
// FetchPromptsWithRetry 后台重试拉取初始激活 prompt 集(覆盖内置默认)。
|
||||||
func (s *Subscriber) FetchPromptsWithRetry(ctx context.Context, apply func(map[string]string)) {
|
func (s *Subscriber) FetchPromptsWithRetry(ctx context.Context, apply func(map[string]string)) {
|
||||||
s.inner.RequestPromptsWithRetry(ctx, apply)
|
s.inner.RequestPromptsWithRetry(ctx, apply)
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ func main() {
|
|||||||
bus := nats.MustConnect(natsURL) // 接入 NATS 零拷贝骨干网 + 声明任务流
|
bus := nats.MustConnect(natsURL) // 接入 NATS 零拷贝骨干网 + 声明任务流
|
||||||
defer bus.Close()
|
defer bus.Close()
|
||||||
|
|
||||||
// 配置控制面:按 kind 响应消费方(Dispatcher=chat / mcp-go=embedding)的配置请求。
|
// 配置控制面:按 kind 响应消费方(Dispatcher=chat/voice / mcp-go=embedding)的配置请求。
|
||||||
for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding} {
|
for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding, contract.ConfigKindVoice} {
|
||||||
k := kind
|
k := kind
|
||||||
if _, err := bus.ServeConfig(k, func() *contract.ModelConfig {
|
if _, err := bus.ServeConfig(k, func() *contract.ModelConfig {
|
||||||
return db.ActiveConfig(context.Background(), k) // chat 含 Fallbacks(其它模型作备用)
|
return db.ActiveConfig(context.Background(), k) // chat 含 Fallbacks(其它模型作备用)
|
||||||
@@ -146,7 +146,15 @@ func main() {
|
|||||||
|
|
||||||
r := router.New(db, cache, bus, blobStore)
|
r := router.New(db, cache, bus, blobStore)
|
||||||
addr := envOr("GATEWAY_ADDR", ":8080")
|
addr := envOr("GATEWAY_ADDR", ":8080")
|
||||||
srv := &http.Server{Addr: addr, Handler: r}
|
// 慢读/Slowloris 防护:限制读头/读体时间与头大小。**不设 WriteTimeout**——会掐断
|
||||||
|
// SSE 长连(/tasks/:id/stream、/exec)。ReadTimeout 取 60s 容纳文件上传体。
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: r,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
ReadTimeout: 60 * time.Second,
|
||||||
|
MaxHeaderBytes: 1 << 20, // 1MB
|
||||||
|
}
|
||||||
|
|
||||||
// 后台监听;ListenAndServe 在 Shutdown 后返回 ErrServerClosed(正常退出)。
|
// 后台监听;ListenAndServe 在 Shutdown 后返回 ErrServerClosed(正常退出)。
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// voicecheck 是火山语音协议自检工具:不需要麦克风、不需要起全栈,直接从本机连火山公网端点,
|
||||||
|
// 端到端验证我们手搓的 ASR / TTS 二进制帧协议是否被真火山接受。
|
||||||
|
//
|
||||||
|
// 做两件事:
|
||||||
|
// 1. TTS:合成一句中文 → 收音频(PCM 24k)→ 存 out_tts.wav(可播放试听)。
|
||||||
|
// 2. 往返:把 TTS 音频降采样到 16k → 喂 ASR → 打印转写。若转写≈原句,则 ASR+TTS 两协议全validated。
|
||||||
|
//
|
||||||
|
// 用法(凭证只走环境变量,绝不进代码/git):
|
||||||
|
//
|
||||||
|
// export VOLC_API_KEY=<你的APIKey>
|
||||||
|
// export VOLC_ASR_RESOURCE_ID=volc.seedasr.sauc.duration
|
||||||
|
// export VOLC_TTS_RESOURCE_ID=seed-tts-2.0
|
||||||
|
// export VOLC_TTS_VOICE=zh_male_m191_uranus_bigtts
|
||||||
|
// go run ./cmd/voicecheck # 默认合成并往返一句
|
||||||
|
// go run ./cmd/voicecheck "自定义要合成的话"
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := voice.Config{
|
||||||
|
APIKey: os.Getenv("VOLC_API_KEY"),
|
||||||
|
ASRResourceID: os.Getenv("VOLC_ASR_RESOURCE_ID"),
|
||||||
|
TTSResourceID: os.Getenv("VOLC_TTS_RESOURCE_ID"),
|
||||||
|
TTSVoiceType: os.Getenv("VOLC_TTS_VOICE"),
|
||||||
|
}
|
||||||
|
if cfg.APIKey == "" {
|
||||||
|
fatal("缺 VOLC_API_KEY(见文件头用法)")
|
||||||
|
}
|
||||||
|
text := "北京今天的天气怎么样,需要带伞吗。"
|
||||||
|
if len(os.Args) > 1 && os.Args[1] != "" {
|
||||||
|
text = os.Args[1]
|
||||||
|
}
|
||||||
|
fmt.Printf("配置:ASR-resource=%q TTS-resource=%q 音色=%q\n", cfg.ASRResourceID, cfg.TTSResourceID, cfg.TTSVoiceType)
|
||||||
|
|
||||||
|
// ---- 0. 握手探针:分别验 ASR / TTS 端点能否连上(区分"鉴权方案错"还是"某个 resource-id 错")----
|
||||||
|
if cfg.ASREnabled() {
|
||||||
|
if asr, err := voice.StartASR(context.Background(), cfg, "probe"); err != nil {
|
||||||
|
fmt.Printf("[探针] ASR 握手 ❌ %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("[探针] ASR 握手 ✅(鉴权方案 + ASR resource-id 有效)")
|
||||||
|
asr.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 1. TTS:合成 → 收音频 ----
|
||||||
|
fmt.Printf("\n[TTS] 合成:%q\n", text)
|
||||||
|
pcm24k, err := runTTS(cfg, text)
|
||||||
|
if err != nil {
|
||||||
|
fatal("TTS 失败:" + err.Error())
|
||||||
|
}
|
||||||
|
fmt.Printf("[TTS] ✅ 收到音频 %d 字节(PCM 24k 单声道,%.1f 秒)\n", len(pcm24k), float64(len(pcm24k)/2)/24000)
|
||||||
|
if err := writeWAV("out_tts.wav", pcm24k, 24000); err != nil {
|
||||||
|
fmt.Printf("[TTS] ⚠️ 存 WAV 失败:%v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("[TTS] 已存 out_tts.wav —— 可 `afplay out_tts.wav` 试听")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 2. 往返:TTS 音频降采样→16k 喂 ASR ----
|
||||||
|
if !cfg.ASREnabled() {
|
||||||
|
fmt.Println("\n[ASR] 跳过(未配 ASR resource-id)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pcm16k := downsample24kTo16k(pcm24k)
|
||||||
|
fmt.Printf("\n[ASR] 把合成音频降采样到 16k(%d 字节)喂识别…\n", len(pcm16k))
|
||||||
|
transcript, err := runASR(cfg, pcm16k)
|
||||||
|
if err != nil {
|
||||||
|
fatal("ASR 失败:" + err.Error())
|
||||||
|
}
|
||||||
|
fmt.Printf("[ASR] ✅ 转写结果:%q\n", transcript)
|
||||||
|
fmt.Println("\n🎉 若转写与原句大致一致,说明 ASR + TTS 两套协议都已被真火山验证通过。")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTTS 连火山双向 TTS,推一句文字,收全部音频。
|
||||||
|
func runTTS(cfg voice.Config, text string) ([]byte, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ts, err := voice.StartTTS(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer ts.Close()
|
||||||
|
if err := ts.Speak(text); err != nil {
|
||||||
|
return nil, fmt.Errorf("Speak: %w", err)
|
||||||
|
}
|
||||||
|
if err := ts.Finish(); err != nil {
|
||||||
|
return nil, fmt.Errorf("Finish: %w", err)
|
||||||
|
}
|
||||||
|
var out []byte
|
||||||
|
for chunk := range ts.Audio() {
|
||||||
|
out = append(out, chunk...)
|
||||||
|
}
|
||||||
|
if e := ts.Err(); e != nil {
|
||||||
|
return out, e
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("没收到任何音频(检查音色/resource-id/payload 键名)")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runASR 连火山流式识别,喂完整段 PCM,收最终转写。
|
||||||
|
func runASR(cfg voice.Config, pcm16k []byte) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
asr, err := voice.StartASR(ctx, cfg, "voicecheck")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer asr.Close()
|
||||||
|
|
||||||
|
// 分帧推送(模拟流式;每帧 ~100ms = 3200 字节)。
|
||||||
|
const frame = 3200
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < len(pcm16k); i += frame {
|
||||||
|
end := i + frame
|
||||||
|
if end > len(pcm16k) {
|
||||||
|
end = len(pcm16k)
|
||||||
|
}
|
||||||
|
_ = asr.PushAudio(pcm16k[i:end])
|
||||||
|
time.Sleep(80 * time.Millisecond) // 稍慢于实时,贴近真实节奏
|
||||||
|
}
|
||||||
|
_ = asr.Finish()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var last string
|
||||||
|
for r := range asr.Results() {
|
||||||
|
if r.Err != nil {
|
||||||
|
if last != "" {
|
||||||
|
return last, nil // 已有转写,流结束正常
|
||||||
|
}
|
||||||
|
return "", r.Err
|
||||||
|
}
|
||||||
|
if r.Text != "" {
|
||||||
|
last = r.Text
|
||||||
|
}
|
||||||
|
if r.Final {
|
||||||
|
return last, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downsample24kTo16k 把 16bit PCM 从 24k 降到 16k(3 取 2 的线性抽取)。
|
||||||
|
func downsample24kTo16k(in []byte) []byte {
|
||||||
|
n := len(in) / 2
|
||||||
|
out := make([]byte, 0, n*2*2/3+4)
|
||||||
|
var buf [2]byte
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if i%3 == 2 { // 每 3 个采样丢 1 个 → 24k*2/3=16k
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
binary.LittleEndian.PutUint16(buf[:], uint16(int16(binary.LittleEndian.Uint16(in[i*2:]))))
|
||||||
|
out = append(out, buf[0], buf[1])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeWAV 把 16bit 单声道 PCM 包成可播放的 WAV。
|
||||||
|
func writeWAV(path string, pcm []byte, rate int) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
dataLen := len(pcm)
|
||||||
|
var h []byte
|
||||||
|
put := func(s string) { h = append(h, s...) }
|
||||||
|
putU32 := func(v uint32) { b := make([]byte, 4); binary.LittleEndian.PutUint32(b, v); h = append(h, b...) }
|
||||||
|
putU16 := func(v uint16) { b := make([]byte, 2); binary.LittleEndian.PutUint16(b, v); h = append(h, b...) }
|
||||||
|
put("RIFF")
|
||||||
|
putU32(uint32(36 + dataLen))
|
||||||
|
put("WAVEfmt ")
|
||||||
|
putU32(16) // fmt chunk size
|
||||||
|
putU16(1) // PCM
|
||||||
|
putU16(1) // mono
|
||||||
|
putU32(uint32(rate)) // sample rate
|
||||||
|
putU32(uint32(rate*2)) // byte rate = rate * block align
|
||||||
|
putU16(2) // block align = channels * bits/8
|
||||||
|
putU16(16) // bits per sample
|
||||||
|
put("data")
|
||||||
|
putU32(uint32(dataLen))
|
||||||
|
if _, err := f.Write(h); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = f.Write(pcm)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatal(msg string) {
|
||||||
|
fmt.Fprintln(os.Stderr, "❌ "+msg)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// voiceconfig 把火山语音配置写入数据库(等价于 admin「语音设置」页保存一次)。
|
||||||
|
// 供本地联调 / 无头环境快速配好语音,免开 admin 控制台。API Key 只走环境变量、加密入库,
|
||||||
|
// 绝不进代码/git;写库前复用 gateway 同一套 AES 加密(voice.Config.EncryptedForStore)。
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
//
|
||||||
|
// export POSTGRES_DSN="postgres://sundynix:sundynix@localhost:5432/sundynix?sslmode=disable"
|
||||||
|
// export VOLC_API_KEY=<你的APIKey>
|
||||||
|
// export VOLC_ASR_RESOURCE_ID=volc.seedasr.sauc.duration
|
||||||
|
// export VOLC_TTS_RESOURCE_ID=seed-tts-2.0
|
||||||
|
// export VOLC_TTS_VOICE=zh_male_m191_uranus_bigtts
|
||||||
|
// # SUNDYNIX_SECRET_KEY 须与 gateway 一致(都不设=同用开发默认)
|
||||||
|
// go run ./cmd/voiceconfig
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
const settingVoice = "voice_config" // 与 handler.SettingVoice 对齐
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dsn := os.Getenv("POSTGRES_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
fatal("缺 POSTGRES_DSN")
|
||||||
|
}
|
||||||
|
cfg := voice.Config{
|
||||||
|
APIKey: os.Getenv("VOLC_API_KEY"),
|
||||||
|
ASRResourceID: os.Getenv("VOLC_ASR_RESOURCE_ID"),
|
||||||
|
TTSResourceID: os.Getenv("VOLC_TTS_RESOURCE_ID"),
|
||||||
|
TTSVoiceType: os.Getenv("VOLC_TTS_VOICE"),
|
||||||
|
}
|
||||||
|
if cfg.APIKey == "" {
|
||||||
|
fatal("缺 VOLC_API_KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
db := store.OpenPostgres(dsn)
|
||||||
|
if !db.Enabled() {
|
||||||
|
fatal("连不上数据库(检查 POSTGRES_DSN / 容器是否起)")
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := cfg.EncryptedForStore() // AES-256-GCM 加密 APIKey(同 gateway)
|
||||||
|
if err != nil {
|
||||||
|
fatal("加密失败:" + err.Error())
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(stored)
|
||||||
|
if err := db.SetSetting(context.Background(), settingVoice, string(raw)); err != nil {
|
||||||
|
fatal("写库失败:" + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✅ 语音配置已入库(%s)\n", settingVoice)
|
||||||
|
fmt.Printf(" ASR resource=%q 可用=%v\n", cfg.ASRResourceID, cfg.ASREnabled())
|
||||||
|
fmt.Printf(" TTS resource=%q 音色=%q 可用=%v\n", cfg.TTSResourceID, cfg.TTSVoiceType, cfg.TTSEnabled())
|
||||||
|
fmt.Println(" 网关每次请求现读,无需重启;刷新桌面端点麦克风即可。")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatal(msg string) {
|
||||||
|
fmt.Fprintln(os.Stderr, "❌ "+msg)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
// voicesim 端到端模拟一次语音对话(免麦克风):把一句问话用火山 TTS 合成成音频,当作"麦克风输入"
|
||||||
|
// 灌进网关的语音 WebSocket,走完整链路——ASR 转写 → 提交任务 → Agent(大模型)回答 → TTS 朗读回推,
|
||||||
|
// 把「问题音频」和「回答音频」都存成 wav,转写/task_id/回答文字打印出来。晚上有麦克风前先这样验全链路。
|
||||||
|
//
|
||||||
|
// 前置:gateway/dispatcher/mcp-go/基建都在跑;语音配置已入库;LLM 已配。
|
||||||
|
// 用法:
|
||||||
|
//
|
||||||
|
// export VOLC_API_KEY=... VOLC_ASR_RESOURCE_ID=volc.bigasr.sauc.duration \
|
||||||
|
// VOLC_TTS_RESOURCE_ID=seed-tts-2.0 VOLC_TTS_VOICE=zh_male_m191_uranus_bigtts
|
||||||
|
// go run ./cmd/voicesim # 默认问"你是谁?你能做什么?"
|
||||||
|
// go run ./cmd/voicesim "帮我查下明天天气" # 自定义问话
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/auth"
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
gatewayWS = "ws://localhost:8080/api/v1/voice/stream"
|
||||||
|
testUser = "2067489539219263488" // blizzardzhang@icloud.com
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := voice.Config{
|
||||||
|
APIKey: os.Getenv("VOLC_API_KEY"),
|
||||||
|
ASRResourceID: os.Getenv("VOLC_ASR_RESOURCE_ID"),
|
||||||
|
TTSResourceID: os.Getenv("VOLC_TTS_RESOURCE_ID"),
|
||||||
|
TTSVoiceType: os.Getenv("VOLC_TTS_VOICE"),
|
||||||
|
}
|
||||||
|
if !cfg.TTSEnabled() {
|
||||||
|
fatal("缺 VOLC_* 环境变量(需要 TTS 来合成问话音频)")
|
||||||
|
}
|
||||||
|
question := "你是谁?你能做什么?"
|
||||||
|
if len(os.Args) > 1 && os.Args[1] != "" {
|
||||||
|
question = os.Args[1]
|
||||||
|
}
|
||||||
|
fmt.Printf("🗣️ 模拟问话:%q\n", question)
|
||||||
|
|
||||||
|
// 1) 用火山 TTS 把问话合成为音频(PCM 24k)→ 降采样到 16k(ASR 上行采样率)。
|
||||||
|
fmt.Println("① 合成问话音频…")
|
||||||
|
q24k := synth(cfg, question)
|
||||||
|
q16k := downsample24kTo16k(q24k)
|
||||||
|
_ = writeWAV("sim_question.wav", q24k, 24000)
|
||||||
|
fmt.Printf(" ✅ 问话音频 %d 字节(存 sim_question.wav)\n", len(q24k))
|
||||||
|
|
||||||
|
// 2) 签发测试用户 JWT(与 gateway 同一 dev 默认密钥,勿设 JWT_SECRET)。
|
||||||
|
token, err := auth.Issue(testUser)
|
||||||
|
if err != nil {
|
||||||
|
fatal("签发 token 失败:" + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 连网关语音 WS。
|
||||||
|
fmt.Println("② 连接网关语音 WebSocket…")
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(gatewayWS+"?token="+token, nil)
|
||||||
|
if err != nil {
|
||||||
|
fatal("连接网关失败:" + err.Error())
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
answer := make([]byte, 0, 1<<20)
|
||||||
|
done := make(chan struct{})
|
||||||
|
var endAt, tTask, tFirstReply, tFirstAudio, tDone time.Time // 时延测量:以"说完(ClientEnd)"为起点
|
||||||
|
var replyText string
|
||||||
|
go func() { // 读循环:文本帧=事件,二进制帧=回答 TTS 音频
|
||||||
|
defer close(done)
|
||||||
|
for {
|
||||||
|
mt, data, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mt == websocket.BinaryMessage {
|
||||||
|
if tFirstAudio.IsZero() {
|
||||||
|
tFirstAudio = time.Now() // 首个音频帧=听到第一声
|
||||||
|
}
|
||||||
|
answer = append(answer, data...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var m voice.ServerMsg
|
||||||
|
if json.Unmarshal(data, &m) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch m.Type {
|
||||||
|
case voice.ServerReady:
|
||||||
|
fmt.Println(" ← ready(会话就绪)")
|
||||||
|
case voice.ServerTranscript:
|
||||||
|
tag := "部分"
|
||||||
|
if m.Final {
|
||||||
|
tag = "最终"
|
||||||
|
}
|
||||||
|
fmt.Printf(" ← 转写[%s]:%q\n", tag, m.Text)
|
||||||
|
case voice.ServerTask:
|
||||||
|
tTask = time.Now()
|
||||||
|
fmt.Printf(" ← 任务已提交 task_id=%s(Agent 正在思考…)\n", m.TaskID)
|
||||||
|
case voice.ServerReply:
|
||||||
|
if tFirstReply.IsZero() {
|
||||||
|
tFirstReply = time.Now()
|
||||||
|
fmt.Println(" ← 💬 打字机开始(回答文字逐字冒出)…")
|
||||||
|
}
|
||||||
|
replyText += m.Text
|
||||||
|
case voice.ServerSpeaking:
|
||||||
|
fmt.Println(" ← Agent 开始朗读回答…")
|
||||||
|
case voice.ServerTTSEnd:
|
||||||
|
tDone = time.Now()
|
||||||
|
fmt.Println(" ← 回答朗读完毕")
|
||||||
|
return
|
||||||
|
case voice.ServerError:
|
||||||
|
fmt.Printf(" ← 错误:%s\n", m.Msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 4) 发 start → 分帧灌音频(模拟实时)→ 发 end。
|
||||||
|
send(conn, voice.ClientMsg{Type: voice.ClientStart})
|
||||||
|
fmt.Println("③ 灌入问话音频…")
|
||||||
|
const frame = 3200 // ~100ms @16k/16bit
|
||||||
|
for i := 0; i < len(q16k); i += frame {
|
||||||
|
end := i + frame
|
||||||
|
if end > len(q16k) {
|
||||||
|
end = len(q16k)
|
||||||
|
}
|
||||||
|
_ = conn.WriteMessage(websocket.BinaryMessage, q16k[i:end])
|
||||||
|
time.Sleep(90 * time.Millisecond)
|
||||||
|
}
|
||||||
|
send(conn, voice.ClientMsg{Type: voice.ClientEnd})
|
||||||
|
endAt = time.Now() // 时延起点:用户"说完"这一刻
|
||||||
|
fmt.Println("④ 已说完,等 Agent 回答 + 朗读(大模型 + TTS,稍候)…")
|
||||||
|
|
||||||
|
// 5) 等回答朗读完(或超时)。
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(90 * time.Second):
|
||||||
|
fmt.Println(" ⏱️ 超时(90s)——大模型/TTS 可能较慢,已收到的音频仍会保存")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(answer) > 0 {
|
||||||
|
dur := float64(len(answer)/2) / float64(voice.TTSSampleRate)
|
||||||
|
_ = writeWAV("sim_answer.wav", answer, voice.TTSSampleRate)
|
||||||
|
fmt.Printf("\n🔊 回答音频 %d 字节(时长 %.1f 秒)→ 存 sim_answer.wav\n", len(answer), dur)
|
||||||
|
fmt.Println(" afplay sim_answer.wav # 听 JARVIS 的语音回答")
|
||||||
|
|
||||||
|
// 时延拆解(以"说完"为 0 点)。
|
||||||
|
fmt.Println("\n⏱️ 时延拆解(从「说完」起算):")
|
||||||
|
if !tTask.IsZero() {
|
||||||
|
fmt.Printf(" · 提交任务 %.2fs(含 ClientEnd 后 0.5s 兜底等待)\n", tTask.Sub(endAt).Seconds())
|
||||||
|
}
|
||||||
|
if !tFirstReply.IsZero() {
|
||||||
|
fmt.Printf(" · 💬 首字上屏 %.2fs ← 打字机开始(体感「马上响应」就看这个)\n", tFirstReply.Sub(endAt).Seconds())
|
||||||
|
}
|
||||||
|
if !tFirstAudio.IsZero() {
|
||||||
|
fmt.Printf(" · 👂 首字出声 %.2fs ← 听到第一声\n", tFirstAudio.Sub(endAt).Seconds())
|
||||||
|
}
|
||||||
|
if !tDone.IsZero() {
|
||||||
|
fmt.Printf(" · 朗读完毕 %.2fs(= 首字 %.2fs + 念完 %.1fs 那段话)\n",
|
||||||
|
tDone.Sub(endAt).Seconds(), tFirstAudio.Sub(endAt).Seconds(), dur)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("\n⚠️ 没收到回答音频(看上面事件流定位:转写?任务?朗读?)")
|
||||||
|
}
|
||||||
|
fmt.Println("\n完整链路:麦克风音频 → ASR 转写 → 提交任务 → 大模型回答 → TTS 朗读 —— 全程走网关,与真麦克风一致。")
|
||||||
|
}
|
||||||
|
|
||||||
|
// synth 用火山双向 TTS 合成整段文字为 PCM24k。
|
||||||
|
func synth(cfg voice.Config, text string) []byte {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ts, err := voice.StartTTS(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
fatal("合成连接失败:" + err.Error())
|
||||||
|
}
|
||||||
|
defer ts.Close()
|
||||||
|
if err := ts.Speak(text); err != nil {
|
||||||
|
fatal("合成推文字失败:" + err.Error())
|
||||||
|
}
|
||||||
|
_ = ts.Finish()
|
||||||
|
var out []byte
|
||||||
|
for chunk := range ts.Audio() {
|
||||||
|
out = append(out, chunk...)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
fatal("合成没拿到音频")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(conn *websocket.Conn, m voice.ClientMsg) {
|
||||||
|
b, _ := json.Marshal(m)
|
||||||
|
_ = conn.WriteMessage(websocket.TextMessage, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// downsample24kTo16k 16bit PCM 24k→16k(3 取 2 抽取)。
|
||||||
|
func downsample24kTo16k(in []byte) []byte {
|
||||||
|
n := len(in) / 2
|
||||||
|
out := make([]byte, 0, n*2*2/3+4)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if i%3 == 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, in[i*2], in[i*2+1])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeWAV(path string, pcm []byte, rate int) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var h []byte
|
||||||
|
put := func(s string) { h = append(h, s...) }
|
||||||
|
u32 := func(v uint32) { b := make([]byte, 4); binary.LittleEndian.PutUint32(b, v); h = append(h, b...) }
|
||||||
|
u16 := func(v uint16) { b := make([]byte, 2); binary.LittleEndian.PutUint16(b, v); h = append(h, b...) }
|
||||||
|
put("RIFF")
|
||||||
|
u32(uint32(36 + len(pcm)))
|
||||||
|
put("WAVEfmt ")
|
||||||
|
u32(16)
|
||||||
|
u16(1)
|
||||||
|
u16(1)
|
||||||
|
u32(uint32(rate))
|
||||||
|
u32(uint32(rate * 2))
|
||||||
|
u16(2)
|
||||||
|
u16(16)
|
||||||
|
put("data")
|
||||||
|
u32(uint32(len(pcm)))
|
||||||
|
if _, err := f.Write(h); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = f.Write(pcm)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatal(msg string) {
|
||||||
|
fmt.Fprintln(os.Stderr, "❌ "+msg)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ require (
|
|||||||
github.com/goccy/go-json v0.10.6 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu
|
|||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
|||||||
@@ -18,19 +18,32 @@ import (
|
|||||||
"github.com/sundynix/sundynix-shared/secrets"
|
"github.com/sundynix/sundynix-shared/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// clampLimit 解析 ?limit= 并夹到 [1, max],非法/≤0 用 def。
|
||||||
|
// 必须夹:负数(如 limit=-1)会让 gorm `Limit(-1)` **取消 LIMIT 子句**,对审计/护栏这类
|
||||||
|
// 最易膨胀的表变成全表扫 + 深翻分页。
|
||||||
|
func clampLimit(v string, def, max int) int {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if n > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampOffset 解析 ?offset=,非法/负数归 0。
|
||||||
|
func clampOffset(v string) int {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// AuditList: GET /api/v1/admin/audit?limit=&offset= —— 敏感操作审计流(倒序,供运维溯源)。
|
// AuditList: GET /api/v1/admin/audit?limit=&offset= —— 敏感操作审计流(倒序,供运维溯源)。
|
||||||
func (h *Handler) AuditList(c *gin.Context) {
|
func (h *Handler) AuditList(c *gin.Context) {
|
||||||
limit, offset := 50, 0
|
limit := clampLimit(c.Query("limit"), 50, 200)
|
||||||
if v := c.Query("limit"); v != "" {
|
offset := clampOffset(c.Query("offset"))
|
||||||
if n, err := strconv.Atoi(v); err == nil {
|
|
||||||
limit = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := c.Query("offset"); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil {
|
|
||||||
offset = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 筛选下沉到 SQL:此前是前端在当前页 50 条里过滤,翻页外的记录搜不到,
|
// 筛选下沉到 SQL:此前是前端在当前页 50 条里过滤,翻页外的记录搜不到,
|
||||||
// 对审计来说等于给出错误结论。
|
// 对审计来说等于给出错误结论。
|
||||||
f := store.AuditFilter{
|
f := store.AuditFilter{
|
||||||
@@ -55,17 +68,8 @@ func (h *Handler) AuditList(c *gin.Context) {
|
|||||||
|
|
||||||
// GuardrailEvents: GET /api/v1/admin/guardrail-events?limit=&offset= —— 护栏命中安全事件流(倒序)。
|
// GuardrailEvents: GET /api/v1/admin/guardrail-events?limit=&offset= —— 护栏命中安全事件流(倒序)。
|
||||||
func (h *Handler) GuardrailEvents(c *gin.Context) {
|
func (h *Handler) GuardrailEvents(c *gin.Context) {
|
||||||
limit, offset := 50, 0
|
limit := clampLimit(c.Query("limit"), 50, 200)
|
||||||
if v := c.Query("limit"); v != "" {
|
offset := clampOffset(c.Query("offset"))
|
||||||
if n, err := strconv.Atoi(v); err == nil {
|
|
||||||
limit = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := c.Query("offset"); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil {
|
|
||||||
offset = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows, err := h.db.ListGuardrailEvents(c.Request.Context(), limit, offset)
|
rows, err := h.db.ListGuardrailEvents(c.Request.Context(), limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
@@ -562,7 +566,7 @@ func (h *Handler) TestModel(c *gin.Context) {
|
|||||||
// broadcastActive 重新广播各 kind 当前激活配置,触发对应消费方热更新。
|
// broadcastActive 重新广播各 kind 当前激活配置,触发对应消费方热更新。
|
||||||
// chat 配置带 Fallbacks(其它已登记 chat 模型作备用),dispatcher 据此重建 failover 链。
|
// chat 配置带 Fallbacks(其它已登记 chat 模型作备用),dispatcher 据此重建 failover 链。
|
||||||
func (h *Handler) broadcastActive(ctx context.Context) {
|
func (h *Handler) broadcastActive(ctx context.Context) {
|
||||||
for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding} {
|
for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding, contract.ConfigKindVoice} {
|
||||||
if cfg := h.db.ActiveConfig(ctx, kind); cfg != nil {
|
if cfg := h.db.ActiveConfig(ctx, kind); cfg != nil {
|
||||||
// 广播失败 = dispatcher/mcp-go 拿不到新配置,症状是"控制台改了模型却不生效",
|
// 广播失败 = dispatcher/mcp-go 拿不到新配置,症状是"控制台改了模型却不生效",
|
||||||
// 而改配置的人这边一切正常。必须留痕,否则只能靠猜。
|
// 而改配置的人这边一切正常。必须留痕,否则只能靠猜。
|
||||||
|
|||||||
@@ -36,8 +36,10 @@ func (h *Handler) Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
email := strings.TrimSpace(strings.ToLower(body.Email))
|
email := strings.TrimSpace(strings.ToLower(body.Email))
|
||||||
if !strings.Contains(email, "@") || len(body.Password) < 6 {
|
// 密码 8–72 位:8 起步(此前 6 太弱);上限 72 字节——bcrypt 超 72 字节静默截断,
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "需合法邮箱且密码至少 6 位"})
|
// 不拦会让"超长密码"实际只用前 72 字节,用户以为更安全其实不然。
|
||||||
|
if !strings.Contains(email, "@") || len(body.Password) < 8 || len(body.Password) > 72 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "需合法邮箱,密码 8–72 位"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := auth.HashPassword(body.Password)
|
hash, err := auth.HashPassword(body.Password)
|
||||||
@@ -72,12 +74,20 @@ func (h *Handler) Login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
email := strings.TrimSpace(strings.ToLower(body.Email))
|
email := strings.TrimSpace(strings.ToLower(body.Email))
|
||||||
u, err := h.db.GetUserByEmail(c.Request.Context(), email)
|
ctx := c.Request.Context()
|
||||||
|
// 账户级锁定:连续失败达阈值即临时拒绝,不再校验密码(挡分布式慢速撞库;与 A5 IP 限流互补)。
|
||||||
|
if h.cache.LoginLocked(ctx, email) {
|
||||||
|
c.JSON(http.StatusTooManyRequests, gin.H{"error": "登录失败次数过多,账户已临时锁定,请稍后再试"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := h.db.GetUserByEmail(ctx, email)
|
||||||
// 用户不存在与密码错误返回同一文案,避免邮箱枚举。
|
// 用户不存在与密码错误返回同一文案,避免邮箱枚举。
|
||||||
if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, body.Password) {
|
if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, body.Password) {
|
||||||
|
h.cache.NoteLoginFail(ctx, email) // 记一次失败,累计到阈值即锁定
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.cache.ClearLoginFails(ctx, email) // 成功即清零
|
||||||
issueToken(c, u)
|
issueToken(c, u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,6 +13,24 @@ import (
|
|||||||
"github.com/sundynix/sundynix-gateway/internal/store"
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// holdDisputedOrder 处理「渠道已付但金额与订单不符」:CAS 挂起为 disputed 终态(停止无限重扫)
|
||||||
|
// 并写一条审计(仅首次转移写,避免重复回调刷审计)。绝不入账——错账比挂起贵。
|
||||||
|
func (h *Handler) holdDisputedOrder(ctx context.Context, o *store.PaymentOrder, paidFen int64) {
|
||||||
|
changed, err := h.db.MarkOrderDisputed(ctx, o.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[payment] ⚠️ 挂起金额不符订单失败 order=%s: %v", o.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return // 已挂起过
|
||||||
|
}
|
||||||
|
_ = h.db.AppendAudit(ctx, &store.AuditLog{
|
||||||
|
Actor: "system", Action: "POST", Route: "/billing/callback", Path: "/api/v1/billing/callback/" + o.Channel,
|
||||||
|
Detail: fmt.Sprintf("支付金额不符已挂起(disputed):order=%s 应付=%d 实付=%d 分 tenant=%s", o.ID, o.AmountFen, paidFen, o.TenantID),
|
||||||
|
})
|
||||||
|
log.Printf("[payment] ⚠️ 订单 %s 金额不符已挂起 应付=%d 实付=%d 分(待人工核对)", o.ID, o.AmountFen, paidFen)
|
||||||
|
}
|
||||||
|
|
||||||
// 充值(P5.1:兑换码渠道;设计见 PAYMENT_DESIGN.md)。
|
// 充值(P5.1:兑换码渠道;设计见 PAYMENT_DESIGN.md)。
|
||||||
// 入账目标一律是「计费租户」(ResolveBillingTenantID)——和消耗记账同一本账,
|
// 入账目标一律是「计费租户」(ResolveBillingTenantID)——和消耗记账同一本账,
|
||||||
// 谁的池子扣钱就往谁的池子充,别让用户充进一个花不到的池。
|
// 谁的池子扣钱就往谁的池子充,别让用户充进一个花不到的池。
|
||||||
@@ -147,10 +166,15 @@ func (h *Handler) reconcileOrder(ctx context.Context, o *store.PaymentOrder) (*s
|
|||||||
if r, err := ch.QueryOrder(ctx, o.ID); err == nil {
|
if r, err := ch.QueryOrder(ctx, o.ID); err == nil {
|
||||||
switch {
|
switch {
|
||||||
case r.Paid && r.AmountFen == o.AmountFen:
|
case r.Paid && r.AmountFen == o.AmountFen:
|
||||||
if _, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn); err == nil {
|
if changed, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn); err == nil {
|
||||||
|
if changed {
|
||||||
|
h.notifyOrderPaid(o.ID) // 首次到账才推回执,避免重复查单重复推送
|
||||||
|
}
|
||||||
o, _ = h.db.GetOrder(ctx, o.ID)
|
o, _ = h.db.GetOrder(ctx, o.ID)
|
||||||
}
|
}
|
||||||
case r.Paid: // 金额对不上:不入账,人工对账(比错账便宜)
|
case r.Paid: // 金额对不上:不入账,挂起 disputed + 审计,留人工对账(比错账便宜)
|
||||||
|
h.holdDisputedOrder(ctx, o, r.AmountFen)
|
||||||
|
o, _ = h.db.GetOrder(ctx, o.ID)
|
||||||
return o, true
|
return o, true
|
||||||
case r.Closed:
|
case r.Closed:
|
||||||
_ = h.db.ExpireOrder(ctx, o.ID)
|
_ = h.db.ExpireOrder(ctx, o.ID)
|
||||||
@@ -194,14 +218,19 @@ func (h *Handler) PaymentCallback(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.AmountFen != o.AmountFen {
|
if r.AmountFen != o.AmountFen {
|
||||||
// 金额不符:不入账、不让重试(重试也不会变对),落审计人工处理。
|
// 金额不符:不入账、不让重试(重试也不会变对),挂起 disputed + 落审计人工处理。
|
||||||
|
h.holdDisputedOrder(ctx, o, r.AmountFen)
|
||||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
|
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn); err != nil {
|
changed, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn)
|
||||||
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "入账失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "入账失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if changed {
|
||||||
|
h.notifyOrderPaid(o.ID) // 首次到账才推回执(回调可能重复推送,靠 changed 去重)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
|
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,12 +372,7 @@ func (h *Handler) AdminReconcile(c *gin.Context) {
|
|||||||
// 跨租户看所有任务(状态/租户/提交人/评测),含 HITL 待审批(status=waiting)。返回列表 + 状态计数。
|
// 跨租户看所有任务(状态/租户/提交人/评测),含 HITL 待审批(status=waiting)。返回列表 + 状态计数。
|
||||||
func (h *Handler) AdminTasks(c *gin.Context) {
|
func (h *Handler) AdminTasks(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
limit := 50
|
limit := clampLimit(c.Query("limit"), 50, 200)
|
||||||
if v := c.Query("limit"); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
||||||
limit = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows := h.db.AllTasks(ctx, c.Query("status"), c.Query("tenant"), limit)
|
rows := h.db.AllTasks(ctx, c.Query("status"), c.Query("tenant"), limit)
|
||||||
c.JSON(http.StatusOK, gin.H{"tasks": rows, "counts": h.db.TaskStatusCounts(ctx)})
|
c.JSON(http.StatusOK, gin.H{"tasks": rows, "counts": h.db.TaskStatusCounts(ctx)})
|
||||||
}
|
}
|
||||||
@@ -374,12 +398,7 @@ func (h *Handler) AdminTaskDetail(c *gin.Context) {
|
|||||||
|
|
||||||
// AdminSpaces: GET /api/v1/admin/spaces?limit= —— 全平台空间观测(跨租户)。
|
// AdminSpaces: GET /api/v1/admin/spaces?limit= —— 全平台空间观测(跨租户)。
|
||||||
func (h *Handler) AdminSpaces(c *gin.Context) {
|
func (h *Handler) AdminSpaces(c *gin.Context) {
|
||||||
limit := 200
|
limit := clampLimit(c.Query("limit"), 200, 500)
|
||||||
if v := c.Query("limit"); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
||||||
limit = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"spaces": h.db.AllSpaces(c.Request.Context(), limit)})
|
c.JSON(http.StatusOK, gin.H{"spaces": h.db.AllSpaces(c.Request.Context(), limit)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A4:limit 必须夹紧——负数(limit=-1 会让 gorm 取消 LIMIT 全表扫)、0、超大都要归位。
|
||||||
|
func TestClampLimit(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
v string
|
||||||
|
def, max int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"", 50, 200, 50}, // 空 → 默认
|
||||||
|
{"-1", 50, 200, 50}, // 负数 → 默认(关键:堵住全表扫)
|
||||||
|
{"0", 50, 200, 50}, // 0 → 默认
|
||||||
|
{"abc", 50, 200, 50}, // 非法 → 默认
|
||||||
|
{"100", 50, 200, 100}, // 合法 → 原值
|
||||||
|
{"999", 50, 200, 200}, // 超上界 → 夹到 max
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := clampLimit(tc.v, tc.def, tc.max); got != tc.want {
|
||||||
|
t.Fatalf("clampLimit(%q,%d,%d)=%d want %d", tc.v, tc.def, tc.max, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clampOffset("-5") != 0 || clampOffset("abc") != 0 || clampOffset("7") != 7 {
|
||||||
|
t.Fatal("clampOffset 应把负数/非法归 0、合法透传")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A1:safeCall 必须兜住 panic,不外抛(否则后台 goroutine 一 panic 崩整个进程)。
|
||||||
|
func TestSafeCallRecovers(t *testing.T) {
|
||||||
|
done := false
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("safeCall 未兜住 panic:%v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
safeCall("test", func() { panic("boom") })
|
||||||
|
done = true
|
||||||
|
}()
|
||||||
|
if !done {
|
||||||
|
t.Fatal("safeCall 之后应正常继续")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
|
"github.com/sundynix/sundynix-shared/secrets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 每用户 JARVIS 设置的用户级存取(非 admin):名字 / 人设 / 自带豆包配置。
|
||||||
|
// 桌面端「JARVIS 设置」面板用它读写。api_key 密文入库、脱敏回显。
|
||||||
|
|
||||||
|
// GetMyJarvis: GET /api/v1/me/jarvis —— 当前用户的 JARVIS 设置。
|
||||||
|
func (h *Handler) GetMyJarvis(c *gin.Context) {
|
||||||
|
j := h.db.GetUserJarvis(c.Request.Context(), userID(c))
|
||||||
|
if j == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"name": "", "persona": "", "asr_resource_id": "", "tts_resource_id": "", "tts_voice_type": "", "api_key": "", "has_own_voice": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiKey := ""
|
||||||
|
if j.APIKey != "" {
|
||||||
|
if plain, err := secrets.Decrypt(j.APIKey); err == nil {
|
||||||
|
apiKey = mask(plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"name": j.Name, "persona": j.Persona,
|
||||||
|
"asr_resource_id": j.ASRResourceID, "tts_resource_id": j.TTSResourceID, "tts_voice_type": j.TTSVoiceType,
|
||||||
|
"api_key": apiKey,
|
||||||
|
// 是否自带一套完整豆包配置(齐全才会覆盖系统;否则只是名字/人设生效、语音仍走系统)。
|
||||||
|
"has_own_voice": j.APIKey != "" && j.ASRResourceID != "" && j.TTSResourceID != "" && j.TTSVoiceType != "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveMyJarvis: PUT /api/v1/me/jarvis —— 保存当前用户的 JARVIS 设置。api_key 留空/掩码=沿用已存。
|
||||||
|
func (h *Handler) SaveMyJarvis(c *gin.Context) {
|
||||||
|
var b struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Persona string `json:"persona"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
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()
|
||||||
|
uid := userID(c)
|
||||||
|
|
||||||
|
// api_key:空或掩码占位 → 沿用已存密文;否则加密新值。
|
||||||
|
key := strings.TrimSpace(b.APIKey)
|
||||||
|
enc := ""
|
||||||
|
if key == "" || strings.Contains(key, "•") {
|
||||||
|
if cur := h.db.GetUserJarvis(ctx, uid); cur != nil {
|
||||||
|
enc = cur.APIKey
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e, err := secrets.Encrypt(key)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "密钥加密失败:" + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enc = e
|
||||||
|
}
|
||||||
|
|
||||||
|
j := &store.UserJarvis{
|
||||||
|
UserID: uid, Name: strings.TrimSpace(b.Name), Persona: strings.TrimSpace(b.Persona),
|
||||||
|
APIKey: enc,
|
||||||
|
ASRResourceID: strings.TrimSpace(b.ASRResourceID),
|
||||||
|
TTSResourceID: strings.TrimSpace(b.TTSResourceID),
|
||||||
|
TTSVoiceType: strings.TrimSpace(b.TTSVoiceType),
|
||||||
|
}
|
||||||
|
if err := h.db.SaveUserJarvis(ctx, j); err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
@@ -11,8 +11,10 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -230,6 +232,17 @@ func noteName(text string) string {
|
|||||||
|
|
||||||
// KbIngestFile: POST /api/v1/kb/ingest_file(multipart)—— 文件入库(异步,返回 job_id)。
|
// KbIngestFile: POST /api/v1/kb/ingest_file(multipart)—— 文件入库(异步,返回 job_id)。
|
||||||
// 流水线(解析→切块→向量化→写入)的进度经 sundynix.streams.<job_id> 回流,UI 用 SSE 看。
|
// 流水线(解析→切块→向量化→写入)的进度经 sundynix.streams.<job_id> 回流,UI 用 SSE 看。
|
||||||
|
// kbMaxUploadBytes 返回文件入库大小上限(字节)。默认 50MB,可经 KB_MAX_UPLOAD_BYTES 覆盖。
|
||||||
|
func kbMaxUploadBytes() int64 {
|
||||||
|
const def = 50 << 20 // 50MB
|
||||||
|
if v := os.Getenv("KB_MAX_UPLOAD_BYTES"); v != "" {
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) KbIngestFile(c *gin.Context) {
|
func (h *Handler) KbIngestFile(c *gin.Context) {
|
||||||
kb := c.PostForm("kb")
|
kb := c.PostForm("kb")
|
||||||
fh, err := c.FormFile("file")
|
fh, err := c.FormFile("file")
|
||||||
@@ -237,17 +250,28 @@ func (h *Handler) KbIngestFile(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 大小闸:先看 multipart 头声明的 Size(快速拒绝),再用 LimitReader 兜底防伪造 Size。
|
||||||
|
// 否则整文件 io.ReadAll 进内存 = OOM 面。上限经 KB_MAX_UPLOAD_BYTES 配(默认 50MB)。
|
||||||
|
max := kbMaxUploadBytes()
|
||||||
|
if fh.Size > max {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件过大(上限 " + strconv.FormatInt(max/(1<<20), 10) + "MB)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
f, err := fh.Open()
|
f, err := fh.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
data, err := io.ReadAll(f)
|
data, err := io.ReadAll(io.LimitReader(f, max+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if int64(len(data)) > max {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件过大(上限 " + strconv.FormatInt(max/(1<<20), 10) + "MB)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
_ = h.db.EnsureKB(c.Request.Context(), spaceID(c), userID(c), rawKB(kb), "general")
|
_ = h.db.EnsureKB(c.Request.Context(), spaceID(c), userID(c), rawKB(kb), "general")
|
||||||
job, err := h.enqueueIngest(c.Request.Context(), spaceID(c), userID(c), rawKB(kb), scopedKB(c, kb), "", fh.Filename, data, "")
|
job, err := h.enqueueIngest(c.Request.Context(), spaceID(c), userID(c), rawKB(kb), scopedKB(c, kb), "", fh.Filename, data, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import (
|
|||||||
|
|
||||||
const reconcileInterval = 1 * time.Minute
|
const reconcileInterval = 1 * time.Minute
|
||||||
|
|
||||||
|
// reconcileLeaderKey 是掉单补偿的 leader 选举锁键(多副本下只让一个实例查单,见 store.TryRunExclusive)。
|
||||||
|
const reconcileLeaderKey int64 = 20260723
|
||||||
|
|
||||||
// 主动查单节流:前端支付弹窗每 2.5s 轮一次单态,而 reconcileOrder 见 pending 就直连渠道
|
// 主动查单节流:前端支付弹窗每 2.5s 轮一次单态,而 reconcileOrder 见 pending 就直连渠道
|
||||||
// 查单——单笔订单在 30min TTL 内能打出约 720 次微信查单调用,微信侧有频控,多用户并发时
|
// 查单——单笔订单在 30min TTL 内能打出约 720 次微信查单调用,微信侧有频控,多用户并发时
|
||||||
// 先被限流的反而是我们自己。回调才是入账主路径,查单只是兜底,给它一个最小间隔即可:
|
// 先被限流的反而是我们自己。回调才是入账主路径,查单只是兜底,给它一个最小间隔即可:
|
||||||
@@ -56,7 +59,7 @@ func pruneQueryMarks() {
|
|||||||
// StartReconcile 启动掉单补偿定时器(微信渠道未配置时空转,几乎零成本)。随进程生命周期运行,
|
// StartReconcile 启动掉单补偿定时器(微信渠道未配置时空转,几乎零成本)。随进程生命周期运行,
|
||||||
// ctx 取消即退出。返回给调用方保存以便优雅停机时取消。
|
// ctx 取消即退出。返回给调用方保存以便优雅停机时取消。
|
||||||
func (h *Handler) StartReconcile(ctx context.Context) {
|
func (h *Handler) StartReconcile(ctx context.Context) {
|
||||||
go func() {
|
safeGo("payment-reconcile-ticker", func() {
|
||||||
t := time.NewTicker(reconcileInterval)
|
t := time.NewTicker(reconcileInterval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
@@ -64,11 +67,16 @@ func (h *Handler) StartReconcile(ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
h.reconcilePending(ctx)
|
// 单轮兜底 + leader 选举:多副本下只有抢到锁的实例查单补偿,避免对微信查单量随副本翻倍。
|
||||||
pruneQueryMarks()
|
safeCall("payment-reconcile-tick", func() {
|
||||||
|
h.db.TryRunExclusive(ctx, reconcileLeaderKey, func() {
|
||||||
|
h.reconcilePending(ctx)
|
||||||
|
pruneQueryMarks()
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
log.Printf("[payment] 掉单补偿定时器已启动(每 %s 扫一次 pending 微信单)", reconcileInterval)
|
log.Printf("[payment] 掉单补偿定时器已启动(每 %s 扫一次 pending 微信单)", reconcileInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ func (h *Handler) GenerateReport(c *gin.Context) {
|
|||||||
// 生成阶段只存源;此处经 mcp-go report_export 现渲染("导出时再处理")。PDF 由前端打印预览生成。
|
// 生成阶段只存源;此处经 mcp-go report_export 现渲染("导出时再处理")。PDF 由前端打印预览生成。
|
||||||
func (h *Handler) ExportReport(c *gin.Context) {
|
func (h *Handler) ExportReport(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !h.requireTaskOwner(c, id) { // 报告按 task_id 寻址:仅提交者可导出
|
||||||
|
return
|
||||||
|
}
|
||||||
format := c.DefaultQuery("format", "docx")
|
format := c.DefaultQuery("format", "docx")
|
||||||
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("report_export"),
|
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("report_export"),
|
||||||
&contract.ToolCall{Tool: "report_export", Args: map[string]any{"task_id": id, "format": format}})
|
&contract.ToolCall{Tool: "report_export", Args: map[string]any{"task_id": id, "format": format}})
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requireTaskOwner 校验请求者(AuthFromHeaderOrQuery 已注入的 uid)是该 task 的提交者。
|
||||||
|
// 用于公开 by-id 端点(SSE 流 / 报告导出):这些资源始终由本人的客户端访问(用户看/导出
|
||||||
|
// 自己提交的运行),故按 owner 判权即安全。非本人 → 403,返回 false。
|
||||||
|
func (h *Handler) requireTaskOwner(c *gin.Context, taskID string) bool {
|
||||||
|
uid := userID(c)
|
||||||
|
if uid == "" || h.db.TaskOwner(c.Request.Context(), taskID) != uid {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeGo 起一个带 panic 兜底的后台 goroutine:panic 只记日志(含 name + stack)不外抛。
|
||||||
|
// 为什么必须有:Go 里未 recover 的 panic 会崩掉**整个进程**,而 gin.Recovery() 只保护
|
||||||
|
// 请求 goroutine、不覆盖 handler 派生的后台 goroutine(定时器/推送/探针)。一个后台任务的
|
||||||
|
// 意外 panic 不该拖垮整个 gateway、连带所有在途 HTTP。
|
||||||
|
func safeGo(name string, fn func()) {
|
||||||
|
go safeCall(name, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeCall 同步执行 fn 并兜底 panic。用于定时器**单轮**内部:单轮 panic 不该终止整个 ticker,
|
||||||
|
// 兜住后下一轮照常继续(若把 recover 只放在 safeGo 外层,单轮 panic 会让整个循环 goroutine 结束)。
|
||||||
|
func safeCall(name string, fn func()) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[panic] 后台任务 %q panic 已兜底: %v\n%s", name, r, debug.Stack())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
sharedbus "github.com/sundynix/sundynix-shared/bus"
|
||||||
"github.com/sundynix/sundynix-shared/contract"
|
"github.com/sundynix/sundynix-shared/contract"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,12 +36,13 @@ type toolGroup struct {
|
|||||||
Tools []toolInfo `json:"tools"`
|
Tools []toolInfo `json:"tools"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// systemStatus 是「服务状态」面板的聚合视图:基建 / 应用服务 / MCP 工具注册。
|
// systemStatus 是「服务状态」面板的聚合视图:基建 / 应用服务 / MCP 工具注册 / NATS 集群。
|
||||||
type systemStatus struct {
|
type systemStatus struct {
|
||||||
CheckedAt string `json:"checked_at"`
|
CheckedAt string `json:"checked_at"`
|
||||||
Infra []statusItem `json:"infra"`
|
Infra []statusItem `json:"infra"`
|
||||||
Services []statusItem `json:"services"`
|
Services []statusItem `json:"services"`
|
||||||
Tools []toolGroup `json:"tools"`
|
Tools []toolGroup `json:"tools"`
|
||||||
|
Nats *sharedbus.NATSClusterStatus `json:"nats,omitempty"` // NATS 集群详情(节点/RTT/流副本 Raft 健康)
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeTimeout 是各探针的单次超时(无响应即判为下线)。
|
// probeTimeout 是各探针的单次超时(无响应即判为下线)。
|
||||||
@@ -68,65 +70,87 @@ func (h *Handler) AdminStatus(c *gin.Context) {
|
|||||||
dispLatency int // dispatcher 探针耗时
|
dispLatency int // dispatcher 探针耗时
|
||||||
|
|
||||||
pgUp, redisUp, minioUp bool // 基建活性探针(实时 ping,非仅启动标志)
|
pgUp, redisUp, minioUp bool // 基建活性探针(实时 ping,非仅启动标志)
|
||||||
|
pgMs, redisMs, minioMs int // 各基建 ping 往返耗时(ms)
|
||||||
|
natsClu sharedbus.NATSClusterStatus
|
||||||
)
|
)
|
||||||
|
|
||||||
wg.Add(5)
|
wg.Add(6)
|
||||||
|
|
||||||
// 1) mcp-go health → milvus / neo4j 基建灯
|
// 1) mcp-go health → milvus / neo4j 基建灯
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
safeCall("status-probe-mcpgo-health", func() {
|
||||||
defer cancel()
|
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||||
if res, err := h.bus.CallTool(ctx, contract.ToolSubjectGo("health"),
|
defer cancel()
|
||||||
&contract.ToolCall{Tool: "health"}); err == nil && res != nil && res.OK {
|
if res, err := h.bus.CallTool(ctx, contract.ToolSubjectGo("health"),
|
||||||
var sub map[string]bool
|
&contract.ToolCall{Tool: "health"}); err == nil && res != nil && res.OK {
|
||||||
if json.Unmarshal([]byte(res.Content), &sub) == nil {
|
var sub map[string]bool
|
||||||
milvus, neo4j = sub["milvus"], sub["neo4j"]
|
if json.Unmarshal([]byte(res.Content), &sub) == nil {
|
||||||
ftDisk = sub["fulltext_disk"]
|
milvus, neo4j = sub["milvus"], sub["neo4j"]
|
||||||
|
ftDisk = sub["fulltext_disk"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 2) mcp-go list_tools → 在线判定 + 工具清单
|
// 2) mcp-go list_tools → 在线判定 + 工具清单
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
goUp, goTools, goLatency = h.probeTools(parent, contract.ToolSubjectGo("list_tools"))
|
safeCall("status-probe-mcpgo-tools", func() {
|
||||||
|
goUp, goTools, goLatency = h.probeTools(parent, contract.ToolSubjectGo("list_tools"))
|
||||||
|
})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 3) mcp-py list_tools → 在线判定 + 工具清单
|
// 3) mcp-py list_tools → 在线判定 + 工具清单
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
pyUp, pyTools, pyLatency = h.probeTools(parent, contract.ToolSubjectPy("list_tools"))
|
safeCall("status-probe-mcppy-tools", func() {
|
||||||
|
pyUp, pyTools, pyLatency = h.probeTools(parent, contract.ToolSubjectPy("list_tools"))
|
||||||
|
})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 4) dispatcher 心跳 → 在线判定 + 模型/运行时长
|
// 4) dispatcher 心跳 → 在线判定 + 模型/运行时长
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
safeCall("status-probe-dispatcher", func() {
|
||||||
defer cancel()
|
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||||
start := time.Now()
|
defer cancel()
|
||||||
if data, err := h.bus.Ping(ctx, contract.SubjectHealthDispatcher); err == nil {
|
start := time.Now()
|
||||||
dispUp = true
|
if data, err := h.bus.Ping(ctx, contract.SubjectHealthDispatcher); err == nil {
|
||||||
dispLatency = int(time.Since(start).Milliseconds())
|
dispUp = true
|
||||||
var st struct {
|
dispLatency = int(time.Since(start).Milliseconds())
|
||||||
Model string `json:"model"`
|
var st struct {
|
||||||
Ready bool `json:"ready"`
|
Model string `json:"model"`
|
||||||
UptimeS int `json:"uptime_s"`
|
Ready bool `json:"ready"`
|
||||||
|
UptimeS int `json:"uptime_s"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &st) == nil {
|
||||||
|
dispDetail = dispatcherDetail(st.Model, st.Ready, st.UptimeS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if json.Unmarshal(data, &st) == nil {
|
})
|
||||||
dispDetail = dispatcherDetail(st.Model, st.Ready, st.UptimeS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 5) 基建活性探针:PG / Redis / MinIO 实时 ping(非仅启动标志,能反映中途掉线)。
|
// 5) 基建活性探针:PG / Redis / MinIO 实时 ping(非仅启动标志,能反映中途掉线)+ 往返耗时。
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
safeCall("status-probe-infra", func() {
|
||||||
defer cancel()
|
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||||
pgUp = h.db.Ping(ctx)
|
defer cancel()
|
||||||
redisUp = h.cache.Ping(ctx)
|
pgUp, pgMs = pingLatency(func() bool { return h.db.Ping(ctx) })
|
||||||
minioUp = h.blob != nil && h.blob.Ping(ctx)
|
redisUp, redisMs = pingLatency(func() bool { return h.cache.Ping(ctx) })
|
||||||
|
minioUp, minioMs = pingLatency(func() bool { return h.blob != nil && h.blob.Ping(ctx) })
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 6) NATS 集群体检:节点数 / RTT / 各关键流 Raft 副本健康(不再是一盏二元灯)。
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
safeCall("status-probe-nats", func() {
|
||||||
|
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
natsClu = h.bus.ClusterStatus(ctx)
|
||||||
|
})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
@@ -134,16 +158,18 @@ func (h *Handler) AdminStatus(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, systemStatus{
|
c.JSON(http.StatusOK, systemStatus{
|
||||||
CheckedAt: time.Now().Format(time.RFC3339),
|
CheckedAt: time.Now().Format(time.RFC3339),
|
||||||
Infra: []statusItem{
|
Infra: []statusItem{
|
||||||
{Name: "postgres", Up: pgUp},
|
{Name: "postgres", Up: pgUp, Latency: pgMs},
|
||||||
{Name: "redis", Up: redisUp},
|
{Name: "redis", Up: redisUp, Latency: redisMs},
|
||||||
{Name: "nats", Up: true}, // 网关连不上 NATS 即 fatal,能应答即在线
|
// NATS:不再是二元灯——上报连的节点、集群节点数、RTT、有几条流副本降级。
|
||||||
|
{Name: "nats", Up: natsClu.Connected && natsClu.Degraded == 0, Detail: natsDetail(natsClu), Latency: natsClu.RTTMillis},
|
||||||
{Name: "milvus", Up: milvus},
|
{Name: "milvus", Up: milvus},
|
||||||
{Name: "neo4j", Up: neo4j},
|
{Name: "neo4j", Up: neo4j},
|
||||||
{Name: "minio", Up: minioUp}, // 对象存储(报告/KB 正文/blob,126)
|
{Name: "minio", Up: minioUp, Latency: minioMs}, // 对象存储(报告/KB 正文/blob,126)
|
||||||
// 全文索引:mcp-go 本地 bleve,是唯一不在 128 集中存储上的检索路,
|
// 全文索引:mcp-go 本地 bleve,是唯一不在 128 集中存储上的检索路,
|
||||||
// 也是唯一会"静默降级"的一路(退内存后重启清零,检索只是变差不报错)。
|
// 也是唯一会"静默降级"的一路(退内存后重启清零,检索只是变差不报错)。
|
||||||
{Name: "全文索引", Up: goUp && ftDisk, Detail: fulltextDetail(goUp, ftDisk)},
|
{Name: "全文索引", Up: goUp && ftDisk, Detail: fulltextDetail(goUp, ftDisk)},
|
||||||
},
|
},
|
||||||
|
Nats: &natsClu,
|
||||||
Services: []statusItem{
|
Services: []statusItem{
|
||||||
{Name: "gateway", Up: true, Detail: "在线"},
|
{Name: "gateway", Up: true, Detail: "在线"},
|
||||||
{Name: "dispatcher", Up: dispUp, Detail: serviceDetail(dispUp, dispDetail), Latency: dispLatency},
|
{Name: "dispatcher", Up: dispUp, Detail: serviceDetail(dispUp, dispDetail), Latency: dispLatency},
|
||||||
@@ -173,6 +199,35 @@ func (h *Handler) probeTools(parent context.Context, subject string) (up bool, t
|
|||||||
return true, payload.Tools, int(time.Since(start).Milliseconds())
|
return true, payload.Tools, int(time.Since(start).Milliseconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pingLatency 跑一次 ping 并计时,返回 (是否可达, 往返毫秒)。不可达则耗时记 0。
|
||||||
|
func pingLatency(ping func() bool) (bool, int) {
|
||||||
|
start := time.Now()
|
||||||
|
ok := ping()
|
||||||
|
if !ok {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
return true, int(time.Since(start).Milliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// natsDetail 把 NATS 集群体检拼成一行人读摘要:节点数 · 连的节点 · 流副本健康。
|
||||||
|
func natsDetail(s sharedbus.NATSClusterStatus) string {
|
||||||
|
if !s.Connected {
|
||||||
|
return "未连接"
|
||||||
|
}
|
||||||
|
d := fmt.Sprintf("%d 节点", s.KnownServers)
|
||||||
|
if s.ConnectedTo != "" {
|
||||||
|
d += " · 连 " + s.ConnectedTo
|
||||||
|
}
|
||||||
|
if n := len(s.Streams); n > 0 {
|
||||||
|
if s.Degraded > 0 {
|
||||||
|
d += fmt.Sprintf(" · %d/%d 流副本降级(有节点掉队)", s.Degraded, n)
|
||||||
|
} else {
|
||||||
|
d += fmt.Sprintf(" · %d 流副本齐全", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
func dispatcherDetail(model string, ready bool, uptimeS int) string {
|
func dispatcherDetail(model string, ready bool, uptimeS int) string {
|
||||||
d := "运行 " + humanDuration(uptimeS)
|
d := "运行 " + humanDuration(uptimeS)
|
||||||
if model != "" {
|
if model != "" {
|
||||||
|
|||||||
@@ -14,21 +14,31 @@ import (
|
|||||||
// 进程停机期间欠下的发放,由 TickSubscription 的补发逻辑一次性补齐。
|
// 进程停机期间欠下的发放,由 TickSubscription 的补发逻辑一次性补齐。
|
||||||
const subTickInterval = 10 * time.Minute
|
const subTickInterval = 10 * time.Minute
|
||||||
|
|
||||||
|
// subLeaderKey 是订阅推进的 leader 选举锁键(多副本下只让一个实例跑,见 store.TryRunExclusive)。
|
||||||
|
const subLeaderKey int64 = 20260722
|
||||||
|
|
||||||
// StartSubscriptionTicker 随进程生命周期运行;多实例并发也安全(发放靠 ledger 唯一索引幂等)。
|
// StartSubscriptionTicker 随进程生命周期运行;多实例并发也安全(发放靠 ledger 唯一索引幂等)。
|
||||||
func (h *Handler) StartSubscriptionTicker(ctx context.Context) {
|
func (h *Handler) StartSubscriptionTicker(ctx context.Context) {
|
||||||
go func() {
|
safeGo("subscription-ticker", func() {
|
||||||
t := time.NewTicker(subTickInterval)
|
t := time.NewTicker(subTickInterval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
h.tickSubscriptions(ctx) // 启动即跑一次,把停机期间欠的补上
|
// 单轮兜底:某轮 panic 不该终止整个定时器,下一轮继续(漏发的下轮补发逻辑兜住)。
|
||||||
|
// leader 选举:多副本下只有抢到 advisory 锁的实例真正扫,其余跳过(幂等,但省重复扫 + 省频控)。
|
||||||
|
runTick := func() {
|
||||||
|
safeCall("subscription-tick", func() {
|
||||||
|
h.db.TryRunExclusive(ctx, subLeaderKey, func() { h.tickSubscriptions(ctx) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
runTick() // 启动即跑一次,补停机期间欠的
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
h.tickSubscriptions(ctx)
|
runTick()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
log.Printf("[sub] 订阅推进定时器已启动(每 %s 扫一次)", subTickInterval)
|
log.Printf("[sub] 订阅推进定时器已启动(每 %s 扫一次)", subTickInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,35 +36,64 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blob *blob.Store
|
|||||||
return &Handler{db: db, cache: cache, bus: bus, blob: blob, pay: payment.NewManager()}
|
return &Handler{db: db, cache: cache, bus: bus, blob: blob, pay: payment.NewManager()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// preflight 是「会烧钱的执行」提交前的统一关卡:当日 token 预算 → 计费租户 → 积分硬拦截。
|
// preflightBlock 是关卡未通过时的拒绝信息(HTTP 状态 + 响应体)。core 返回它、由具体调用方
|
||||||
// 返回计费租户;ok=false 表示已写过响应,调用方直接 return。
|
// 决定怎么把它变成回应(HTTP 写 JSON / 语音会话取 error 文案播报)。
|
||||||
//
|
type preflightBlock struct {
|
||||||
// 抽出来是因为这套关卡曾经只长在 SubmitTask 上,报告生成(GenerateReport)是另一条路径、
|
Status int
|
||||||
// 一直停在最初的「发个 NATS」——于是报告绕过了预算、不记计费租户、余额为 0 也照生成。
|
Body gin.H
|
||||||
// 两条路径共用同一个函数,才不会再各长各的。
|
}
|
||||||
func (h *Handler) preflight(c *gin.Context) (string, bool) {
|
|
||||||
|
func (b *preflightBlock) message() string {
|
||||||
|
if b == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if m, ok := b.Body["error"].(string); ok {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return "提交被拦截"
|
||||||
|
}
|
||||||
|
|
||||||
|
// preflightCore 是「会烧钱的执行」提交前统一关卡的**无 gin 内核**:当日 token 预算 → 暂停管控
|
||||||
|
// → 计费租户解析 → 积分硬拦截。返回计费租户;block 非空表示被拦(HTTP 与语音两条入口共用它,
|
||||||
|
// 谁也别再各长各的关卡——见记忆 execution-single-entry)。
|
||||||
|
func (h *Handler) preflightCore(ctx context.Context, uid, tid string) (billingTenant string, block *preflightBlock) {
|
||||||
// 成本护栏:单用户当日 token 日预算门控(USER_DAILY_TOKEN_BUDGET,0=不限)。
|
// 成本护栏:单用户当日 token 日预算门控(USER_DAILY_TOKEN_BUDGET,0=不限)。
|
||||||
if budget := userDailyTokenBudget(); budget > 0 {
|
if budget := userDailyTokenBudget(); budget > 0 {
|
||||||
uid := userID(c)
|
used := h.cache.GetUsage(ctx, uid, time.Now().Format("20060102"))
|
||||||
used := h.cache.GetUsage(c.Request.Context(), uid, time.Now().Format("20060102"))
|
|
||||||
if used >= int64(budget) {
|
if used >= int64(budget) {
|
||||||
c.JSON(http.StatusPaymentRequired, gin.H{
|
return "", &preflightBlock{http.StatusPaymentRequired, gin.H{
|
||||||
"error": "已达当日 token 预算上限", "used": used, "budget": budget,
|
"error": "已达当日 token 预算上限", "used": used, "budget": budget,
|
||||||
})
|
}}
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 暂停管控:活跃租户(工作区)被暂停 → 拒绝提交。否则「暂停」只是个装了没接线的开关。
|
||||||
|
if h.db.TenantSuspended(ctx, tid) {
|
||||||
|
return "", &preflightBlock{http.StatusForbidden, gin.H{"error": "租户已被暂停,暂无法提交任务"}}
|
||||||
|
}
|
||||||
// 计费目标:数据落在活跃租户(工作区),但消耗记到"计费租户"——owner/共享计费→活跃租户,
|
// 计费目标:数据落在活跃租户(工作区),但消耗记到"计费租户"——owner/共享计费→活跃租户,
|
||||||
// 否则→本人个人租户(各付各的)。硬拦截与用量都按计费租户走。
|
// 否则→本人个人租户(各付各的)。硬拦截与用量都按计费租户走。
|
||||||
billingTenant := h.db.ResolveBillingTenantID(c.Request.Context(), userID(c), tenantID(c))
|
billingTenant = h.db.ResolveBillingTenantID(ctx, uid, tid)
|
||||||
|
// 计费租户与活跃租户不同(共享计费分叉)时,计费租户被暂停也拦——别让暂停的组织被人借道烧积分。
|
||||||
|
if billingTenant != "" && billingTenant != tid && h.db.TenantSuspended(ctx, billingTenant) {
|
||||||
|
return "", &preflightBlock{http.StatusForbidden, gin.H{"error": "计费租户已被暂停,暂无法提交任务"}}
|
||||||
|
}
|
||||||
// 积分硬拦截(默认关;开关 credit_enforce):计费租户积分余额 ≤0 则拒绝,提示充值。
|
// 积分硬拦截(默认关;开关 credit_enforce):计费租户积分余额 ≤0 则拒绝,提示充值。
|
||||||
if billingTenant != "" && h.db.CreditEnforceEnabled(c.Request.Context()) {
|
if billingTenant != "" && h.db.CreditEnforceEnabled(ctx) {
|
||||||
if h.db.TenantBalance(c.Request.Context(), billingTenant) <= 0 {
|
if h.db.TenantBalance(ctx, billingTenant) <= 0 {
|
||||||
c.JSON(http.StatusPaymentRequired, gin.H{"error": "租户积分余额不足,请充值后再试", "balance_micro": 0})
|
return "", &preflightBlock{http.StatusPaymentRequired, gin.H{"error": "租户积分余额不足,请充值后再试", "balance_micro": 0}}
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return billingTenant, true
|
return billingTenant, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// preflight 是 preflightCore 的 gin 薄封装:ok=false 表示已写过响应,调用方直接 return。
|
||||||
|
func (h *Handler) preflight(c *gin.Context) (string, bool) {
|
||||||
|
bt, block := h.preflightCore(c.Request.Context(), userID(c), tenantID(c))
|
||||||
|
if block != nil {
|
||||||
|
c.JSON(block.Status, block.Body)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return bt, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// launch 把一次执行真正发出去,并接上「执行」该有的全套基建:
|
// launch 把一次执行真正发出去,并接上「执行」该有的全套基建:
|
||||||
@@ -72,16 +101,23 @@ func (h *Handler) preflight(c *gin.Context) (string, bool) {
|
|||||||
// 报告生成此前只 PublishTask,这两样都没有,所以报告既进不了运行历史,
|
// 报告生成此前只 PublishTask,这两样都没有,所以报告既进不了运行历史,
|
||||||
// 切个页面回来也彻底找不回——它明明在后端好好地跑完了。
|
// 切个页面回来也彻底找不回——它明明在后端好好地跑完了。
|
||||||
func (h *Handler) launch(c *gin.Context, task *contract.Task) error {
|
func (h *Handler) launch(c *gin.Context, task *contract.Task) error {
|
||||||
|
return h.launchCore(c.Request.Context(), userID(c), task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// launchCore 是 launch 的**无 gin 内核**:落库 + Publish + 起 token/轨迹录像。
|
||||||
|
// 语音会话(无 gin.Context)也走它,与 HTTP 提交共用同一条发射流程。
|
||||||
|
// 注意:ctx 只用于落库与 Publish(同步、瞬时完成),录像器自持后台 ctx,不受此 ctx 生命周期影响。
|
||||||
|
func (h *Handler) launchCore(ctx context.Context, uid string, task *contract.Task) error {
|
||||||
// 持久化任务提交。DB 降级(nil)时 SaveTask 返 nil 静默跳过(开发态本就无库,不阻断);
|
// 持久化任务提交。DB 降级(nil)时 SaveTask 返 nil 静默跳过(开发态本就无库,不阻断);
|
||||||
// 但 DB 活着却写失败 → 真故障,绝不能吞:一旦 PublishTask 发出去,任务就在后端跑了,
|
// 但 DB 活着却写失败 → 真故障,绝不能吞:一旦 PublishTask 发出去,任务就在后端跑了,
|
||||||
// 却不进运行历史、复盘不了、报告类的会彻底"丢"(用户切页面回来找不回)。
|
// 却不进运行历史、复盘不了、报告类的会彻底"丢"(用户切页面回来找不回)。
|
||||||
// 宁可这里失败上浮 5xx 让用户重试,也不发一个"看不见的执行"。落库在 Publish 之前,
|
// 宁可这里失败上浮 5xx 让用户重试,也不发一个"看不见的执行"。落库在 Publish 之前,
|
||||||
// 失败时还没发布,中止是干净的。
|
// 失败时还没发布,中止是干净的。
|
||||||
if err := h.db.SaveTask(c.Request.Context(), userID(c), task.ID, string(task.Graph)); err != nil {
|
if err := h.db.SaveTask(ctx, uid, task.ID, string(task.Graph)); err != nil {
|
||||||
log.Printf("[gateway] save task %s failed: %v", task.ID, err)
|
log.Printf("[gateway] save task %s failed: %v", task.ID, err)
|
||||||
return fmt.Errorf("任务落库失败,请重试: %w", err)
|
return fmt.Errorf("任务落库失败,请重试: %w", err)
|
||||||
}
|
}
|
||||||
if err := h.bus.PublishTask(c.Request.Context(), task); err != nil {
|
if err := h.bus.PublishTask(ctx, task); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// 从提交即开始把 token 流 + 执行轨迹录进 Redis Stream(订阅早于 dispatcher 产出)→
|
// 从提交即开始把 token 流 + 执行轨迹录进 Redis Stream(订阅早于 dispatcher 产出)→
|
||||||
@@ -251,6 +287,9 @@ func (h *Handler) ApproveTask(c *gin.Context) {
|
|||||||
// 优先从 Redis Stream 读(可回放 + 断点续传,根治连晚/重连丢 token);Redis 降级时回退 live NATS。
|
// 优先从 Redis Stream 读(可回放 + 断点续传,根治连晚/重连丢 token);Redis 降级时回退 live NATS。
|
||||||
func (h *Handler) StreamTask(c *gin.Context) {
|
func (h *Handler) StreamTask(c *gin.Context) {
|
||||||
taskID := c.Param("id")
|
taskID := c.Param("id")
|
||||||
|
if !h.requireTaskOwner(c, taskID) { // 归属校验须在写 SSE 头之前
|
||||||
|
return
|
||||||
|
}
|
||||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||||
c.Writer.Header().Set("Connection", "keep-alive")
|
c.Writer.Header().Set("Connection", "keep-alive")
|
||||||
@@ -320,11 +359,20 @@ func (h *Handler) Healthz(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Readyz: GET /readyz —— 就绪探针(readiness):核心依赖(DB/Redis)可用才 200,否则 503。
|
// Readyz: GET /readyz —— 就绪探针(readiness):**实时** ping 依赖,硬依赖 DB 可达才 200,否则 503。
|
||||||
// 供 k8s 等编排器在依赖未就绪时暂不导流。NATS 在启动时即连(连不上会 fatal),故不单列。
|
// 供 k8s/LB 在依赖未就绪或运行中掉线时暂不导流。
|
||||||
|
// 两个刻意的设计:
|
||||||
|
// - 用 Ping 实时探活,**不是** Enabled() 启动期降级标志——后者反映不了「启动时连过、运行中
|
||||||
|
// PG 掉线」,会让 LB 继续往已不可用的实例导流。
|
||||||
|
// - 只把 **DB 当硬依赖门**:Redis 掉线仍可服务(限流有进程内 fail-safe 兜底、SSE 回落 live NATS),
|
||||||
|
// 若 Redis 一 blip 就把全部实例踢出轮转反而制造整站故障。Redis 只上报、不 gate。
|
||||||
|
// NATS 启动即连(连不上 fatal),不单列。
|
||||||
func (h *Handler) Readyz(c *gin.Context) {
|
func (h *Handler) Readyz(c *gin.Context) {
|
||||||
deps := gin.H{"db": h.db.Enabled(), "redis": h.cache.Enabled()}
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||||
if h.db.Enabled() && h.cache.Enabled() {
|
defer cancel()
|
||||||
|
dbOK := h.db.Ping(ctx)
|
||||||
|
deps := gin.H{"db": dbOK, "redis": h.cache.Ping(ctx)}
|
||||||
|
if dbOK {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ready", "deps": deps})
|
c.JSON(http.StatusOK, gin.H{"status": "ready", "deps": deps})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -360,6 +408,9 @@ func (h *Handler) Health(c *gin.Context) {
|
|||||||
// 优先从 Redis Stream 读(可回放 + 断点续传,根治连晚/刷新重连丢轨迹事件);Redis 降级时回退 live NATS。
|
// 优先从 Redis Stream 读(可回放 + 断点续传,根治连晚/刷新重连丢轨迹事件);Redis 降级时回退 live NATS。
|
||||||
func (h *Handler) StreamExec(c *gin.Context) {
|
func (h *Handler) StreamExec(c *gin.Context) {
|
||||||
taskID := c.Param("id")
|
taskID := c.Param("id")
|
||||||
|
if !h.requireTaskOwner(c, taskID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||||
c.Writer.Header().Set("Connection", "keep-alive")
|
c.Writer.Header().Set("Connection", "keep-alive")
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 租户成员「二维码邀请」(可复用团队码)。owner/admin 生成一张微信带参二维码发给团队,
|
||||||
|
// 成员扫码关注/识别即自动入组(见 wechat_login.go 的 WxMPEvent inv_ 分支)。
|
||||||
|
//
|
||||||
|
// 挂在 tenant_self.go 旁,作用于当前活跃租户;建/撤销须 ≥admin(路由 RequireTenantRole 把守)。
|
||||||
|
|
||||||
|
const (
|
||||||
|
inviteScenePrefix = "inv_" // 二维码 scene 前缀,区分邀请扫码 vs 登录扫码
|
||||||
|
inviteMaxDays = 30 // 微信临时二维码有效期上限
|
||||||
|
inviteDefaultDays = 7
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateTenantInvite: POST /api/v1/tenants/current/invites {role?, expires_days?, max_uses?}
|
||||||
|
// 建邀请码并调微信出二维码,返回含二维码图 URL 的邀请记录。
|
||||||
|
func (h *Handler) CreateTenantInvite(c *gin.Context) {
|
||||||
|
var b struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
ExpiresDays int `json:"expires_days"`
|
||||||
|
MaxUses int `json:"max_uses"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&b)
|
||||||
|
|
||||||
|
role := b.Role
|
||||||
|
if role == "" {
|
||||||
|
role = store.RoleMember
|
||||||
|
}
|
||||||
|
if role == store.RoleOwner || !store.ValidRole(role) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "非法角色(二维码不能邀请为 owner)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days := b.ExpiresDays
|
||||||
|
if days <= 0 {
|
||||||
|
days = inviteDefaultDays
|
||||||
|
}
|
||||||
|
if days > inviteMaxDays {
|
||||||
|
days = inviteMaxDays
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
cfg := h.loadWechatMP(ctx)
|
||||||
|
if !cfg.Enabled() {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "微信未配置,无法生成邀请二维码"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := h.accessToken(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "微信暂不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inv, err := h.db.CreateInvite(ctx, tenantID(c), userID(c), role, time.Now().AddDate(0, 0, days), b.MaxUses)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qr, err := cfg.CreateSceneQR(ctx, token, inviteScenePrefix+inv.Token, days*86400)
|
||||||
|
if err != nil {
|
||||||
|
// 二维码没建成 → 撤销这条,别在列表里留一条无图的死码。
|
||||||
|
_ = h.db.RevokeInvite(ctx, tenantID(c), inv.ID)
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "生成二维码失败:" + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.SetInviteQR(ctx, inv.ID, qr); err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inv.QRImage = qr
|
||||||
|
c.JSON(http.StatusOK, gin.H{"invite": inv})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTenantInvites: GET /api/v1/tenants/current/invites —— 本租户的有效邀请码(含已用/上限/有效期)。
|
||||||
|
func (h *Handler) ListTenantInvites(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"invites": h.db.ListInvites(c.Request.Context(), tenantID(c), true)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeTenantInvite: DELETE /api/v1/tenants/current/invites/:id —— 撤销一张邀请码。
|
||||||
|
func (h *Handler) RevokeTenantInvite(c *gin.Context) {
|
||||||
|
if err := h.db.RevokeInvite(c.Request.Context(), tenantID(c), c.Param("id")); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 语音交互 WebSocket 端点(JARVIS)。一条连接承载上行音频 + 下行转写 + 下行 TTS 音频,
|
||||||
|
// 协议见 voice/protocol.go。鉴权走 AuthFromHeaderOrQuery(EventSource/WS 带不了 Bearer 头,
|
||||||
|
// 用 ?token=)。本文件是会话外壳 + 客户端↔网关协议循环;火山 ASR/TTS 客户端在下一步接入。
|
||||||
|
|
||||||
|
var voiceUpgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 4096,
|
||||||
|
WriteBufferSize: 4096,
|
||||||
|
// CheckOrigin 放行:鉴权已由 token 把关(跨源 WS 无法读响应,且我们不依赖 cookie)。
|
||||||
|
CheckOrigin: func(*http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
const voiceWriteWait = 10 * time.Second
|
||||||
|
|
||||||
|
// VoiceStream: GET /api/v1/voice/stream —— 升级为 WebSocket 语音会话。
|
||||||
|
func (h *Handler) VoiceStream(c *gin.Context) {
|
||||||
|
uid := userID(c)
|
||||||
|
if uid == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "需要登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 每用户解析:火山配置(用户自带优先、系统兜底)+ 助手名 + 人设。
|
||||||
|
cfg, jname, jpersona := h.resolveJarvis(c.Request.Context(), uid)
|
||||||
|
if !cfg.ASREnabled() {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "语音服务未配置(缺 API Key / ASR resource-id)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := voiceUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[voice] 升级 WS 失败 uid=%s: %v", uid, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// 租户/会话在升级时(还握着 gin.Context)一并抓取,供 WS 读循环里提交任务复用共用关卡。
|
||||||
|
sess := &voiceSession{
|
||||||
|
conn: conn, uid: uid, cfg: cfg, h: h,
|
||||||
|
tenantID: tenantID(c), sessionID: sessionID(c),
|
||||||
|
jarvisName: jname, jarvisPersona: jpersona,
|
||||||
|
}
|
||||||
|
sess.send(voice.ServerMsg{Type: voice.ServerReady})
|
||||||
|
sess.run()
|
||||||
|
sess.stopASR() // 连接结束,收掉在跑的识别会话
|
||||||
|
sess.stopTTS() // 连带停掉在朗读的下行 TTS
|
||||||
|
}
|
||||||
|
|
||||||
|
// voiceSession 是一次语音会话的外壳:持 WS 连接,跑协议循环。
|
||||||
|
// 上行 = 音频→ASR→转写→提交任务;下行(token流→攒句→TTS→音频)将在 TTS 步接上。
|
||||||
|
type voiceSession struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
h *Handler // 复用 preflightCore/launchCore 提交任务
|
||||||
|
uid string
|
||||||
|
tenantID string // 升级时抓取(读循环里无 gin.Context)
|
||||||
|
sessionID string
|
||||||
|
cfg voice.Config
|
||||||
|
jarvisName string // 用户自定义助手名(空=默认 JARVIS)
|
||||||
|
jarvisPersona string // 用户为该助手设的语气人设(与主偏好记忆分开)
|
||||||
|
|
||||||
|
writeMu sync.Mutex // gorilla WS 不允许并发写:读循环与 ASR 结果 goroutine 都会 send,须串行化
|
||||||
|
asr *voice.ASRSession
|
||||||
|
asrCancel context.CancelFunc
|
||||||
|
|
||||||
|
ttsMu sync.Mutex // 护住当前下行 TTS 会话指针(打断/收尾从别的 goroutine 访问)
|
||||||
|
tts *voice.TTSSession
|
||||||
|
ttsCancel context.CancelFunc
|
||||||
|
|
||||||
|
pendingGraph string // 客户端 start 时带的画布编排图(语音触发既有编排),空则按转写现组
|
||||||
|
|
||||||
|
turnMu sync.Mutex // 护住一轮的转写累计 + 提交去重(ASR 结果 goroutine 与 ClientEnd 兜底 goroutine 都访问)
|
||||||
|
latestText string // 本轮最近一次转写(部分/最终);ClientEnd 时兜底用它提交
|
||||||
|
submitted bool // 本轮是否已提交——Final 与 ClientEnd 两条路径只落一次
|
||||||
|
}
|
||||||
|
|
||||||
|
// send 下发一条控制/事件消息(文本帧,JSON)。并发安全。
|
||||||
|
func (s *voiceSession) send(m voice.ServerMsg) {
|
||||||
|
b, _ := json.Marshal(m)
|
||||||
|
s.writeMu.Lock()
|
||||||
|
defer s.writeMu.Unlock()
|
||||||
|
_ = s.conn.SetWriteDeadline(time.Now().Add(voiceWriteWait))
|
||||||
|
if err := s.conn.WriteMessage(websocket.TextMessage, b); err != nil {
|
||||||
|
log.Printf("[voice] 写控制消息失败 uid=%s: %v", s.uid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendAudio 下发一帧 TTS 音频(二进制帧)。并发安全。
|
||||||
|
func (s *voiceSession) sendAudio(pcm []byte) {
|
||||||
|
s.writeMu.Lock()
|
||||||
|
defer s.writeMu.Unlock()
|
||||||
|
_ = s.conn.SetWriteDeadline(time.Now().Add(voiceWriteWait))
|
||||||
|
if err := s.conn.WriteMessage(websocket.BinaryMessage, pcm); err != nil {
|
||||||
|
log.Printf("[voice] 写音频失败 uid=%s: %v", s.uid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run 是协议读循环:二进制帧=上行音频,文本帧=控制消息。
|
||||||
|
func (s *voiceSession) run() {
|
||||||
|
for {
|
||||||
|
mt, data, err := s.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return // 客户端断开 / 读错误
|
||||||
|
}
|
||||||
|
switch mt {
|
||||||
|
case websocket.BinaryMessage:
|
||||||
|
s.onAudio(data)
|
||||||
|
case websocket.TextMessage:
|
||||||
|
var m voice.ClientMsg
|
||||||
|
if json.Unmarshal(data, &m) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.onControl(m) {
|
||||||
|
return // bye
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onAudio 收到一帧上行音频 → 喂火山 ASR。
|
||||||
|
func (s *voiceSession) onAudio(pcm []byte) {
|
||||||
|
if s.asr == nil {
|
||||||
|
s.startASR() // 客户端没显式 start 就直接说话时,惰性开一路识别
|
||||||
|
}
|
||||||
|
if s.asr != nil {
|
||||||
|
if err := s.asr.PushAudio(pcm); err != nil {
|
||||||
|
log.Printf("[voice] 喂 ASR 音频失败 uid=%s: %v", s.uid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onControl 处理客户端控制消息,返回 true 表示会话应结束。
|
||||||
|
func (s *voiceSession) onControl(m voice.ClientMsg) (done bool) {
|
||||||
|
switch m.Type {
|
||||||
|
case voice.ClientBye:
|
||||||
|
return true
|
||||||
|
case voice.ClientStart:
|
||||||
|
s.pendingGraph = m.Graph // 客户端画布图(可空):本轮若有转写则语音触发它跑
|
||||||
|
s.turnMu.Lock()
|
||||||
|
s.latestText, s.submitted = "", false // 新一轮:清累计与提交标记
|
||||||
|
s.turnMu.Unlock()
|
||||||
|
s.stopASR()
|
||||||
|
s.startASR() // 新一轮:重开识别
|
||||||
|
case voice.ClientEnd:
|
||||||
|
if s.asr != nil {
|
||||||
|
_ = s.asr.Finish() // 告知火山本轮说完
|
||||||
|
}
|
||||||
|
// 火山流式 ASR 只在 VAD 静音时才发 Final;客户端显式 end(点停)时不能干等——
|
||||||
|
// 给一小段收尾时间让末尾部分结果到齐,再用"最新转写"兜底提交(trySubmit 去重,Final 先到就它先提交)。
|
||||||
|
// 500ms 是"够接住末尾 partial"与"别拖慢首字出声"的折中(此前 1.2s 白白吃掉一秒多时延)。
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
s.turnMu.Lock()
|
||||||
|
txt := s.latestText
|
||||||
|
s.turnMu.Unlock()
|
||||||
|
s.trySubmit(txt)
|
||||||
|
}()
|
||||||
|
case voice.ClientBargeIn:
|
||||||
|
s.stopTTS() // 打断:用户又开口,立刻掐掉正在朗读的 TTS
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// startASR 开一路火山流式识别,并起 goroutine 把转写实时回推客户端。
|
||||||
|
func (s *voiceSession) startASR() {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
asr, err := voice.StartASR(ctx, s.cfg, s.uid)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
log.Printf("[voice] 启动 ASR 失败 uid=%s: %v", s.uid, err)
|
||||||
|
s.send(voice.ServerMsg{Type: voice.ServerError, Msg: "语音识别启动失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.asr = asr
|
||||||
|
s.asrCancel = cancel
|
||||||
|
go func() {
|
||||||
|
for r := range asr.Results() {
|
||||||
|
if r.Err != nil {
|
||||||
|
return // 识别流结束/出错
|
||||||
|
}
|
||||||
|
s.send(voice.ServerMsg{Type: voice.ServerTranscript, Text: r.Text, Final: r.Final})
|
||||||
|
if t := strings.TrimSpace(r.Text); t != "" {
|
||||||
|
s.turnMu.Lock()
|
||||||
|
s.latestText = r.Text // 累计最新转写,供 ClientEnd 兜底提交
|
||||||
|
s.turnMu.Unlock()
|
||||||
|
}
|
||||||
|
if r.Final {
|
||||||
|
s.trySubmit(r.Text) // VAD 检出句末 → 直接提交(与 ClientEnd 兜底二选一,去重)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// trySubmit 本轮提交一次任务:Final 与 ClientEnd 兜底两条路径抢先,submitted 保证只落一次。
|
||||||
|
func (s *voiceSession) trySubmit(text string) {
|
||||||
|
txt := strings.TrimSpace(text)
|
||||||
|
s.turnMu.Lock()
|
||||||
|
if txt == "" || s.submitted {
|
||||||
|
s.turnMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.submitted = true
|
||||||
|
s.turnMu.Unlock()
|
||||||
|
|
||||||
|
taskID, err := s.submitVoiceTask(txt, s.pendingGraph)
|
||||||
|
if err != nil {
|
||||||
|
s.send(voice.ServerMsg{Type: voice.ServerError, Msg: "任务提交失败:" + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.pendingGraph = "" // 画布图一次性消费,避免后续转写重复触发同图
|
||||||
|
s.send(voice.ServerMsg{Type: voice.ServerTask, TaskID: taskID})
|
||||||
|
// 下行:订阅该任务 token 流 → 攒句 → TTS → 音频帧回推。独立 goroutine 跑,不堵 ASR 结果流。
|
||||||
|
go s.speak(taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopASR 收掉当前识别会话(幂等)。
|
||||||
|
func (s *voiceSession) stopASR() {
|
||||||
|
if s.asr != nil {
|
||||||
|
s.asr.Close()
|
||||||
|
s.asr = nil
|
||||||
|
}
|
||||||
|
if s.asrCancel != nil {
|
||||||
|
s.asrCancel()
|
||||||
|
s.asrCancel = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveJarvis 解析某用户**有效**的 JARVIS 设定:火山配置 + 助手名 + 人设。
|
||||||
|
// - 火山配置:用户自带豆包齐全(Enabled)→ 用用户的;否则回落系统配置(voice_config)。
|
||||||
|
// - 名字/人设:来自用户的 UserJarvis 记录(可空,空则代码里再兜默认);与火山配置相互独立
|
||||||
|
// (常见情形:多数用户不配自己的豆包、只改名字和人设)。
|
||||||
|
func (h *Handler) resolveJarvis(ctx context.Context, uid string) (cfg voice.Config, name, persona string) {
|
||||||
|
cfg = h.loadVoiceConfig(ctx) // 系统兜底
|
||||||
|
j := h.db.GetUserJarvis(ctx, uid)
|
||||||
|
if j == nil {
|
||||||
|
return cfg, "", ""
|
||||||
|
}
|
||||||
|
name, persona = j.Name, j.Persona
|
||||||
|
user := voice.Config{APIKey: j.APIKey, ASRResourceID: j.ASRResourceID, TTSResourceID: j.TTSResourceID, TTSVoiceType: j.TTSVoiceType}.DecryptFromStore()
|
||||||
|
if user.Enabled() { // 用户自带豆包齐全 → 整套用用户的(key 与 resource-id 必须同账号,不混用)
|
||||||
|
cfg = user
|
||||||
|
}
|
||||||
|
return cfg, name, persona
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetVoiceConfig: GET /api/v1/admin/voice —— 回显语音配置(api_key 明文,RequireAdmin 已拦)。
|
||||||
|
func (h *Handler) AdminGetVoiceConfig(c *gin.Context) {
|
||||||
|
cfg := h.loadVoiceConfig(c.Request.Context())
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"api_key": cfg.APIKey,
|
||||||
|
"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 —— 保存语音配置(api_key 空串=沿用已存)。
|
||||||
|
func (h *Handler) AdminSaveVoiceConfig(c *gin.Context) {
|
||||||
|
var b struct {
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
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()
|
||||||
|
key := strings.TrimSpace(b.APIKey)
|
||||||
|
if key == "" {
|
||||||
|
key = h.loadVoiceConfig(ctx).APIKey // 留空=沿用已存
|
||||||
|
}
|
||||||
|
cfg := voice.Config{
|
||||||
|
APIKey: key,
|
||||||
|
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()})
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/dsl"
|
||||||
|
"github.com/sundynix/sundynix-shared/contract"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 语音上行接线:最终转写 → 组 DSL → 复用 preflightCore/launchCore 关卡 → 提交任务 → 回 task_id。
|
||||||
|
// 语音只是"嘴替键盘",一行编排/工具/计费逻辑都不新造:走的正是 HTTP SubmitTask 那条关卡
|
||||||
|
// (见记忆 execution-single-entry「提交必走 preflight()+launch() 共用关卡」)。
|
||||||
|
|
||||||
|
// defaultJarvisName 是用户没自定义名字时的默认助手名。
|
||||||
|
const defaultJarvisName = "JARVIS"
|
||||||
|
|
||||||
|
// voiceSystemPrompt 组语音 agent 的系统提示:**简短**是硬基线(语音场景要抢首字、别让人干听十几秒),
|
||||||
|
// **名字与语气/人设由用户决定**——name 用户自定义(你叫 JARVIS、别人叫星期五都行),persona 是用户为
|
||||||
|
// 这个助手单设的语气人设(与主偏好记忆分开)。persona 为空则默认平和礼貌。
|
||||||
|
func voiceSystemPrompt(name, persona string) string {
|
||||||
|
n := strings.TrimSpace(name)
|
||||||
|
if n == "" {
|
||||||
|
n = defaultJarvisName
|
||||||
|
}
|
||||||
|
s := "你是 " + n + "——用户的私人语音助手。这是语音对话,务必简短:先直接给结论," +
|
||||||
|
"一两句话说清,通常不超过三句,别铺垫、别列清单、别念代码、别复述问题。口语化、自然。"
|
||||||
|
if p := strings.TrimSpace(persona); p != "" {
|
||||||
|
s += "\n你的语气与人设:" + p
|
||||||
|
} else {
|
||||||
|
s += "语气平和、礼貌。"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildVoiceGraph 把一句转写组成最简可执行图:input(转写) → agent(用户的 JARVIS,带其名字+人设)。
|
||||||
|
// 与前端画布 exportDsl 同构(kind=input/agent、config.text/system),dispatcher 直接吃。
|
||||||
|
func buildVoiceGraph(query, name, persona string) json.RawMessage {
|
||||||
|
g := map[string]any{
|
||||||
|
"version": "voice-1",
|
||||||
|
"nodes": []map[string]any{
|
||||||
|
{"id": "voice_in", "kind": "input", "config": map[string]any{"text": query}},
|
||||||
|
{"id": "voice_agent", "kind": "agent", "config": map[string]any{"system": voiceSystemPrompt(name, persona)}},
|
||||||
|
},
|
||||||
|
"edges": []map[string]any{
|
||||||
|
{"source": "voice_in", "target": "voice_agent"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(g)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// submitVoiceTask 提交一次语音任务。graphOverride 非空时用客户端画布图(语音触发既有编排),
|
||||||
|
// 否则用转写现组的单 agent 图。返回 task_id。
|
||||||
|
func (s *voiceSession) submitVoiceTask(transcript, graphOverride string) (string, error) {
|
||||||
|
transcript = strings.TrimSpace(transcript)
|
||||||
|
if transcript == "" && graphOverride == "" {
|
||||||
|
return "", fmt.Errorf("空转写")
|
||||||
|
}
|
||||||
|
ctx := context.Background() // WS 会话长生命周期,不绑单条请求 ctx
|
||||||
|
|
||||||
|
var raw json.RawMessage
|
||||||
|
if strings.TrimSpace(graphOverride) != "" {
|
||||||
|
raw = json.RawMessage(graphOverride) // 语音触发画布上的既有编排图
|
||||||
|
} else {
|
||||||
|
raw = buildVoiceGraph(transcript, s.jarvisName, s.jarvisPersona) // 带上用户的助手名+人设
|
||||||
|
}
|
||||||
|
task, err := dsl.ParseAndAssemble(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 共用关卡:预算 / 暂停 / 计费租户 / 积分硬拦截。被拦时把文案上抛(供语音播报/回传)。
|
||||||
|
billingTenant, block := s.h.preflightCore(ctx, s.uid, s.tenantID)
|
||||||
|
if block != nil {
|
||||||
|
return "", fmt.Errorf("%s", block.message())
|
||||||
|
}
|
||||||
|
task.Meta[contract.MetaUserID] = s.uid
|
||||||
|
task.Meta[contract.MetaTenantID] = billingTenant
|
||||||
|
task.Meta[contract.MetaSessionID] = s.sessionID
|
||||||
|
task.Meta[contract.MetaModelProfile] = contract.ModelProfileVoice // 语音任务走 JARVIS 快模型(未配则回落工作模型)
|
||||||
|
|
||||||
|
if err := s.h.launchCore(ctx, s.uid, task); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return task.ID, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/dsl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildVoiceGraph 的产物必须是 dsl.ParseAndAssemble 能吃下的合法图,且带上转写文本。
|
||||||
|
func TestBuildVoiceGraph_Valid(t *testing.T) {
|
||||||
|
const q = "帮我查一下明天上海的天气"
|
||||||
|
raw := buildVoiceGraph(q, "", "")
|
||||||
|
|
||||||
|
// 1) 能通过 DSL 解析与拓扑校验(与 HTTP SubmitTask 同一条解析)。
|
||||||
|
task, err := dsl.ParseAndAssemble(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("语音图未通过 DSL 校验: %v", err)
|
||||||
|
}
|
||||||
|
if task.ID == "" {
|
||||||
|
t.Fatal("task.ID 为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 图里带着转写文本(input 节点)与 JARVIS 系统提示(agent 节点)。
|
||||||
|
var g struct {
|
||||||
|
Nodes []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Config map[string]any `json:"config"`
|
||||||
|
} `json:"nodes"`
|
||||||
|
Edges []struct {
|
||||||
|
Source, Target string
|
||||||
|
} `json:"edges"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &g); err != nil {
|
||||||
|
t.Fatalf("反解语音图失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(g.Nodes) != 2 || len(g.Edges) != 1 {
|
||||||
|
t.Fatalf("期望 2 节点 1 边,得 %d 节点 %d 边", len(g.Nodes), len(g.Edges))
|
||||||
|
}
|
||||||
|
var gotInput, gotAgent bool
|
||||||
|
for _, n := range g.Nodes {
|
||||||
|
switch n.Kind {
|
||||||
|
case "input":
|
||||||
|
gotInput = true
|
||||||
|
if text, _ := n.Config["text"].(string); text != q {
|
||||||
|
t.Errorf("input.text=%q,期望 %q", text, q)
|
||||||
|
}
|
||||||
|
case "agent":
|
||||||
|
gotAgent = true
|
||||||
|
if sys, _ := n.Config["system"].(string); !strings.Contains(sys, "JARVIS") {
|
||||||
|
t.Errorf("agent.system 未含 JARVIS 提示: %q", sys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gotInput || !gotAgent {
|
||||||
|
t.Fatalf("缺 input(%v)/agent(%v) 节点", gotInput, gotAgent)
|
||||||
|
}
|
||||||
|
// 边必须连 input→agent(否则 compose 编译后 agent 收不到输入)。
|
||||||
|
if g.Edges[0].Source != "voice_in" || g.Edges[0].Target != "voice_agent" {
|
||||||
|
t.Errorf("边应为 voice_in→voice_agent,得 %s→%s", g.Edges[0].Source, g.Edges[0].Target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空转写不该组图触发(提交侧兜底:submitVoiceTask 空转写返错)——这里只校验组图函数对空串仍产出结构。
|
||||||
|
func TestBuildVoiceGraph_EmptyStillStructured(t *testing.T) {
|
||||||
|
raw := buildVoiceGraph("", "", "")
|
||||||
|
if _, err := dsl.ParseAndAssemble(raw); err != nil {
|
||||||
|
t.Fatalf("空转写图仍应结构合法: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户自定义名字 + 人设应注入到 agent 节点的 system 里(名字替 JARVIS、人设附上)。
|
||||||
|
func TestBuildVoiceGraph_NamePersonaInjected(t *testing.T) {
|
||||||
|
raw := buildVoiceGraph("你好", "星期五", "简洁专业不说脏话")
|
||||||
|
var g struct {
|
||||||
|
Nodes []struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Config map[string]any `json:"config"`
|
||||||
|
} `json:"nodes"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &g); err != nil {
|
||||||
|
t.Fatalf("反解失败: %v", err)
|
||||||
|
}
|
||||||
|
for _, n := range g.Nodes {
|
||||||
|
if n.Kind != "agent" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sys, _ := n.Config["system"].(string)
|
||||||
|
if !strings.Contains(sys, "星期五") {
|
||||||
|
t.Errorf("system 未含自定义名字「星期五」: %q", sys)
|
||||||
|
}
|
||||||
|
if strings.Contains(sys, "JARVIS") {
|
||||||
|
t.Errorf("有自定义名字时不应再出现 JARVIS: %q", sys)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sys, "简洁专业不说脏话") {
|
||||||
|
t.Errorf("system 未含用户人设: %q", sys)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatal("没找到 agent 节点")
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 语音下行接线:任务 token 流 → 攒句器 → 火山双向 TTS → 音频帧回推客户端。
|
||||||
|
// 复用既有 token 流(bus.SubscribeTokens,与 SSE/录像器同一路 fan-out),一行编排不改:
|
||||||
|
// 语音只是给回答"配了个嘴"。
|
||||||
|
|
||||||
|
// speak 为一次任务的回答做流式朗读。独立 goroutine 调用(onFinalTranscript 里 go 起)。
|
||||||
|
//
|
||||||
|
// 关键次序:**先订阅 token 流,再建 TTS 会话**。core NATS 无持久化、订阅晚于产出就丢开头 token,
|
||||||
|
// 而 TTS 握手(StartConnection→StartSession 两个往返)要几百毫秒——这期间攒下的句子先入 pending,
|
||||||
|
// TTS 就绪后补吐,保证第一句不丢。
|
||||||
|
func (s *voiceSession) speak(taskID string) {
|
||||||
|
if !s.cfg.TTSEnabled() {
|
||||||
|
return // 没配 TTS:只回转写 + 任务,无语音朗读
|
||||||
|
}
|
||||||
|
|
||||||
|
sb := voice.NewSentenceBuffer()
|
||||||
|
var pending []string // TTS 未就绪前攒下的句子
|
||||||
|
ready := false
|
||||||
|
finished := false
|
||||||
|
|
||||||
|
// push 把一句吐给 TTS;未就绪则先入 pending。全程在 ttsMu 下,与就绪补吐/打断互斥。
|
||||||
|
push := func(sentence string) {
|
||||||
|
s.ttsMu.Lock()
|
||||||
|
if ready && s.tts != nil {
|
||||||
|
_ = s.tts.Speak(sentence)
|
||||||
|
} else {
|
||||||
|
pending = append(pending, sentence)
|
||||||
|
}
|
||||||
|
s.ttsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
unsub, err := s.h.bus.SubscribeTokens(taskID,
|
||||||
|
func(tok []byte) {
|
||||||
|
// 打字机:每个 token 一到就转发给客户端显示(早于音频,LLM 首 token 即刻反馈)。
|
||||||
|
s.send(voice.ServerMsg{Type: voice.ServerReply, Text: string(tok)})
|
||||||
|
// 同时攒句喂 TTS(成句即合成,音频随后跟上)。
|
||||||
|
for _, sentence := range sb.Push(string(tok)) {
|
||||||
|
push(sentence)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
func() {
|
||||||
|
if tail := sb.Flush(); tail != "" {
|
||||||
|
push(tail)
|
||||||
|
}
|
||||||
|
s.ttsMu.Lock()
|
||||||
|
finished = true
|
||||||
|
if ready && s.tts != nil {
|
||||||
|
_ = s.tts.Finish() // 文字推完,等服务端吐完剩余音频
|
||||||
|
}
|
||||||
|
s.ttsMu.Unlock()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[voice] 订阅 token 流失败 task=%s: %v", taskID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 建 TTS 会话(含握手)。失败也要给客户端一个 tts_end,别让它干等。
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
ts, err := voice.StartTTS(ctx, s.cfg)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
_ = unsub()
|
||||||
|
log.Printf("[voice] 启动 TTS 失败 uid=%s: %v", s.uid, err)
|
||||||
|
s.send(voice.ServerMsg{Type: voice.ServerError, Msg: "语音合成启动失败"})
|
||||||
|
s.send(voice.ServerMsg{Type: voice.ServerTTSEnd})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 就绪:登记会话、补吐 pending,若 token 流已结束则立刻收尾。
|
||||||
|
s.ttsMu.Lock()
|
||||||
|
s.tts = ts
|
||||||
|
s.ttsCancel = cancel
|
||||||
|
ready = true
|
||||||
|
for _, sentence := range pending {
|
||||||
|
_ = ts.Speak(sentence)
|
||||||
|
}
|
||||||
|
pending = nil
|
||||||
|
if finished {
|
||||||
|
_ = ts.Finish()
|
||||||
|
}
|
||||||
|
s.ttsMu.Unlock()
|
||||||
|
|
||||||
|
// 音频泵:TTS 音频帧 → 客户端。首帧发 speaking,channel 关闭(收尾/打断/出错)后发 tts_end。
|
||||||
|
first := true
|
||||||
|
for pcm := range ts.Audio() {
|
||||||
|
if first {
|
||||||
|
s.send(voice.ServerMsg{Type: voice.ServerSpeaking})
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
s.sendAudio(pcm)
|
||||||
|
}
|
||||||
|
if e := ts.Err(); e != nil {
|
||||||
|
log.Printf("[voice] TTS 出错 uid=%s: %v", s.uid, e)
|
||||||
|
}
|
||||||
|
s.send(voice.ServerMsg{Type: voice.ServerTTSEnd})
|
||||||
|
|
||||||
|
_ = unsub()
|
||||||
|
s.ttsMu.Lock()
|
||||||
|
if s.tts == ts { // 未被打断替换才清(打断已置空并 Close)
|
||||||
|
s.tts = nil
|
||||||
|
s.ttsCancel = nil
|
||||||
|
}
|
||||||
|
s.ttsMu.Unlock()
|
||||||
|
ts.Close()
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopTTS 掐掉当前下行 TTS(打断 / 会话结束)。幂等。Close 后 Audio 关闭 → 音频泵自然收尾。
|
||||||
|
func (s *voiceSession) stopTTS() {
|
||||||
|
s.ttsMu.Lock()
|
||||||
|
ts, cancel := s.tts, s.ttsCancel
|
||||||
|
s.tts, s.ttsCancel = nil, nil
|
||||||
|
s.ttsMu.Unlock()
|
||||||
|
if ts != nil {
|
||||||
|
ts.Close()
|
||||||
|
}
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -156,26 +157,83 @@ func (h *Handler) WxMPEvent(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
body, _ := io.ReadAll(c.Request.Body)
|
body, _ := io.ReadAll(c.Request.Body)
|
||||||
ev, err := wechat.ParseEvent(body)
|
ev, err := wechat.ParseEvent(body)
|
||||||
if err != nil || !ev.IsLoginScan() {
|
if err != nil {
|
||||||
c.String(http.StatusOK, "success") // 非登录扫码事件忽略,照常回执
|
c.String(http.StatusOK, "success")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 邀请扫码:scene 带 inv_ 前缀 → 找/建用户 + 入组 + 被动回复回执。独立于 PC 登录,
|
||||||
|
// 且不需要 access_token(回执走被动回复)。放在最前,避免被登录/欢迎逻辑抢先。
|
||||||
|
if scene := ev.Scene(); strings.HasPrefix(scene, inviteScenePrefix) {
|
||||||
|
c.Header("Content-Type", "application/xml")
|
||||||
|
c.String(http.StatusOK, h.handleInviteScan(ctx, ev, strings.TrimPrefix(scene, inviteScenePrefix)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录扫码(关注扫码 / 已关注再扫)→ 授权对应 ticket,让 PC 端轮询登录。
|
||||||
|
if ev.IsLoginScan() {
|
||||||
|
h.authorizeWxLogin(ctx, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新关注 → 被动回复欢迎语(覆盖「扫登录码后关注」和「搜索直接关注」两种入口)。
|
||||||
|
// 被动回复无需 access_token、不受 IP 白名单限制,回执 XML 即到达用户。
|
||||||
|
if ev.IsSubscribe() {
|
||||||
|
welcome := strings.TrimSpace(cfg.Welcome)
|
||||||
|
if welcome == "" {
|
||||||
|
welcome = wechat.DefaultWelcome
|
||||||
|
}
|
||||||
|
reply := wechat.BuildTextReply(ev.FromUserName, ev.ToUserName, welcome, time.Now().Unix())
|
||||||
|
c.Header("Content-Type", "application/xml")
|
||||||
|
c.String(http.StatusOK, reply)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.String(http.StatusOK, "success")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInviteScan 处理邀请扫码:按 openid 找/建用户 → 兑换邀请令牌入组 → 返回被动回复 XML。
|
||||||
|
// 回执走被动回复(无需 access_token),无论成败都要回一段合法响应体给微信。
|
||||||
|
func (h *Handler) handleInviteScan(ctx context.Context, ev *wechat.Event, token string) string {
|
||||||
|
openID := ev.FromUserName
|
||||||
|
u, err := h.db.GetUserByWechatOpenID(ctx, openID)
|
||||||
|
if err != nil {
|
||||||
|
return "success"
|
||||||
|
}
|
||||||
|
if u == nil { // 新用户:建号 + 个人租户(与登录一致),随后再入被邀请的租户
|
||||||
|
u, err = h.db.CreateWechatUser(ctx, openID, wechatNickname(openID))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[wxinvite] 建微信用户失败 openid=%s: %v", openID, err)
|
||||||
|
return "success"
|
||||||
|
}
|
||||||
|
if _, e := h.db.EnsureDefaultTenant(ctx, u.ID, "我的空间"); e != nil {
|
||||||
|
log.Printf("[wxinvite] 建默认租户失败 uid=%s: %v", u.ID, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name, ok := h.db.RedeemInvite(ctx, token, u.ID)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if !ok {
|
||||||
|
return wechat.BuildTextReply(ev.FromUserName, ev.ToUserName,
|
||||||
|
"邀请链接已失效或人数已满,请向邀请人重新获取。", now)
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("✅ 已加入团队【%s】\n在桌面端用微信扫码登录即可共享团队积分。", name)
|
||||||
|
return wechat.BuildTextReply(ev.FromUserName, ev.ToUserName, msg, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorizeWxLogin 处理登录扫码事件:按 openid 找/建用户并置 ticket 已授权。
|
||||||
|
// 只做副作用(Redis/DB),不写 HTTP 响应——响应由调用方按事件类型统一决定。
|
||||||
|
func (h *Handler) authorizeWxLogin(ctx context.Context, ev *wechat.Event) {
|
||||||
ticket, openID := ev.Scene(), ev.FromUserName
|
ticket, openID := ev.Scene(), ev.FromUserName
|
||||||
if h.cache.WxTicketGet(ctx, ticket) == "" { // ticket 必须仍有效
|
if h.cache.WxTicketGet(ctx, ticket) == "" { // ticket 必须仍有效
|
||||||
c.String(http.StatusOK, "success")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u, err := h.db.GetUserByWechatOpenID(ctx, openID)
|
u, err := h.db.GetUserByWechatOpenID(ctx, openID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusOK, "success")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if u == nil {
|
if u == nil {
|
||||||
u, err = h.db.CreateWechatUser(ctx, openID, wechatNickname(openID))
|
u, err = h.db.CreateWechatUser(ctx, openID, wechatNickname(openID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", openID, err)
|
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", openID, err)
|
||||||
c.String(http.StatusOK, "success")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, e := h.db.EnsureDefaultTenant(ctx, u.ID, "我的空间"); e != nil {
|
if _, e := h.db.EnsureDefaultTenant(ctx, u.ID, "我的空间"); e != nil {
|
||||||
@@ -184,7 +242,6 @@ func (h *Handler) WxMPEvent(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
st, _ := json.Marshal(wxTicketState{Status: "authorized", UserID: u.ID})
|
st, _ := json.Marshal(wxTicketState{Status: "authorized", UserID: u.ID})
|
||||||
_ = h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL)
|
_ = h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL)
|
||||||
c.String(http.StatusOK, "success")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WxMPPoll: GET /api/v1/wx/mp/poll?t=<ticket>
|
// WxMPPoll: GET /api/v1/wx/mp/poll?t=<ticket>
|
||||||
@@ -230,6 +287,7 @@ func (h *Handler) AdminGetWechatMP(c *gin.Context) {
|
|||||||
"appid": cfg.AppID,
|
"appid": cfg.AppID,
|
||||||
"token": cfg.Token,
|
"token": cfg.Token,
|
||||||
"app_secret": cfg.AppSecret,
|
"app_secret": cfg.AppSecret,
|
||||||
|
"welcome": cfg.Welcome,
|
||||||
"enabled": cfg.Enabled(),
|
"enabled": cfg.Enabled(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -239,6 +297,7 @@ func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
|
|||||||
AppID string `json:"appid"`
|
AppID string `json:"appid"`
|
||||||
AppSecret string `json:"app_secret"`
|
AppSecret string `json:"app_secret"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
|
Welcome string `json:"welcome"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&b); err != nil {
|
if err := c.ShouldBindJSON(&b); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
@@ -249,7 +308,7 @@ func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
|
|||||||
if secret == "" {
|
if secret == "" {
|
||||||
secret = h.loadWechatMP(ctx).AppSecret
|
secret = h.loadWechatMP(ctx).AppSecret
|
||||||
}
|
}
|
||||||
cfg := wechat.Config{AppID: strings.TrimSpace(b.AppID), AppSecret: secret, Token: strings.TrimSpace(b.Token)}
|
cfg := wechat.Config{AppID: strings.TrimSpace(b.AppID), AppSecret: secret, Token: strings.TrimSpace(b.Token), Welcome: strings.TrimSpace(b.Welcome)}
|
||||||
stored, err := cfg.EncryptedForStore()
|
stored, err := cfg.EncryptedForStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密钥加密失败: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "密钥加密失败: " + err.Error()})
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/wechat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 支付到账后给下单人推一条微信客服消息(回执)。
|
||||||
|
//
|
||||||
|
// 为什么只做「支付回执」不做「周期刷新提醒」:客服消息只能在用户 48h 内与公众号
|
||||||
|
// 互动过时下发。支付回执时用户刚扫码付完款,稳在窗口内;而订阅的周期刷新由定时器
|
||||||
|
// 触发,那一刻用户多半早已超出 48h → 客服消息必然失败。隔天的提醒须用模板消息(暂缓)。
|
||||||
|
|
||||||
|
const wxNotifyTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// notifyOrderPaid 尽力而为地给下单人推支付回执:异步、超时隔离,任何失败只记日志,
|
||||||
|
// 绝不影响入账主流程(钱已收、账已记,通知发不发都不能回滚)。
|
||||||
|
// 仅在 MarkOrderPaid 返回 changed=true(首次到账)时调用,避免重复回调重复推送。
|
||||||
|
func (h *Handler) notifyOrderPaid(orderID string) {
|
||||||
|
safeGo("wx-notify-order-paid", func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), wxNotifyTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
o, err := h.db.GetOrder(ctx, orderID)
|
||||||
|
if err != nil || o == nil || o.UserID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := h.db.GetUserByID(ctx, o.UserID)
|
||||||
|
if err != nil || u == nil || u.WechatOpenID == "" {
|
||||||
|
return // 非微信用户(邮箱注册等)无从推送,静默跳过
|
||||||
|
}
|
||||||
|
cfg := h.loadWechatMP(ctx)
|
||||||
|
if !cfg.Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := h.accessToken(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[wxnotify] 取 access_token 失败 order=%s: %v", orderID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := wechat.SendCustomText(ctx, token, u.WechatOpenID, h.composePaidMessage(ctx, o)); err != nil {
|
||||||
|
log.Printf("[wxnotify] 支付回执推送失败 order=%s: %v", orderID, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// composePaidMessage 按订单类型拼回执文案,并带上当前积分余额。
|
||||||
|
func (h *Handler) composePaidMessage(ctx context.Context, o *store.PaymentOrder) string {
|
||||||
|
bal := credits(h.db.TenantBalance(ctx, o.TenantID))
|
||||||
|
if o.Kind == store.OrderKindSub {
|
||||||
|
name := "订阅"
|
||||||
|
if pl := h.db.GetSubPlan(ctx, o.PlanID); pl != nil && pl.Name != "" {
|
||||||
|
name = pl.Name
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("✅ 订阅开通成功\n套餐:%s\n积分将在有效期内按周期自动发放。\n当前余额:%s 积分", name, bal)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("✅ 充值成功\n本次到账:%s 积分\n当前余额:%s 积分", credits(o.CreditsMicro), bal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// credits 把 micro 积分格式化成人类可读数(去掉多余小数)。
|
||||||
|
func credits(micro int64) string {
|
||||||
|
const unit = 1_000_000
|
||||||
|
if micro%unit == 0 {
|
||||||
|
return fmt.Sprintf("%d", micro/unit)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f", float64(micro)/float64(unit))
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// 回执里的积分数要给人看:整除去小数、非整留两位。
|
||||||
|
func TestCreditsFormat(t *testing.T) {
|
||||||
|
cases := map[int64]string{
|
||||||
|
0: "0",
|
||||||
|
1_000_000: "1",
|
||||||
|
5_000_000: "5",
|
||||||
|
1_500_000: "1.50",
|
||||||
|
2_340_000: "2.34",
|
||||||
|
}
|
||||||
|
for micro, want := range cases {
|
||||||
|
if got := credits(micro); got != want {
|
||||||
|
t.Fatalf("credits(%d)=%q want %q", micro, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthFromHeaderOrQuery 是给「公开 by-id」端点(SSE 流 / 报告下载)用的强制鉴权。
|
||||||
|
// 这些端点由 EventSource / <a download> 发起,**带不了 Authorization 头**——此前只靠随机
|
||||||
|
// task_id 寻址、无 authz、无租户过滤(capability-URL,泄露即无第二道门)。
|
||||||
|
// 本中间件先读 Bearer 头、没有再读 `?token=` / `?access_token=` 查询参数(EventSource 能带 query),
|
||||||
|
// 校验通过注入 CtxUserID(同 Auth),失败即 401。归属校验由各 handler 用 uid 再做(见 requireTaskOwner)。
|
||||||
|
func AuthFromHeaderOrQuery() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
tok := ""
|
||||||
|
if h := c.GetHeader("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||||
|
tok = strings.TrimSpace(h[len("Bearer "):])
|
||||||
|
}
|
||||||
|
if tok == "" {
|
||||||
|
tok = c.Query("token")
|
||||||
|
}
|
||||||
|
if tok == "" {
|
||||||
|
tok = c.Query("access_token")
|
||||||
|
}
|
||||||
|
uid, err := auth.Parse(tok)
|
||||||
|
if err != nil || uid == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "需要登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set(CtxUserID, uid)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,9 +71,9 @@ func recordGuardrail(c *gin.Context, db *store.Postgres, kind, reason string, si
|
|||||||
// RateLimit 基于 Redis 的会话级限流(每分钟上限)。
|
// RateLimit 基于 Redis 的会话级限流(每分钟上限)。
|
||||||
// 限流键:**已认证用户优先按 uid,未认证按客户端 IP** —— 企业网多人共享出口 IP 不再互相拖累,
|
// 限流键:**已认证用户优先按 uid,未认证按客户端 IP** —— 企业网多人共享出口 IP 不再互相拖累,
|
||||||
// 单用户换 IP 也绕不过。须挂在 Auth 之后(否则取不到 uid)。上限经 RATE_LIMIT_PER_MIN 配置
|
// 单用户换 IP 也绕不过。须挂在 Auth 之后(否则取不到 uid)。上限经 RATE_LIMIT_PER_MIN 配置
|
||||||
// (缺省 120);压测可调高。Redis 降级时始终放行,不阻断业务。
|
// (缺省 120);压测可调高。**Redis 降级时回落进程内兜底限流(fail-safe),不再完全放行。**
|
||||||
func RateLimit(cache *store.Redis) gin.HandlerFunc {
|
func RateLimit(cache *store.Redis) gin.HandlerFunc {
|
||||||
perMinute := int64(envInt("RATE_LIMIT_PER_MIN", 120))
|
perMinute := envInt("RATE_LIMIT_PER_MIN", 120)
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
key := "ip:" + c.ClientIP()
|
key := "ip:" + c.ClientIP()
|
||||||
if v, ok := c.Get(CtxUserID); ok {
|
if v, ok := c.Get(CtxUserID); ok {
|
||||||
@@ -81,8 +81,7 @@ func RateLimit(cache *store.Redis) gin.HandlerFunc {
|
|||||||
key = "u:" + uid
|
key = "u:" + uid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ok, _ := cache.Allow(c.Request.Context(), key, perMinute, time.Minute)
|
if !allowWithFallback(cache, c.Request.Context(), key, perMinute) {
|
||||||
if !ok {
|
|
||||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
|
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 限流的 fail-safe 兜底。Redis 是限流的主后端,但它一挂(或降级)时,此前 `Allow` 直接放行
|
||||||
|
// = fail-open:爆破/洪泛防护随 Redis 一起消失。这里加一个**进程内**固定窗口兜底限流,让
|
||||||
|
// Redis 不可用时仍有每实例的宽松限流(fail-safe),不牺牲整体可用性(本地限流很快、无外部依赖)。
|
||||||
|
|
||||||
|
// procLimiter 是进程内固定窗口计数器(每实例独立,不追求精确——兜底而已)。
|
||||||
|
type procLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
counts map[string]*winCount
|
||||||
|
window time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type winCount struct {
|
||||||
|
n int
|
||||||
|
reset time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProcLimiter(window time.Duration) *procLimiter {
|
||||||
|
return &procLimiter{counts: make(map[string]*winCount), window: window}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow 固定窗口内对 key 累加,超 limit 拒绝。顺带惰性清理过期项防 map 无界增长。
|
||||||
|
func (l *procLimiter) allow(key string, limit int, now time.Time) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if len(l.counts) > 10000 {
|
||||||
|
for k, wc := range l.counts {
|
||||||
|
if now.After(wc.reset) {
|
||||||
|
delete(l.counts, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wc := l.counts[key]
|
||||||
|
if wc == nil || now.After(wc.reset) {
|
||||||
|
l.counts[key] = &winCount{n: 1, reset: now.Add(l.window)}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
wc.n++
|
||||||
|
return wc.n <= limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局兜底器(1 分钟窗口,与 Redis 限流同窗口口径)。
|
||||||
|
var fallbackLimiter = newProcLimiter(time.Minute)
|
||||||
|
|
||||||
|
// allowWithFallback 优先用 Redis 限流;Redis 降级/故障(!Enabled 或 Allow 出错)时回落进程内兜底。
|
||||||
|
// 返回 true=放行。这是把 fail-open 改成 fail-safe 的关键接缝。
|
||||||
|
func allowWithFallback(cache *store.Redis, ctx context.Context, key string, limit int) bool {
|
||||||
|
ok, err := cache.Allow(ctx, key, int64(limit), time.Minute)
|
||||||
|
if !cache.Enabled() || err != nil {
|
||||||
|
return fallbackLimiter.allow(key, limit, time.Now())
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimitN 对某类端点施加更严的独立限流(按 IP + prefix 分桶),用于登录/注册等爆破面大的
|
||||||
|
// 公开端点。与全局 RateLimit 叠加(两道桶都过才放行)。Redis 故障时走进程内兜底。
|
||||||
|
func RateLimitN(cache *store.Redis, perMinute int, prefix string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
key := prefix + ":" + c.ClientIP()
|
||||||
|
if !allowWithFallback(cache, c.Request.Context(), key, perMinute) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A5:进程内兜底限流——固定窗口内超限即拒,过窗后恢复。Redis 挂时靠它 fail-safe。
|
||||||
|
func TestProcLimiter(t *testing.T) {
|
||||||
|
l := newProcLimiter(time.Minute)
|
||||||
|
now := time.Now()
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if !l.allow("k", 3, now) {
|
||||||
|
t.Fatalf("前 3 次应放行(第 %d 次被拒)", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.allow("k", 3, now) {
|
||||||
|
t.Fatal("超限第 4 次应拒")
|
||||||
|
}
|
||||||
|
if !l.allow("k", 3, now.Add(2*time.Minute)) {
|
||||||
|
t.Fatal("新窗口应恢复放行")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A6:AuthFromHeaderOrQuery——?token= 有效则注入 uid、无 token 则 401。
|
||||||
|
func TestAuthFromHeaderOrQuery(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
tok, err := auth.Issue("u1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("签发失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest("GET", "/x?token="+tok, nil)
|
||||||
|
AuthFromHeaderOrQuery()(c)
|
||||||
|
if c.IsAborted() {
|
||||||
|
t.Fatal("有效 query token 不该被拦")
|
||||||
|
}
|
||||||
|
if v, _ := c.Get(CtxUserID); v != "u1" {
|
||||||
|
t.Fatalf("应注入 uid=u1,得 %v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
c2, _ := gin.CreateTestContext(w2)
|
||||||
|
c2.Request = httptest.NewRequest("GET", "/x", nil)
|
||||||
|
AuthFromHeaderOrQuery()(c2)
|
||||||
|
if !c2.IsAborted() || w2.Code != 401 {
|
||||||
|
t.Fatalf("无 token 应 401,得 aborted=%v code=%d", c2.IsAborted(), w2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,11 @@ func MustConnect(url string) *Bus {
|
|||||||
return &Bus{inner: inner}
|
return &Bus{inner: inner}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClusterStatus 透传共享 bus 的 NATS 集群体检(供「服务状态」监测面板)。
|
||||||
|
func (b *Bus) ClusterStatus(ctx context.Context) sharedbus.NATSClusterStatus {
|
||||||
|
return b.inner.ClusterStatus(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// PublishTask 把组装后的 Task 发布到 sundynix.tasks.<id>。
|
// PublishTask 把组装后的 Task 发布到 sundynix.tasks.<id>。
|
||||||
func (b *Bus) PublishTask(ctx context.Context, t *contract.Task) error {
|
func (b *Bus) PublishTask(ctx context.Context, t *contract.Task) error {
|
||||||
seq, err := b.inner.PublishTask(ctx, t)
|
seq, err := b.inner.PublishTask(ctx, t)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||||
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
|
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
|
||||||
@@ -33,6 +34,12 @@ type Wechat struct {
|
|||||||
pubKeyID string
|
pubKeyID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wechatAPITimeout 限制单次微信 API 往返。微信是外部第三方依赖、最可能慢/挂,
|
||||||
|
// SDK 默认 http.Client 无 Timeout:一次卡住的 Prepay/QueryOrder 会拖住请求 goroutine,
|
||||||
|
// 尤其掉单补偿定时器用的是 context.Background()(无超时)→ 微信一挂那轮 tick 无限期卡死。
|
||||||
|
// 双保险:客户端级 HTTP 超时(belt)+ 每次调用的 ctx 超时(suspenders,兜住 SDK 忽略/背景 ctx)。
|
||||||
|
const wechatAPITimeout = 15 * time.Second
|
||||||
|
|
||||||
// 编译期断言:Wechat 实现 Channel 接口。
|
// 编译期断言:Wechat 实现 Channel 接口。
|
||||||
var _ Channel = (*Wechat)(nil)
|
var _ Channel = (*Wechat)(nil)
|
||||||
|
|
||||||
@@ -52,7 +59,8 @@ func New(ctx context.Context, c Config) (*Wechat, error) {
|
|||||||
return nil, fmt.Errorf("微信支付公钥加载失败(%s): %w", c.PublicKeyPath, err)
|
return nil, fmt.Errorf("微信支付公钥加载失败(%s): %w", c.PublicKeyPath, err)
|
||||||
}
|
}
|
||||||
client, err := core.NewClient(ctx,
|
client, err := core.NewClient(ctx,
|
||||||
option.WithWechatPayPublicKeyAuthCipher(c.MchID, c.CertSerial, priv, c.PublicKeyID, pub))
|
option.WithWechatPayPublicKeyAuthCipher(c.MchID, c.CertSerial, priv, c.PublicKeyID, pub),
|
||||||
|
option.WithHTTPClient(&http.Client{Timeout: wechatAPITimeout})) // 客户端级超时兜底
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("客户端初始化失败: %w", err)
|
return nil, fmt.Errorf("客户端初始化失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -65,6 +73,8 @@ func New(ctx context.Context, c Config) (*Wechat, error) {
|
|||||||
|
|
||||||
// CreatePay Native 下单:返回 code_url(前端渲染成二维码)。金额取订单锁定值。
|
// CreatePay Native 下单:返回 code_url(前端渲染成二维码)。金额取订单锁定值。
|
||||||
func (w *Wechat) CreatePay(ctx context.Context, orderID, description string, amountFen int64) (PayIntent, error) {
|
func (w *Wechat) CreatePay(ctx context.Context, orderID, description string, amountFen int64) (PayIntent, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, wechatAPITimeout)
|
||||||
|
defer cancel()
|
||||||
resp, _, err := w.svc.Prepay(ctx, native.PrepayRequest{
|
resp, _, err := w.svc.Prepay(ctx, native.PrepayRequest{
|
||||||
Appid: core.String(w.appID),
|
Appid: core.String(w.appID),
|
||||||
Mchid: core.String(w.mchID),
|
Mchid: core.String(w.mchID),
|
||||||
@@ -108,6 +118,8 @@ func fromTransaction(t *payments.Transaction) PayResult {
|
|||||||
|
|
||||||
// QueryOrder 主动查单(本地开发确认到账、生产掉单补偿共用)。
|
// QueryOrder 主动查单(本地开发确认到账、生产掉单补偿共用)。
|
||||||
func (w *Wechat) QueryOrder(ctx context.Context, orderID string) (PayResult, error) {
|
func (w *Wechat) QueryOrder(ctx context.Context, orderID string) (PayResult, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, wechatAPITimeout)
|
||||||
|
defer cancel()
|
||||||
t, _, err := w.svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
|
t, _, err := w.svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
|
||||||
OutTradeNo: core.String(orderID),
|
OutTradeNo: core.String(orderID),
|
||||||
Mchid: core.String(w.mchID),
|
Mchid: core.String(w.mchID),
|
||||||
|
|||||||
@@ -59,23 +59,27 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
// 微信扫码登录(全公开:ticket 是唯一凭证;PC 建票与轮询都无鉴权头)
|
// 微信扫码登录(全公开:ticket 是唯一凭证;PC 建票与轮询都无鉴权头)
|
||||||
api.POST("/wx/mp/ticket", h.WxMPTicket) // PC 建票 + 微信二维码
|
api.POST("/wx/mp/ticket", h.WxMPTicket) // PC 建票 + 微信二维码
|
||||||
api.GET("/wx/mp/poll", h.WxMPPoll) // PC 轮询登录态
|
api.GET("/wx/mp/poll", h.WxMPPoll) // PC 轮询登录态
|
||||||
api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
|
// 登录/注册爆破面大:在全局限流之上再叠一道更严的按 IP 专用限流(10/min)。
|
||||||
api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
|
api.POST("/auth/register", middleware.RateLimitN(cache, 10, "auth-reg"), h.Register) // 注册 + 签发 JWT
|
||||||
|
api.POST("/auth/login", middleware.RateLimitN(cache, 10, "auth-login"), h.Login) // 登录 + 签发 JWT
|
||||||
api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401)
|
api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401)
|
||||||
api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
|
api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
|
||||||
api.GET("/tasks/:id/stream", h.StreamTask) // SSE 回流 Token Stream(task_id 寻址)
|
// 这些 by-id 端点带不了 Bearer 头(EventSource/下载),改由 AuthFromHeaderOrQuery
|
||||||
api.GET("/tasks/:id/exec", h.StreamExec) // SSE 回流执行轨迹(task_id 寻址)
|
// 从 ?token= 取 JWT 强制鉴权;task/report 三个再按 owner 归属校验(handler 内)。
|
||||||
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(job_id 寻址)
|
api.GET("/tasks/:id/stream", middleware.AuthFromHeaderOrQuery(), h.StreamTask) // SSE 回流 Token Stream
|
||||||
api.GET("/reports/:id/export", h.ExportReport) // 按需导出(report_id 寻址)
|
api.GET("/tasks/:id/exec", middleware.AuthFromHeaderOrQuery(), h.StreamExec) // SSE 回流执行轨迹
|
||||||
api.GET("/reports/:id/download", h.ExportReport) // 兼容旧入口(默认 docx)
|
api.GET("/kb/ingest/:id/stream", middleware.AuthFromHeaderOrQuery(), h.KbIngestStream) // 入库进度 SSE(登录即可,进度非敏感)
|
||||||
api.POST("/billing/callback/:channel", h.PaymentCallback) // 支付回调(渠道服务器带不了 Bearer;渠道验签是唯一的门)
|
api.GET("/reports/:id/export", middleware.AuthFromHeaderOrQuery(), h.ExportReport) // 按需导出
|
||||||
|
api.GET("/reports/:id/download", middleware.AuthFromHeaderOrQuery(), h.ExportReport) // 兼容旧入口(默认 docx)
|
||||||
|
api.POST("/billing/callback/:channel", h.PaymentCallback) // 支付回调(渠道服务器带不了 Bearer;渠道验签是唯一的门)
|
||||||
|
api.GET("/voice/stream", middleware.AuthFromHeaderOrQuery(), h.VoiceStream) // 语音会话 WebSocket(?token= 鉴权,WS 带不了 Bearer 头)
|
||||||
|
|
||||||
// —— 受保护:owner 作用域业务,必须携带有效 JWT ——
|
// —— 受保护:owner 作用域业务,必须携带有效 JWT ——
|
||||||
p := api.Group("", middleware.RequireAuth())
|
p := api.Group("", middleware.RequireAuth())
|
||||||
{
|
{
|
||||||
p.POST("/tasks", middleware.RequireTenantRole(db, store.RoleMember), h.SubmitTask) // 提交任务(烧租户积分):viewer 只读拦下
|
p.POST("/tasks", middleware.RequireTenantRole(db, store.RoleMember), h.SubmitTask) // 提交任务(烧租户积分):viewer 只读拦下
|
||||||
p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout/waiting/rejected)
|
p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout/waiting/rejected)
|
||||||
p.POST("/tasks/:id/approve", middleware.Audit(db), h.ApproveTask) // HITL 人工审批决定(批准/拒绝,审计)
|
p.POST("/tasks/:id/approve", middleware.RequireTenantRole(db, store.RoleMember), middleware.Audit(db), h.ApproveTask) // HITL 人工审批:≥member(放行会烧租户积分,viewer 拦下),审计
|
||||||
p.GET("/tenants/current", h.TenantCurrent) // 当前租户上下文 + 角色 + 可花余额(多租户)
|
p.GET("/tenants/current", h.TenantCurrent) // 当前租户上下文 + 角色 + 可花余额(多租户)
|
||||||
p.GET("/me/tenants", h.MyTenantsList) // 我所属租户(供切换)
|
p.GET("/me/tenants", h.MyTenantsList) // 我所属租户(供切换)
|
||||||
p.POST("/me/tenants", h.CreateMyTenant) // 自助建组织(创建者即 owner,建完切入)
|
p.POST("/me/tenants", h.CreateMyTenant) // 自助建组织(创建者即 owner,建完切入)
|
||||||
@@ -86,7 +90,13 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
p.POST("/tenants/current/members", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantAddMember)
|
p.POST("/tenants/current/members", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantAddMember)
|
||||||
p.PUT("/tenants/current/members/:uid", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantSetMemberRole)
|
p.PUT("/tenants/current/members/:uid", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantSetMemberRole)
|
||||||
p.DELETE("/tenants/current/members/:uid", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantRemoveMember)
|
p.DELETE("/tenants/current/members/:uid", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantRemoveMember)
|
||||||
|
// 二维码邀请(可复用团队码):看 ≥viewer,建/撤销 ≥admin + 审计。扫码入组走 /wx/mp/callback。
|
||||||
|
p.GET("/tenants/current/invites", middleware.RequireTenantRole(db, store.RoleViewer), h.ListTenantInvites)
|
||||||
|
p.POST("/tenants/current/invites", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.CreateTenantInvite)
|
||||||
|
p.DELETE("/tenants/current/invites/:id", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.RevokeTenantInvite)
|
||||||
p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗)
|
p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗)
|
||||||
|
p.GET("/me/jarvis", h.GetMyJarvis) // 我的 JARVIS 设置(名字/人设/自带豆包)
|
||||||
|
p.PUT("/me/jarvis", h.SaveMyJarvis) // 保存我的 JARVIS 设置
|
||||||
p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
|
p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
|
||||||
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
||||||
p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
|
p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
|
||||||
@@ -100,11 +110,13 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
p.GET("/kb/doc", h.KbDoc) // 取单篇文档
|
p.GET("/kb/doc", h.KbDoc) // 取单篇文档
|
||||||
p.DELETE("/kb/doc", middleware.RequireSpaceRole(db, store.RoleMember), h.KbDeleteDoc) // 级联删文档:viewer 拦下
|
p.DELETE("/kb/doc", middleware.RequireSpaceRole(db, store.RoleMember), h.KbDeleteDoc) // 级联删文档:viewer 拦下
|
||||||
|
|
||||||
// Prompt 控制面(平台级配置:建版本 → 激活 → 控制面热下发各服务)
|
// Prompt 控制面(平台级配置:建版本 → 激活 → 控制面热下发各服务)。
|
||||||
p.GET("/prompts", h.PromptList) // 列出全部版本 + 可配键
|
// 全平台配置、activate 热广播到所有服务、影响所有租户输出 —— 必须 RequireAdmin,
|
||||||
p.POST("/prompts/version", h.PromptCreateVersion) // 建新版本(不自动激活)
|
// 否则任意登录用户可改全局提示词。读写同源都挂 admin。
|
||||||
p.POST("/prompts/activate", middleware.Audit(db), h.PromptActivate) // 激活某版本 → 广播热更新(审计)
|
p.GET("/prompts", middleware.RequireAdmin(), h.PromptList) // 列出全部版本 + 可配键
|
||||||
p.POST("/prompts/deactivate", middleware.Audit(db), h.PromptDeactivate) // 撤销激活 → 回退代码默认(热,审计)
|
p.POST("/prompts/version", middleware.RequireAdmin(), middleware.Audit(db), h.PromptCreateVersion) // 建新版本(不自动激活)
|
||||||
|
p.POST("/prompts/activate", middleware.RequireAdmin(), middleware.Audit(db), h.PromptActivate) // 激活某版本 → 广播热更新(审计)
|
||||||
|
p.POST("/prompts/deactivate", middleware.RequireAdmin(), middleware.Audit(db), h.PromptDeactivate) // 撤销激活 → 回退代码默认(热,审计)
|
||||||
p.GET("/kb/links", h.KbLinks) // 某库双链
|
p.GET("/kb/links", h.KbLinks) // 某库双链
|
||||||
p.POST("/kb/note", middleware.RequireSpaceRole(db, store.RoleMember), h.KbSaveNote) // 新建/编辑笔记:viewer 拦下
|
p.POST("/kb/note", middleware.RequireSpaceRole(db, store.RoleMember), h.KbSaveNote) // 新建/编辑笔记:viewer 拦下
|
||||||
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
|
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
|
||||||
@@ -160,6 +172,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
admin.GET("/wechat-users", h.AdminWechatUsers) // 微信用户列表(openid/加入时间/余额)
|
admin.GET("/wechat-users", h.AdminWechatUsers) // 微信用户列表(openid/加入时间/余额)
|
||||||
admin.GET("/payment/wechat", h.AdminGetWechatPay)
|
admin.GET("/payment/wechat", h.AdminGetWechatPay)
|
||||||
admin.PUT("/payment/wechat", h.AdminSaveWechatPay)
|
admin.PUT("/payment/wechat", h.AdminSaveWechatPay)
|
||||||
|
admin.GET("/voice", h.AdminGetVoiceConfig) // 语音(火山豆包)配置
|
||||||
|
admin.PUT("/voice", h.AdminSaveVoiceConfig)
|
||||||
admin.GET("/sub-plans", h.AdminSubPlans) // 订阅套餐(含下架)
|
admin.GET("/sub-plans", h.AdminSubPlans) // 订阅套餐(含下架)
|
||||||
admin.PUT("/sub-plans", h.AdminSaveSubPlan) // 配价格/时长/发放节奏
|
admin.PUT("/sub-plans", h.AdminSaveSubPlan) // 配价格/时长/发放节奏
|
||||||
admin.GET("/subscriptions", h.AdminSubscriptions) // 全平台订阅观测
|
admin.GET("/subscriptions", h.AdminSubscriptions) // 全平台订阅观测
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 暂停租户是个真管控,不能是空开关:TenantSuspended 必须如实反映 status。
|
||||||
|
func TestTenantSuspended(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
seedTenant(t, p, "t1") // seedTenant 建的是 active
|
||||||
|
if p.TenantSuspended(ctx, "t1") {
|
||||||
|
t.Fatal("active 租户不该报暂停")
|
||||||
|
}
|
||||||
|
if err := p.SetTenantStatus(ctx, "t1", "suspended"); err != nil {
|
||||||
|
t.Fatalf("置暂停失败: %v", err)
|
||||||
|
}
|
||||||
|
if !p.TenantSuspended(ctx, "t1") {
|
||||||
|
t.Fatal("suspended 租户应报暂停")
|
||||||
|
}
|
||||||
|
// 查不到的租户按未暂停处理(宁放行不误封)。
|
||||||
|
if p.TenantSuspended(ctx, "nope") {
|
||||||
|
t.Fatal("不存在的租户不该报暂停")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 金额不符挂起:CAS 只挂一次,且不动已 paid 单。
|
||||||
|
func TestMarkOrderDisputed_CAS(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
o := &PaymentOrder{TenantID: "t1", UserID: "u1", AmountFen: 990, Channel: ChannelWechat, Status: OrderPending}
|
||||||
|
if err := p.CreateOrder(ctx, o); err != nil {
|
||||||
|
t.Fatalf("建单失败: %v", err)
|
||||||
|
}
|
||||||
|
changed, err := p.MarkOrderDisputed(ctx, o.ID)
|
||||||
|
if err != nil || !changed {
|
||||||
|
t.Fatalf("首次挂起应 changed=true: %v %v", changed, err)
|
||||||
|
}
|
||||||
|
again, _ := p.MarkOrderDisputed(ctx, o.ID)
|
||||||
|
if again {
|
||||||
|
t.Fatal("重复挂起应 changed=false(审计只写一次)")
|
||||||
|
}
|
||||||
|
got, _ := p.GetOrder(ctx, o.ID)
|
||||||
|
if got.Status != OrderDisputed {
|
||||||
|
t.Fatalf("状态应 disputed,得 %q", got.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已 paid 的单不能被挂起(CAS 只认 pending)。
|
||||||
|
paid := &PaymentOrder{TenantID: "t1", UserID: "u1", AmountFen: 990, Channel: ChannelWechat, Status: OrderPaid}
|
||||||
|
p.CreateOrder(ctx, paid)
|
||||||
|
if c, _ := p.MarkOrderDisputed(ctx, paid.ID); c {
|
||||||
|
t.Fatal("已 paid 单不该能挂起")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有效邀请码列表要滤掉过期/满员,否则误导邀请人。
|
||||||
|
func TestListInvites_FiltersDeadCodes(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
|
||||||
|
good, _ := p.CreateInvite(ctx, "t1", "inv", RoleMember, time.Now().Add(time.Hour), 0)
|
||||||
|
expired, _ := p.CreateInvite(ctx, "t1", "inv", RoleMember, time.Now().Add(-time.Hour), 0)
|
||||||
|
full, _ := p.CreateInvite(ctx, "t1", "inv", RoleMember, time.Now().Add(time.Hour), 1)
|
||||||
|
// 把 full 灌满
|
||||||
|
p.db.Model(&TenantInvite{}).Where("id = ?", full.ID).UpdateColumn("used_count", 1)
|
||||||
|
|
||||||
|
active := p.ListInvites(ctx, "t1", true)
|
||||||
|
if len(active) != 1 || active[0].ID != good.ID {
|
||||||
|
ids := make([]string, len(active))
|
||||||
|
for i, a := range active {
|
||||||
|
ids[i] = a.ID
|
||||||
|
}
|
||||||
|
t.Fatalf("有效列表应只剩 good(%s),得 %v", good.ID, ids)
|
||||||
|
}
|
||||||
|
_ = expired
|
||||||
|
// onlyActive=false 仍应看到全部三条(管理/审计用途)。
|
||||||
|
if all := p.ListInvites(ctx, "t1", false); len(all) != 3 {
|
||||||
|
t.Fatalf("全量列表应 3 条,得 %d", len(all))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A6:TaskOwner 供公开 by-id 端点判权——按 task_id 返回提交者,跨租户可查,不存在返回空。
|
||||||
|
func TestTaskOwner(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := p.SaveTask(ctx, "user-1", "task-abc", "{}"); err != nil {
|
||||||
|
t.Fatalf("建任务失败: %v", err)
|
||||||
|
}
|
||||||
|
if got := p.TaskOwner(ctx, "task-abc"); got != "user-1" {
|
||||||
|
t.Fatalf("owner 应 user-1,得 %q", got)
|
||||||
|
}
|
||||||
|
if got := p.TaskOwner(ctx, "nope"); got != "" {
|
||||||
|
t.Fatalf("不存在任务应返回空,得 %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 租户成员「二维码邀请」(可复用团队码):owner/admin 生成一张微信带参二维码,
|
||||||
|
// 发给团队,成员扫码关注/识别即自动入组。与登录二维码同一微信机制,区别只在
|
||||||
|
// scene 指向邀请令牌、扫码后干的是「入组」而非「授权 PC 登录」。
|
||||||
|
//
|
||||||
|
// 三道安全闸:有效期(ExpiresAt) + 可撤销(Status) + 最大人数(MaxUses)。
|
||||||
|
// 令牌 Token 随机不可枚举——它就是二维码里的 scene,泄露即等于把加入权发出去。
|
||||||
|
//
|
||||||
|
// 不标 isTenantScoped:与 TenantMember 一样是基础设施表,租户过滤在查询里显式做;
|
||||||
|
// 且扫码入组发生在微信回调(无请求 ctx 租户),本就不能依赖插件自动注入。
|
||||||
|
|
||||||
|
// TenantInvite 一张可复用的租户邀请码。
|
||||||
|
type TenantInvite struct {
|
||||||
|
BaseModel
|
||||||
|
TenantID string `gorm:"size:64;index" json:"tenant_id"`
|
||||||
|
Token string `gorm:"size:64;uniqueIndex" json:"-"` // = 二维码 scene;已在图里,不必回前端
|
||||||
|
Role string `gorm:"size:16" json:"role"` // 入组角色(member/viewer/admin;禁 owner)
|
||||||
|
InviterID string `gorm:"size:64" json:"-"` // 建码人(审计)
|
||||||
|
QRImage string `gorm:"size:255" json:"qr_image"` // 微信二维码图 URL(showqrcode)
|
||||||
|
ExpiresAt time.Time `gorm:"index" json:"expires_at"` // 过期时间(对齐微信临时二维码,≤30 天)
|
||||||
|
MaxUses int `gorm:"default:0" json:"max_uses"` // 0 = 不限人数
|
||||||
|
UsedCount int `gorm:"default:0" json:"used_count"` // 已成功加入的人数(同一人重复扫不重复计)
|
||||||
|
Status string `gorm:"size:16;default:active" json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TenantInvite) TableName() string { return "sundynix_tenant_invite" }
|
||||||
|
|
||||||
|
const (
|
||||||
|
InviteActive = "active"
|
||||||
|
InviteRevoked = "revoked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newInviteToken 生成不可枚举的邀请令牌(16 字节 → 32 hex)。
|
||||||
|
func newInviteToken() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvite 建一张邀请码(QRImage 由 handler 拿到微信二维码后回填 SetInviteQR)。
|
||||||
|
func (p *Postgres) CreateInvite(ctx context.Context, tenantID, inviterID, role string, expiresAt time.Time, maxUses int) (*TenantInvite, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, errStoreDisabled
|
||||||
|
}
|
||||||
|
if role == "" {
|
||||||
|
role = RoleMember
|
||||||
|
}
|
||||||
|
if !ValidRole(role) || role == RoleOwner {
|
||||||
|
return nil, errors.New("非法角色(二维码不能邀请为 owner)")
|
||||||
|
}
|
||||||
|
if maxUses < 0 {
|
||||||
|
maxUses = 0
|
||||||
|
}
|
||||||
|
inv := &TenantInvite{
|
||||||
|
TenantID: tenantID, Token: newInviteToken(), Role: role, InviterID: inviterID,
|
||||||
|
ExpiresAt: expiresAt, MaxUses: maxUses, Status: InviteActive,
|
||||||
|
}
|
||||||
|
if err := p.db.WithContext(ctx).Create(inv).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetInviteQR 回填二维码图 URL(建码后拿到微信二维码再写)。
|
||||||
|
func (p *Postgres) SetInviteQR(ctx context.Context, id, qrImage string) error {
|
||||||
|
if p.db == nil {
|
||||||
|
return errStoreDisabled
|
||||||
|
}
|
||||||
|
return p.db.WithContext(ctx).Model(&TenantInvite{}).Where("id = ?", id).Update("qr_image", qrImage).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInviteByToken 按令牌取邀请码(含已撤销/过期,校验交给调用方)。
|
||||||
|
func (p *Postgres) GetInviteByToken(ctx context.Context, token string) *TenantInvite {
|
||||||
|
if p.db == nil || token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var inv TenantInvite
|
||||||
|
if err := p.db.WithContext(WithoutTenant(ctx)).Where("token = ?", token).First(&inv).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &inv
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInvites 列出某租户的邀请码(新在前)。onlyActive 时只列未撤销的。
|
||||||
|
func (p *Postgres) ListInvites(ctx context.Context, tenantID string, onlyActive bool) []TenantInvite {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
q := p.db.WithContext(ctx).Where("tenant_id = ?", tenantID)
|
||||||
|
if onlyActive {
|
||||||
|
// 「有效」= 未撤销 + 未过期 + 未满员。只看 status 会把过期/满员的码当有效展示、误导邀请人。
|
||||||
|
q = q.Where("status = ?", InviteActive).
|
||||||
|
Where("expires_at > ?", time.Now()).
|
||||||
|
Where("max_uses = 0 OR used_count < max_uses")
|
||||||
|
}
|
||||||
|
var out []TenantInvite
|
||||||
|
q.Order("created_at desc").Limit(100).Find(&out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeInvite 撤销一张邀请码(限本租户,防越权撤别家的)。
|
||||||
|
func (p *Postgres) RevokeInvite(ctx context.Context, tenantID, id string) error {
|
||||||
|
if p.db == nil {
|
||||||
|
return errStoreDisabled
|
||||||
|
}
|
||||||
|
res := p.db.WithContext(ctx).Model(&TenantInvite{}).
|
||||||
|
Where("id = ? AND tenant_id = ?", id, tenantID).Update("status", InviteRevoked)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return errors.New("邀请码不存在或不属于本租户")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeemInvite 扫码入组:校验令牌 → 把 userID 加入其租户(幂等,复活已移除者)。
|
||||||
|
// 返回加入的租户名(供回执文案)与是否成功。令牌无效/过期/超次/撤销 → ok=false(回调静默)。
|
||||||
|
// UsedCount 只对「首次加入」计数:同一人重复扫(subscribe→之后 SCAN)不重复消耗名额。
|
||||||
|
func (p *Postgres) RedeemInvite(ctx context.Context, token, userID string) (tenantName string, ok bool) {
|
||||||
|
if p.db == nil || userID == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
inv := p.GetInviteByToken(ctx, token)
|
||||||
|
if inv == nil || inv.Status != InviteActive {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if !inv.ExpiresAt.IsZero() && time.Now().After(inv.ExpiresAt) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
sctx := WithoutTenant(ctx) // 入组的目标租户与请求 ctx 无关,一律显式、旁路插件
|
||||||
|
var name string
|
||||||
|
p.db.WithContext(sctx).Model(&Tenant{}).Where("id = ?", inv.TenantID).Select("name").Scan(&name)
|
||||||
|
|
||||||
|
// 已是活跃成员 → 幂等成功,不再计数、不改角色(避免重复扫把人降/升级)。
|
||||||
|
var existing TenantMember
|
||||||
|
err := p.db.WithContext(sctx).Where("tenant_id = ? AND user_id = ?", inv.TenantID, userID).First(&existing).Error
|
||||||
|
if err == nil && existing.Status == "active" {
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次加入前再查一次名额(软闸:极端并发下可能轻微超一两个,对邀请链接可接受)。
|
||||||
|
if inv.MaxUses > 0 && inv.UsedCount >= inv.MaxUses {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等入组(复活已移除者):命中唯一约束则置 active + 本码角色。
|
||||||
|
if err := p.db.WithContext(sctx).Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]any{"role": inv.Role, "status": "active", "updated_at": time.Now()}),
|
||||||
|
}).Create(&TenantMember{TenantID: inv.TenantID, UserID: userID, Role: inv.Role, Status: "active"}).Error; err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 名额 +1(原子自增,避免读改写丢更新)。
|
||||||
|
p.db.WithContext(sctx).Model(&TenantInvite{}).Where("id = ?", inv.ID).
|
||||||
|
UpdateColumn("used_count", gorm.Expr("used_count + 1"))
|
||||||
|
|
||||||
|
// 若目标租户已启用全员空间,新成员自动纳入(与 AddMemberByEmail 一致)。
|
||||||
|
p.autoJoinTenantSpace(sctx, inv.TenantID, userID, inv.Role)
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 邀请码 = 把「加入权」发出去。这组测试钉死:正常入组、重复扫不重复消耗名额、
|
||||||
|
// 过期/撤销/超人数一律拒绝——每一条错了都是「陌生人白嫖共享租户积分」的口子。
|
||||||
|
|
||||||
|
func mkUser(t *testing.T, p *Postgres, openid string) string {
|
||||||
|
t.Helper()
|
||||||
|
u, err := p.CreateWechatUser(context.Background(), openid, "微信用户_"+openid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("建用户失败: %v", err)
|
||||||
|
}
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedeemInvite_HappyAndIdempotent(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
ctx := context.Background()
|
||||||
|
inv, err := p.CreateInvite(ctx, "t1", "inviter", RoleMember, time.Now().Add(time.Hour), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("建码失败: %v", err)
|
||||||
|
}
|
||||||
|
uid := mkUser(t, p, "o-1")
|
||||||
|
|
||||||
|
name, ok := p.RedeemInvite(ctx, inv.Token, uid)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("正常令牌应入组成功")
|
||||||
|
}
|
||||||
|
if name != "T-t1" {
|
||||||
|
t.Fatalf("应回租户名 T-t1,得 %q", name)
|
||||||
|
}
|
||||||
|
if r := p.MemberRole(ctx, "t1", uid); r != RoleMember {
|
||||||
|
t.Fatalf("入组后应为 member,得 %q", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一人重复扫:仍成功但不重复消耗名额。
|
||||||
|
if _, ok := p.RedeemInvite(ctx, inv.Token, uid); !ok {
|
||||||
|
t.Fatal("重复扫应幂等成功")
|
||||||
|
}
|
||||||
|
got := p.GetInviteByToken(ctx, inv.Token)
|
||||||
|
if got.UsedCount != 1 {
|
||||||
|
t.Fatalf("同一人重复扫,UsedCount 应仍为 1,得 %d", got.UsedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedeemInvite_ExpiredAndRevoked(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
expired, _ := p.CreateInvite(ctx, "t1", "inv", RoleMember, time.Now().Add(-time.Minute), 0)
|
||||||
|
if _, ok := p.RedeemInvite(ctx, expired.Token, mkUser(t, p, "o-exp")); ok {
|
||||||
|
t.Fatal("过期码不该能入组")
|
||||||
|
}
|
||||||
|
|
||||||
|
revoked, _ := p.CreateInvite(ctx, "t1", "inv", RoleMember, time.Now().Add(time.Hour), 0)
|
||||||
|
if err := p.RevokeInvite(ctx, "t1", revoked.ID); err != nil {
|
||||||
|
t.Fatalf("撤销失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := p.RedeemInvite(ctx, revoked.Token, mkUser(t, p, "o-rev")); ok {
|
||||||
|
t.Fatal("已撤销码不该能入组")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedeemInvite_MaxUses(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
ctx := context.Background()
|
||||||
|
inv, _ := p.CreateInvite(ctx, "t1", "inv", RoleViewer, time.Now().Add(time.Hour), 2)
|
||||||
|
|
||||||
|
if _, ok := p.RedeemInvite(ctx, inv.Token, mkUser(t, p, "u1")); !ok {
|
||||||
|
t.Fatal("第 1 人应成功")
|
||||||
|
}
|
||||||
|
if _, ok := p.RedeemInvite(ctx, inv.Token, mkUser(t, p, "u2")); !ok {
|
||||||
|
t.Fatal("第 2 人应成功")
|
||||||
|
}
|
||||||
|
if _, ok := p.RedeemInvite(ctx, inv.Token, mkUser(t, p, "u3")); ok {
|
||||||
|
t.Fatal("第 3 人应被名额闸拦下")
|
||||||
|
}
|
||||||
|
if got := p.GetInviteByToken(ctx, inv.Token); got.UsedCount != 2 {
|
||||||
|
t.Fatalf("UsedCount 应为 2,得 %d", got.UsedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedeemInvite_RejectsOwnerRole(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
if _, err := p.CreateInvite(context.Background(), "t1", "inv", RoleOwner, time.Now().Add(time.Hour), 0); err == nil {
|
||||||
|
t.Fatal("二维码不该能生成 owner 邀请")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TryRunExclusive 用 PG advisory 锁做「集群级单实例执行」(leader 选举):非阻塞地抢锁,
|
||||||
|
// 抢到就跑 fn、跑完释放,返回 true;没抢到(别的实例正持有)返回 false、不跑 fn。
|
||||||
|
//
|
||||||
|
// 用途:多副本 gateway 下,让后台定时任务(订阅推进 / 掉单补偿)只有一个实例真正执行——
|
||||||
|
// 否则每实例各扫一遍,重复查库、对外部(微信查单)调用量随副本线性放大。**自愈**:锁随持有
|
||||||
|
// 连接释放,当前 leader 挂了下一轮别的实例自然抢到接管,无需显式故障转移。
|
||||||
|
//
|
||||||
|
// 降级/非 PG(sqlite 测试、无 DB 开发态):抢锁不可用 → 退回本地直接跑(幂等 + 单实例安全,
|
||||||
|
// 宁可跑也不要因选主机制缺失而彻底不跑)。
|
||||||
|
func (p *Postgres) TryRunExclusive(ctx context.Context, key int64, fn func()) bool {
|
||||||
|
if p.db == nil {
|
||||||
|
fn() // 无 DB:没法选主,单实例开发态直接跑(tick 内部对 nil db 也会自行 no-op)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sqlDB, err := p.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
fn()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
conn, err := sqlDB.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false // 连接都取不到,本轮跳过(下一轮再试)
|
||||||
|
}
|
||||||
|
defer conn.Close() // 关连接即释放其上的 session advisory 锁(兜底,防漏解锁)
|
||||||
|
var got bool
|
||||||
|
if err := conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&got); err != nil {
|
||||||
|
// 非 PG(sqlite 无此函数)或查询失败:选主不可用 → 退回本地直接跑。
|
||||||
|
fn()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !got {
|
||||||
|
return false // 别的实例是 leader,本轮不跑
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
uctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, _ = conn.ExecContext(uctx, "SELECT pg_advisory_unlock($1)", key)
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 非 PG(sqlite 测试库无 pg_try_advisory_lock):选主不可用 → 退回本地直接跑(单实例/开发态不能因此不跑)。
|
||||||
|
func TestTryRunExclusive_FallbackRunsOnNonPG(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
ran := false
|
||||||
|
if got := p.TryRunExclusive(context.Background(), 987654, func() { ran = true }); !got || !ran {
|
||||||
|
t.Fatalf("sqlite 无 advisory 锁应退回本地跑:got=%v ran=%v", got, ran)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无 DB(降级模式):一样本地直接跑。
|
||||||
|
func TestTryRunExclusive_NilDB(t *testing.T) {
|
||||||
|
p := &Postgres{}
|
||||||
|
ran := false
|
||||||
|
if got := p.TryRunExclusive(context.Background(), 987654, func() { ran = true }); !got || !ran {
|
||||||
|
t.Fatalf("无 DB 应本地跑:got=%v ran=%v", got, ran)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 迁移机制(务实硬化,不引外部工具,见 production_readiness.md B1):
|
||||||
|
// 1. 整段迁移在 **PG advisory lock** 内串行 —— 多实例同时启动不再并发 ALTER/建索引竞争
|
||||||
|
// (此前无锁:两实例并发 AutoMigrate 一方报错即掉降级模式)。
|
||||||
|
// 2. 两处**破坏性** legacy 迁移(DROP TABLE CASCADE)默认**不在启动路径跑**,移到
|
||||||
|
// ALLOW_LEGACY_SCHEMA_MIGRATION=1 显式开关后;检测到旧 schema 但未开则只告警不动手。
|
||||||
|
// 3. **版本化 runner**:AutoMigrate 之外的步骤(部分唯一索引、数据回填、将来 AutoMigrate
|
||||||
|
// 做不了的破坏性/数据迁移)登记在 schemaSteps,各跑一次并记入 schema_migration 表,
|
||||||
|
// 下次启动跳过。给了「有序、记录、跑一次」的真迁移语义,而不重写 gorm 结构体基线。
|
||||||
|
|
||||||
|
// migrationLockKey 是 pg_advisory_lock 的固定键(所有实例一致才能互斥)。
|
||||||
|
const migrationLockKey int64 = 20260721
|
||||||
|
|
||||||
|
// SchemaMigration 记录已应用的版本化迁移步骤(用模型而非裸 DDL,PG/sqlite 都可建,便于测试)。
|
||||||
|
type SchemaMigration struct {
|
||||||
|
ID int `gorm:"primaryKey"`
|
||||||
|
Name string `gorm:"size:128"`
|
||||||
|
AppliedAt time.Time `gorm:"autoCreateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SchemaMigration) TableName() string { return "sundynix_schema_migration" }
|
||||||
|
|
||||||
|
// migrationStep 是一步版本化迁移。fn 幂等更稳(存量库重跑无害),但 runner 靠 schema_migration
|
||||||
|
// 记录保证「已应用即跳过」,故不强求幂等——将来的破坏性步骤可以是非幂等的一次性 DDL。
|
||||||
|
type migrationStep struct {
|
||||||
|
id int
|
||||||
|
name string
|
||||||
|
fn func(*gorm.DB) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaSteps 是 AutoMigrate 之外的有序迁移。往后加破坏性/数据迁移 = 在末尾追加新 id,别改历史。
|
||||||
|
var schemaSteps = []migrationStep{
|
||||||
|
{1, "ledger_grant_ref_unique", func(db *gorm.DB) error {
|
||||||
|
// 支付入账幂等兜底闸:grant 分录按 ref(=订单号) 唯一。部分索引放行手工发放(ref 空)。
|
||||||
|
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_grant_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'grant' AND ref <> ''`).Error
|
||||||
|
}},
|
||||||
|
{2, "ledger_refund_ref_unique", func(db *gorm.DB) error {
|
||||||
|
// 退款幂等兜底闸:adjust 分录按 ref 唯一,与 grant 双闸对称。
|
||||||
|
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refund_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'adjust' AND ref <> ''`).Error
|
||||||
|
}},
|
||||||
|
{3, "backfill_null_tenant_balance", func(db *gorm.DB) error {
|
||||||
|
// 回填历史 NULL 余额(credit_balance_micro 后加列,早于它的租户行为 NULL → 充值 NULL+N=NULL
|
||||||
|
// 永不到账)。存量重跑 WHERE IS NULL 无命中,安全。让「余额=SUM(ledger)」不变量重立。
|
||||||
|
return db.Exec(`UPDATE sundynix_tenant SET credit_balance_micro = COALESCE(
|
||||||
|
(SELECT SUM(credits_micro) FROM sundynix_credit_ledger l WHERE l.tenant_id = sundynix_tenant.id), 0)
|
||||||
|
WHERE credit_balance_micro IS NULL`).Error
|
||||||
|
}},
|
||||||
|
{4, "user_wechat_openid_unique", func(db *gorm.DB) error {
|
||||||
|
// 微信 openid 部分唯一索引:只约束非空。存量邮箱用户该列空串,普通唯一索引会互撞。
|
||||||
|
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_wechat_openid ON sundynix_user (wechat_openid) WHERE wechat_openid <> ''`).Error
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// migratedModels 是 AutoMigrate 的基线模型清单(新增性 DDL,安全)。加表在此追加。
|
||||||
|
func migratedModels() []any {
|
||||||
|
return []any{
|
||||||
|
&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{},
|
||||||
|
&AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &TenantInvite{}, &Space{}, &SpaceMember{},
|
||||||
|
&UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}, &CreditPack{}, &PaymentOrder{},
|
||||||
|
&RedeemCode{}, &SubscriptionPlan{}, &Subscription{}, &UserJarvis{}, &SchemaMigration{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMigrations 在 advisory lock 内跑全部迁移:legacy(默认关) → AutoMigrate 基线 → 版本化步骤。
|
||||||
|
// 只有 AutoMigrate 失败才返回 error(→ 调用方降级);版本化步骤失败只记日志、下次启动重试。
|
||||||
|
func runMigrations(db *gorm.DB) error {
|
||||||
|
return withMigrationLock(db, func() error {
|
||||||
|
if allowLegacyMigration() {
|
||||||
|
migrateLegacyIntIDs(db)
|
||||||
|
migrateDocLinkToID(db)
|
||||||
|
} else {
|
||||||
|
warnIfLegacySchema(db)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(migratedModels()...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := runVersionedMigrations(db, schemaSteps); err != nil {
|
||||||
|
log.Printf("[store] 版本化迁移失败: %v(下次启动重试)", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// withMigrationLock 取一条专用连接持 pg_advisory_lock,在锁内跑 fn,结束释放。
|
||||||
|
// 多实例同时启动只有一个进锁跑迁移,其余阻塞等待(避免并发 DDL 竞争)。
|
||||||
|
// 取锁给 60s 超时兜底:极端情况取不到就带告警继续(AutoMigrate/索引多为幂等,退一步不致命)。
|
||||||
|
func withMigrationLock(db *gorm.DB, fn func() error) error {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fn() // 拿不到底层连接(如测试用非标准驱动)→ 不阻塞,直接跑
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
conn, err := sqlDB.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[store] 取迁移锁连接失败,跳过加锁继续: %v", err)
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
|
||||||
|
log.Printf("[store] 取迁移 advisory lock 失败,跳过加锁继续: %v", err)
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
uctx, ucancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer ucancel()
|
||||||
|
_, _ = conn.ExecContext(uctx, "SELECT pg_advisory_unlock($1)", migrationLockKey)
|
||||||
|
}()
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// runVersionedMigrations 顺序跑未应用的步骤,每步成功即记入 schema_migration 表。
|
||||||
|
// 某步失败即停(后续步骤可能依赖它),不记录 → 下次启动从该步重试。
|
||||||
|
func runVersionedMigrations(db *gorm.DB, steps []migrationStep) error {
|
||||||
|
if err := db.AutoMigrate(&SchemaMigration{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var ids []int
|
||||||
|
db.Model(&SchemaMigration{}).Pluck("id", &ids)
|
||||||
|
applied := make(map[int]bool, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
applied[id] = true
|
||||||
|
}
|
||||||
|
for _, s := range steps {
|
||||||
|
if applied[s.id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.fn(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Create(&SchemaMigration{ID: s.id, Name: s.name}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[store] 迁移 #%d(%s) 已应用", s.id, s.name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowLegacyMigration 报告是否允许跑破坏性 legacy 迁移(默认关:不在启动路径 DROP 表)。
|
||||||
|
func allowLegacyMigration() bool {
|
||||||
|
v := os.Getenv("ALLOW_LEGACY_SCHEMA_MIGRATION")
|
||||||
|
return v == "1" || strings.EqualFold(v, "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnIfLegacySchema 检测到旧整型 id schema 但未开 legacy 开关时,只告警不动手(不 DROP)。
|
||||||
|
func warnIfLegacySchema(db *gorm.DB) {
|
||||||
|
var dt string
|
||||||
|
db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name='sundynix_model' AND column_name='id'`).Scan(&dt)
|
||||||
|
if dt == "bigint" || dt == "integer" {
|
||||||
|
log.Printf("[store] ⚠️ 检测到旧整型 id schema。破坏性迁移默认已关(不会自动 DROP 重建)。" +
|
||||||
|
"如确需迁移,设 ALLOW_LEGACY_SCHEMA_MIGRATION=1 后重启(会 DROP 并重建部分表)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateLegacyIntIDs 检测到旧整型 id 表则备份模型密钥、删旧表(AutoMigrate 随后按新规约重建)。
|
||||||
|
// **破坏性**:仅在 ALLOW_LEGACY_SCHEMA_MIGRATION=1 时经 runMigrations 调用。
|
||||||
|
func migrateLegacyIntIDs(db *gorm.DB) {
|
||||||
|
var dt string
|
||||||
|
db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name='sundynix_model' AND column_name='id'`).Scan(&dt)
|
||||||
|
if dt != "bigint" && dt != "integer" {
|
||||||
|
return // 全新库或已是新规约
|
||||||
|
}
|
||||||
|
log.Println("[store] 检测到旧整型 id 表,执行雪花 id 迁移(保模型密钥,重置其它测试表)")
|
||||||
|
var saved []map[string]any
|
||||||
|
db.Table("sundynix_model").Find(&saved)
|
||||||
|
for _, t := range []string{"sundynix_doc_link", "sundynix_doc", "sundynix_agent", "sundynix_kb", "sundynix_model", "sundynix_task", "sundynix_user"} {
|
||||||
|
db.Exec("DROP TABLE IF EXISTS " + t + " CASCADE")
|
||||||
|
}
|
||||||
|
_ = db.AutoMigrate(&LLMModel{}) // 先建模型表以回灌
|
||||||
|
for _, r := range saved {
|
||||||
|
s := func(k string) string { v, _ := r[k].(string); return v }
|
||||||
|
b, _ := r["active"].(bool)
|
||||||
|
_ = db.Create(&LLMModel{
|
||||||
|
Kind: s("kind"), Provider: s("provider"), BaseURL: s("base_url"),
|
||||||
|
APIKey: s("api_key"), Model: s("model"), Active: b,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
log.Printf("[store] 已回灌 %d 条模型配置(新雪花 id)", len(saved))
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateDocLinkToID 把旧的按名双链表迁到按 Doc.ID 关联的新表。**破坏性**(DROP 重建):
|
||||||
|
// 仅在 ALLOW_LEGACY_SCHEMA_MIGRATION=1 时调用。
|
||||||
|
func migrateDocLinkToID(db *gorm.DB) {
|
||||||
|
if !db.Migrator().HasTable("sundynix_doc_link") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if db.Migrator().HasColumn(&DocLink{}, "from_id") {
|
||||||
|
return // 已是按 ID 关联的新 schema
|
||||||
|
}
|
||||||
|
log.Println("[store] 双链表升级为按文件 ID 关联,重建 sundynix_doc_link(链接随文档再入库重建)")
|
||||||
|
db.Exec("DROP TABLE IF EXISTS sundynix_doc_link CASCADE")
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 版本化 runner:首次跑全部、再跑全跳过、追加只跑新步。
|
||||||
|
func TestRunVersionedMigrations_RunsOnceThenSkips(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
var ran []int
|
||||||
|
steps := []migrationStep{
|
||||||
|
{1, "a", func(*gorm.DB) error { ran = append(ran, 1); return nil }},
|
||||||
|
{2, "b", func(*gorm.DB) error { ran = append(ran, 2); return nil }},
|
||||||
|
}
|
||||||
|
if err := runVersionedMigrations(p.db, steps); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ran) != 2 {
|
||||||
|
t.Fatalf("首次应跑 2 步,得 %v", ran)
|
||||||
|
}
|
||||||
|
|
||||||
|
ran = nil
|
||||||
|
if err := runVersionedMigrations(p.db, steps); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ran) != 0 {
|
||||||
|
t.Fatalf("第二次应全跳过(已记录),得 %v", ran)
|
||||||
|
}
|
||||||
|
|
||||||
|
steps = append(steps, migrationStep{3, "c", func(*gorm.DB) error { ran = append(ran, 3); return nil }})
|
||||||
|
if err := runVersionedMigrations(p.db, steps); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ran) != 1 || ran[0] != 3 {
|
||||||
|
t.Fatalf("应只跑新增步骤 #3,得 %v", ran)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 某步失败即停:后续步骤不跑,且失败步骤不记录(下次从它重试);已成功步骤不重跑。
|
||||||
|
func TestRunVersionedMigrations_StopsOnFailure(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
var ran []int
|
||||||
|
steps := []migrationStep{
|
||||||
|
{1, "ok", func(*gorm.DB) error { ran = append(ran, 1); return nil }},
|
||||||
|
{2, "boom", func(*gorm.DB) error { return errors.New("boom") }},
|
||||||
|
{3, "after", func(*gorm.DB) error { ran = append(ran, 3); return nil }},
|
||||||
|
}
|
||||||
|
if err := runVersionedMigrations(p.db, steps); err == nil {
|
||||||
|
t.Fatal("步骤 #2 失败应返回 error")
|
||||||
|
}
|
||||||
|
if len(ran) != 1 {
|
||||||
|
t.Fatalf("步骤 #3 不该在 #2 失败后跑,得 %v", ran)
|
||||||
|
}
|
||||||
|
// 重试:#1 已记录跳过(不重复跑),#2 再次失败即停。
|
||||||
|
ran = nil
|
||||||
|
if err := runVersionedMigrations(p.db, steps); err == nil {
|
||||||
|
t.Fatal("重试仍应在 #2 失败")
|
||||||
|
}
|
||||||
|
if len(ran) != 0 {
|
||||||
|
t.Fatalf("已应用的 #1 不该重跑,得 %v", ran)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ const (
|
|||||||
OrderFailed = "failed"
|
OrderFailed = "failed"
|
||||||
OrderExpired = "expired"
|
OrderExpired = "expired"
|
||||||
OrderRefunded = "refunded"
|
OrderRefunded = "refunded"
|
||||||
|
OrderDisputed = "disputed" // 渠道已付但金额与订单不符:不入账、挂起待人工核对(终态,不再重扫)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 渠道名。P5.1 只有 redeem;wechat 在 P5.2 挂上。
|
// 渠道名。P5.1 只有 redeem;wechat 在 P5.2 挂上。
|
||||||
@@ -220,6 +221,21 @@ func (p *Postgres) GetOrder(ctx context.Context, id string) (*PaymentOrder, erro
|
|||||||
return &o, nil
|
return &o, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkOrderDisputed 把一张 pending 订单 CAS 置为 disputed(渠道已付但金额不符)。
|
||||||
|
// 返回 changed:仅首次转移为 true,供调用方决定是否只写一次审计。CAS 保证并发/重复回调只挂起一次。
|
||||||
|
// disputed 是终态:补偿定时器只扫 pending,从此不再重复查它、不再刷屏。
|
||||||
|
func (p *Postgres) MarkOrderDisputed(ctx context.Context, orderID string) (bool, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return false, errStoreDisabled
|
||||||
|
}
|
||||||
|
res := p.db.WithContext(WithoutTenant(ctx)).Model(&PaymentOrder{}).
|
||||||
|
Where("id = ? AND status = ?", orderID, OrderPending).Update("status", OrderDisputed)
|
||||||
|
if res.Error != nil {
|
||||||
|
return false, res.Error
|
||||||
|
}
|
||||||
|
return res.RowsAffected > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// MarkOrderPaid 渠道确认已支付后的入账:一个事务里「订单 CAS(pending→paid) → 分录 → 物化余额」。
|
// MarkOrderPaid 渠道确认已支付后的入账:一个事务里「订单 CAS(pending→paid) → 分录 → 物化余额」。
|
||||||
// 返回 changed=false 表示这单已被处理过(回调重复推送/回调与主动查单赛跑),幂等直接成功。
|
// 返回 changed=false 表示这单已被处理过(回调重复推送/回调与主动查单赛跑),幂等直接成功。
|
||||||
// 双闸:CAS 是主闸;credit_ledger (kind,ref=订单号) 唯一索引兜底。
|
// 双闸:CAS 是主闸;credit_ledger (kind,ref=订单号) 唯一索引兜底。
|
||||||
|
|||||||
@@ -61,84 +61,17 @@ func OpenPostgres(dsn string) *Postgres {
|
|||||||
return &Postgres{}
|
return &Postgres{}
|
||||||
}
|
}
|
||||||
tunePool(db) // 连接池上限,防高并发打爆 PG
|
tunePool(db) // 连接池上限,防高并发打爆 PG
|
||||||
// 一次性迁移:旧表用整型自增 id,与新雪花字符串 id 不兼容(AutoMigrate 不改主键类型)。
|
// 迁移统一走 runMigrations:advisory lock 内串行(多实例安全)→ AutoMigrate 基线 →
|
||||||
// 备份模型密钥(唯一不可再生的数据) → 重建全部表 → 回灌模型。其余为可重建的测试数据。
|
// 版本化步骤(索引/回填)。破坏性 legacy 迁移默认不跑(见 migrate.go)。
|
||||||
migrateLegacyIntIDs(db)
|
if err := runMigrations(db); err != nil {
|
||||||
migrateDocLinkToID(db)
|
log.Printf("[store] postgres 迁移失败,降级运行: %v", err)
|
||||||
|
|
||||||
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &Space{}, &SpaceMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}, &CreditPack{}, &PaymentOrder{}, &RedeemCode{}, &SubscriptionPlan{}, &Subscription{}); err != nil {
|
|
||||||
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
|
||||||
return &Postgres{}
|
return &Postgres{}
|
||||||
}
|
}
|
||||||
// 支付入账幂等兜底闸:grant 分录按 ref(=订单号) 唯一——支付回调是 at-least-once,
|
|
||||||
// 订单状态机 CAS 是主闸,这里是第二道。部分索引:admin 手工发放 ref 为空、usage 分录不受影响。
|
|
||||||
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_grant_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'grant' AND ref <> ''`).Error; err != nil {
|
|
||||||
log.Printf("[store] 账本 grant/ref 唯一索引创建失败(重复入账兜底闸缺位): %v", err)
|
|
||||||
}
|
|
||||||
// 退款幂等兜底闸:adjust 分录带 ref(=订单号) 唯一——防重复退款冲销。
|
|
||||||
// 部分索引:admin 手工校正(GrantCredits 负数)ref 为空,不受约束;与 grant 双闸对称。
|
|
||||||
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refund_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'adjust' AND ref <> ''`).Error; err != nil {
|
|
||||||
log.Printf("[store] 账本 adjust/ref 唯一索引创建失败(重复退款兜底闸缺位): %v", err)
|
|
||||||
}
|
|
||||||
// 回填历史 NULL 余额。credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL,
|
|
||||||
// 而入账用的是 `余额 + N` —— SQL 里 NULL + N 仍是 NULL,于是这些租户**充值永远不到账**
|
|
||||||
// (分录照写、余额不动),且不报错。代码侧已改 coalesce 自愈,这里把存量一次修平,
|
|
||||||
// 让「余额 = SUM(ledger)」这条对账不变量重新成立。
|
|
||||||
if err := db.Exec(`UPDATE sundynix_tenant SET credit_balance_micro = COALESCE(
|
|
||||||
(SELECT SUM(credits_micro) FROM sundynix_credit_ledger l WHERE l.tenant_id = sundynix_tenant.id), 0)
|
|
||||||
WHERE credit_balance_micro IS NULL`).Error; err != nil {
|
|
||||||
log.Printf("[store] 历史 NULL 余额回填失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 微信 openid 部分唯一索引:只约束非空值。存量邮箱用户该列是空串 '' 而非 NULL,
|
|
||||||
// 若建普通唯一索引,多个空串会互撞、AutoMigrate 直接失败(NULL 余额那次的同类坑)。
|
|
||||||
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_wechat_openid ON sundynix_user (wechat_openid) WHERE wechat_openid <> ''`).Error; err != nil {
|
|
||||||
log.Printf("[store] 微信 openid 唯一索引创建失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
registerTenantScope(db) // 多租户:受租户模型的查询/创建自动按上下文注入 tenant_id(统一强制隔离)
|
registerTenantScope(db) // 多租户:受租户模型的查询/创建自动按上下文注入 tenant_id(统一强制隔离)
|
||||||
log.Println("[store] postgres connected & migrated (雪花 id + 软删 规约)")
|
log.Println("[store] postgres connected & migrated (雪花 id + 软删 规约)")
|
||||||
return &Postgres{db: db}
|
return &Postgres{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateLegacyIntIDs 检测到旧整型 id 表则备份模型密钥、删旧表(AutoMigrate 随后按新规约重建)。
|
|
||||||
func migrateLegacyIntIDs(db *gorm.DB) {
|
|
||||||
var dt string
|
|
||||||
db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name='sundynix_model' AND column_name='id'`).Scan(&dt)
|
|
||||||
if dt != "bigint" && dt != "integer" {
|
|
||||||
return // 全新库或已是新规约
|
|
||||||
}
|
|
||||||
log.Println("[store] 检测到旧整型 id 表,执行雪花 id 迁移(保模型密钥,重置其它测试表)")
|
|
||||||
var saved []map[string]any
|
|
||||||
db.Table("sundynix_model").Find(&saved)
|
|
||||||
for _, t := range []string{"sundynix_doc_link", "sundynix_doc", "sundynix_agent", "sundynix_kb", "sundynix_model", "sundynix_task", "sundynix_user"} {
|
|
||||||
db.Exec("DROP TABLE IF EXISTS " + t + " CASCADE")
|
|
||||||
}
|
|
||||||
_ = db.AutoMigrate(&LLMModel{}) // 先建模型表以回灌
|
|
||||||
for _, r := range saved {
|
|
||||||
s := func(k string) string { v, _ := r[k].(string); return v }
|
|
||||||
b, _ := r["active"].(bool)
|
|
||||||
_ = db.Create(&LLMModel{
|
|
||||||
Kind: s("kind"), Provider: s("provider"), BaseURL: s("base_url"),
|
|
||||||
APIKey: s("api_key"), Model: s("model"), Active: b,
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
log.Printf("[store] 已回灌 %d 条模型配置(新雪花 id)", len(saved))
|
|
||||||
}
|
|
||||||
|
|
||||||
// migrateDocLinkToID 把旧的按名双链表(from_name/to_name)迁到按 Doc.ID 关联的新表。
|
|
||||||
// 旧表无 from_id 列即判定为旧 schema:直接删表,由 AutoMigrate 重建;链接随文档再入库/编辑重建。
|
|
||||||
func migrateDocLinkToID(db *gorm.DB) {
|
|
||||||
if !db.Migrator().HasTable("sundynix_doc_link") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if db.Migrator().HasColumn(&DocLink{}, "from_id") {
|
|
||||||
return // 已是按 ID 关联的新 schema
|
|
||||||
}
|
|
||||||
log.Println("[store] 双链表升级为按文件 ID 关联,重建 sundynix_doc_link(链接随文档再入库重建)")
|
|
||||||
db.Exec("DROP TABLE IF EXISTS sundynix_doc_link CASCADE")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enabled 报告是否处于真实持久化模式。
|
// Enabled 报告是否处于真实持久化模式。
|
||||||
func (p *Postgres) Enabled() bool { return p.db != nil }
|
func (p *Postgres) Enabled() bool { return p.db != nil }
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,53 @@ func (r *Redis) Allow(ctx context.Context, key string, limit int64, window time.
|
|||||||
return n <= limit, nil
|
return n <= limit, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 登录失败锁定(账户级,防撞库:连续失败 N 次锁 window,成功即清)----
|
||||||
|
// 与 A5 的 IP 限流是两道互补闸:IP 限流挡"一个 IP 猛打",账户锁定挡"分布式慢速猜一个号"。
|
||||||
|
// 权衡:per-account 锁定可被人为锁死受害者账户(lockout DoS),故窗口设短(15min)自动解锁 +
|
||||||
|
// 叠加 IP 限流;Redis 降级则不锁定(尽力而为,IP 限流仍在)。
|
||||||
|
const (
|
||||||
|
loginFailWindow = 15 * time.Minute
|
||||||
|
loginFailThreshold = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
func loginFailKey(email string) string { return "sundynix:loginfail:" + email }
|
||||||
|
|
||||||
|
// LoginLocked 报告账户是否因连续登录失败被临时锁定。降级(rdb==nil)返回 false。
|
||||||
|
func (r *Redis) LoginLocked(ctx context.Context, email string) bool {
|
||||||
|
if r.rdb == nil || email == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
n, err := r.rdb.Get(ctx, loginFailKey(email)).Int()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return n >= loginFailThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteLoginFail 记一次登录失败,返回失败后是否已达锁定阈值。首次失败设窗口 TTL
|
||||||
|
// (之后锁定期内因提前拒绝不再自增 → TTL 自最后一次失败起倒计时,到点自动解锁)。
|
||||||
|
func (r *Redis) NoteLoginFail(ctx context.Context, email string) bool {
|
||||||
|
if r.rdb == nil || email == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
n, err := r.rdb.Incr(ctx, loginFailKey(email)).Result()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if n == 1 {
|
||||||
|
_ = r.rdb.Expire(ctx, loginFailKey(email), loginFailWindow).Err()
|
||||||
|
}
|
||||||
|
return n >= loginFailThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearLoginFails 登录成功后清零失败计数。
|
||||||
|
func (r *Redis) ClearLoginFails(ctx context.Context, email string) {
|
||||||
|
if r.rdb == nil || email == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = r.rdb.Del(ctx, loginFailKey(email)).Err()
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Token 流持久化(Redis Stream:可回放的追加日志,根治 SSE 连晚/重连丢 token)----
|
// ---- Token 流持久化(Redis Stream:可回放的追加日志,根治 SSE 连晚/重连丢 token)----
|
||||||
|
|
||||||
// ---- Token 用量日预算(成本护栏:按用户按天累计 token,供提交前门控)----
|
// ---- Token 用量日预算(成本护栏:按用户按天累计 token,供提交前门控)----
|
||||||
|
|||||||
@@ -16,3 +16,16 @@ func TestStreamKey_ChannelIsolation(t *testing.T) {
|
|||||||
t.Errorf("exec key=%q", ex)
|
t.Errorf("exec key=%q", ex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 登录失败计数 key 独立命名空间,且阈值/窗口是合理防撞库值。
|
||||||
|
func TestLoginFailKey(t *testing.T) {
|
||||||
|
if k := loginFailKey("a@b.com"); k != "sundynix:loginfail:a@b.com" {
|
||||||
|
t.Fatalf("key=%q", k)
|
||||||
|
}
|
||||||
|
if loginFailThreshold < 3 || loginFailThreshold > 10 {
|
||||||
|
t.Fatalf("阈值应在 3–10 合理区间,得 %d", loginFailThreshold)
|
||||||
|
}
|
||||||
|
if loginFailWindow < 5*60*1e9 {
|
||||||
|
t.Fatal("锁定窗口不该太短")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,6 +98,18 @@ type AdminEval struct {
|
|||||||
Corrected bool `json:"corrected"`
|
Corrected bool `json:"corrected"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskOwner 按 task_id 返回提交者 user.id(供 SSE/导出等公开 by-id 端点做归属校验)。
|
||||||
|
// 跨租户查(WithoutTenant):这些端点无租户上下文,靠 owner 判权。不存在返回空串。
|
||||||
|
func (p *Postgres) TaskOwner(ctx context.Context, taskID string) string {
|
||||||
|
if p.db == nil || taskID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var owner string
|
||||||
|
p.db.WithContext(WithoutTenant(ctx)).Model(&Task{}).
|
||||||
|
Where("task_id = ? and deleted_at is null", taskID).Select("owner").Scan(&owner)
|
||||||
|
return owner
|
||||||
|
}
|
||||||
|
|
||||||
// TaskDetail 按 task_id 取单条任务的完整下钻数据(管理端,跨租户)。
|
// TaskDetail 按 task_id 取单条任务的完整下钻数据(管理端,跨租户)。
|
||||||
// 必须 WithoutTenant:Task/Eval 都在租户插件作用域内,用请求 ctx 查别的租户的任务
|
// 必须 WithoutTenant:Task/Eval 都在租户插件作用域内,用请求 ctx 查别的租户的任务
|
||||||
// 不会报错,而是静默返回空——看起来像"这任务没产出",比报错更难排查。
|
// 不会报错,而是静默返回空——看起来像"这任务没产出",比报错更难排查。
|
||||||
|
|||||||
@@ -171,6 +171,18 @@ func (p *Postgres) GetTenant(ctx context.Context, id string) (*Tenant, error) {
|
|||||||
return &t, nil
|
return &t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TenantSuspended 报告租户是否被暂停(轻查询,供提交前门控)。查不到/出错按未暂停处理,
|
||||||
|
// 宁可放行也不误封(暂停是显式管控动作,缺数据时不该凭空拦人)。
|
||||||
|
func (p *Postgres) TenantSuspended(ctx context.Context, tenantID string) bool {
|
||||||
|
if p.db == nil || tenantID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
p.db.WithContext(WithoutTenant(ctx)).Model(&Tenant{}).
|
||||||
|
Where("id = ?", tenantID).Select("status").Scan(&status)
|
||||||
|
return status == "suspended"
|
||||||
|
}
|
||||||
|
|
||||||
// MemberRole 返回用户在某租户的角色(无成员关系返回空)。
|
// MemberRole 返回用户在某租户的角色(无成员关系返回空)。
|
||||||
func (p *Postgres) MemberRole(ctx context.Context, tenantID, userID string) string {
|
func (p *Postgres) MemberRole(ctx context.Context, tenantID, userID string) string {
|
||||||
if p.db == nil {
|
if p.db == nil {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func newTestStore(t *testing.T) *Postgres {
|
|||||||
sqlDB.SetMaxOpenConns(1) // :memory: 每连接一个库,锁死单连接才共享同一份数据
|
sqlDB.SetMaxOpenConns(1) // :memory: 每连接一个库,锁死单连接才共享同一份数据
|
||||||
|
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&User{}, &Tenant{}, &TenantMember{}, &CreditLedger{}, &PaymentOrder{},
|
&User{}, &Tenant{}, &TenantMember{}, &TenantInvite{}, &CreditLedger{}, &PaymentOrder{},
|
||||||
&RedeemCode{}, &CreditPack{}, &UsageEvent{}, &UsageRollup{}, &Setting{}, &Pricing{}, &LLMModel{},
|
&RedeemCode{}, &CreditPack{}, &UsageEvent{}, &UsageRollup{}, &Setting{}, &Pricing{}, &LLMModel{},
|
||||||
&AuditLog{}, &Task{}, &Eval{}, &SubscriptionPlan{}, &Subscription{},
|
&AuditLog{}, &Task{}, &Eval{}, &SubscriptionPlan{}, &Subscription{},
|
||||||
&KB{}, // 租户作用域模型,验证隔离插件
|
&KB{}, // 租户作用域模型,验证隔离插件
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ func (p *Postgres) GetUserByWechatOpenID(ctx context.Context, openID string) (*U
|
|||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateWechatUser 为首次微信登录的用户建号(无邮箱/密码)。name 是展示名(微信昵称或默认)。
|
// CreateWechatUser 为首次微信登录的用户建号(无密码)。name 是展示名(微信昵称或默认)。
|
||||||
|
//
|
||||||
|
// 邮箱填一个合成的、按 openid 唯一的占位值(wx-<openid>@wx.local):User.Email 上有整列
|
||||||
|
// 唯一索引,微信用户若都留空邮箱,第二个就会撞唯一约束建号失败。合成邮箱唯一且一望即知非真实,
|
||||||
|
// 既绕开冲突又不动索引(改成部分唯一索引需删存量索引,无法在无库可见时稳妥进行)。
|
||||||
func (p *Postgres) CreateWechatUser(ctx context.Context, openID, name string) (*User, error) {
|
func (p *Postgres) CreateWechatUser(ctx context.Context, openID, name string) (*User, error) {
|
||||||
if p.db == nil {
|
if p.db == nil {
|
||||||
return nil, errStoreDisabled
|
return nil, errStoreDisabled
|
||||||
@@ -82,7 +86,7 @@ func (p *Postgres) CreateWechatUser(ctx context.Context, openID, name string) (*
|
|||||||
if openID == "" {
|
if openID == "" {
|
||||||
return nil, errors.New("openid 必填")
|
return nil, errors.New("openid 必填")
|
||||||
}
|
}
|
||||||
u := &User{WechatOpenID: openID, Name: name}
|
u := &User{WechatOpenID: openID, Name: name, Email: "wx-" + openID + "@wx.local"}
|
||||||
if err := p.db.WithContext(ctx).Create(u).Error; err != nil {
|
if err := p.db.WithContext(ctx).Create(u).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserJarvis 是**每用户**的 JARVIS 语音助手配置(客户端配、用户级)。
|
||||||
|
// 与系统级语音配置(Setting voice_config)、与主偏好记忆(user_profile) 都分开:
|
||||||
|
// - Name/Persona:这个用户的助手叫什么、什么语气人设(persona 独立于主偏好记忆,互不污染)。
|
||||||
|
// - APIKey/…ResourceID/VoiceType:用户自带的豆包(火山)配置;齐全则语音走用户的,否则回落系统。
|
||||||
|
// APIKey 密文入库(AES-256-GCM,同 LLMModel/微信配置)。表名 sundynix_user_jarvis。
|
||||||
|
type UserJarvis struct {
|
||||||
|
BaseModel
|
||||||
|
UserID string `gorm:"size:32;uniqueIndex"` // 雪花 user.id,每用户唯一一条
|
||||||
|
Name string `gorm:"size:32"` // 助手名(空=用系统默认 "JARVIS")
|
||||||
|
Persona string `gorm:"size:1024"` // 语气/人设(空=用系统默认)
|
||||||
|
APIKey string `gorm:"size:255"` // 用户自带火山 API Key(密文;空=用系统)
|
||||||
|
ASRResourceID string `gorm:"size:64"`
|
||||||
|
TTSResourceID string `gorm:"size:64"`
|
||||||
|
TTSVoiceType string `gorm:"size:64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UserJarvis) TableName() string { return "sundynix_user_jarvis" }
|
||||||
|
|
||||||
|
// GetUserJarvis 取某用户的 JARVIS 配置;不存在返回 nil(调用方回落系统默认)。
|
||||||
|
func (p *Postgres) GetUserJarvis(ctx context.Context, uid string) *UserJarvis {
|
||||||
|
if p.db == nil || uid == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var j UserJarvis
|
||||||
|
if err := p.db.WithContext(ctx).Where("user_id = ?", uid).First(&j).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &j
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveUserJarvis 幂等写某用户的 JARVIS 配置(按 user_id 唯一,重复即覆盖)。
|
||||||
|
// APIKey 传空串表示"沿用已存"(由 handler 决定是否覆盖),此处只负责按传入值落库。
|
||||||
|
func (p *Postgres) SaveUserJarvis(ctx context.Context, j *UserJarvis) error {
|
||||||
|
if p.db == nil {
|
||||||
|
return errStoreDisabled
|
||||||
|
}
|
||||||
|
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "user_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"name", "persona", "api_key", "asr_resource_id", "tts_resource_id", "tts_voice_type", "updated_at"}),
|
||||||
|
}).Create(j).Error
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 微信首次登录建号:无邮箱、按 openid 可查回。
|
// 微信首次登录建号:合成占位邮箱(按 openid 唯一,避免多个微信用户空邮箱撞唯一约束)、按 openid 可查回。
|
||||||
func TestCreateAndGetWechatUser(t *testing.T) {
|
func TestCreateAndGetWechatUser(t *testing.T) {
|
||||||
p := newTestStore(t)
|
p := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -19,8 +19,13 @@ func TestCreateAndGetWechatUser(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("建号失败: %v", err)
|
t.Fatalf("建号失败: %v", err)
|
||||||
}
|
}
|
||||||
if u.Email != "" {
|
if u.Email != "wx-openid-x@wx.local" {
|
||||||
t.Fatalf("微信用户不该有邮箱,得 %q", u.Email)
|
t.Fatalf("微信用户应有按 openid 合成的占位邮箱,得 %q", u.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二个微信用户不能撞唯一约束(此前空邮箱会冲突——邀请功能会批量建微信用户)。
|
||||||
|
if _, err := p.CreateWechatUser(ctx, "openid-y", "微信用户2"); err != nil {
|
||||||
|
t.Fatalf("第二个微信用户建号不该冲突: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := p.GetUserByWechatOpenID(ctx, "openid-x")
|
got, err := p.GetUserByWechatOpenID(ctx, "openid-x")
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package voice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 火山引擎 V3 大模型流式语音识别(SeedASR / SAUC)客户端。协议见 voice/frame.go 与 voice-jarvis 记忆。
|
||||||
|
// 端点固定;鉴权走**新版控制台 API Key**(单个 X-Api-Key,见 frame.go)。
|
||||||
|
|
||||||
|
const asrEndpoint = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
|
||||||
|
|
||||||
|
// ASRResult 是一次识别回传。Err 非空表示识别流出错/结束(此后 Results 关闭)。
|
||||||
|
type ASRResult struct {
|
||||||
|
Text string
|
||||||
|
Final bool
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASRSession 是一路流式识别会话:PushAudio 喂 PCM、Results 出转写、Finish 收尾、Close 关闭。
|
||||||
|
type ASRSession struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
results chan ASRResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 初始配置请求 JSON ----
|
||||||
|
|
||||||
|
type asrReq struct {
|
||||||
|
User asrUser `json:"user"`
|
||||||
|
Audio asrAudio `json:"audio"`
|
||||||
|
Request asrOptions `json:"request"`
|
||||||
|
}
|
||||||
|
type asrUser struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
|
}
|
||||||
|
type asrAudio struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
Rate int `json:"rate"`
|
||||||
|
Bits int `json:"bits"`
|
||||||
|
Channel int `json:"channel"`
|
||||||
|
Codec string `json:"codec"`
|
||||||
|
}
|
||||||
|
type asrVAD struct {
|
||||||
|
VadEnable bool `json:"vad_enable"`
|
||||||
|
EndWindowSize int `json:"end_window_size"`
|
||||||
|
}
|
||||||
|
type asrOptions struct {
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
EnableITN bool `json:"enable_itn"`
|
||||||
|
EnablePunc bool `json:"enable_punc"`
|
||||||
|
ResultType string `json:"result_type"`
|
||||||
|
VAD asrVAD `json:"vad"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildASRRequest 组初始配置 JSON。音频格式与客户端采集一致(PCM 16k 单声道 raw)。
|
||||||
|
// format="pcm" 是联调可调点(火山对 raw PCM 也接受 "raw")。
|
||||||
|
func buildASRRequest(uid string) []byte {
|
||||||
|
if uid == "" {
|
||||||
|
uid = "sundynix"
|
||||||
|
}
|
||||||
|
r := asrReq{
|
||||||
|
User: asrUser{UID: uid},
|
||||||
|
Audio: asrAudio{Format: "pcm", Rate: AudioSampleRate, Bits: AudioBits, Channel: AudioChannels, Codec: "raw"},
|
||||||
|
Request: asrOptions{
|
||||||
|
ModelName: "bigmodel", Language: "zh", EnableITN: true, EnablePunc: true,
|
||||||
|
ResultType: "0", VAD: asrVAD{VadEnable: true, EndWindowSize: 800},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(r)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConnectID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartASR 连火山流式识别、发初始配置帧,返回会话。读 goroutine 持续把结果推入 Results。
|
||||||
|
func StartASR(ctx context.Context, cfg Config, uid string) (*ASRSession, error) {
|
||||||
|
if !cfg.ASREnabled() {
|
||||||
|
return nil, fmt.Errorf("ASR 未配置")
|
||||||
|
}
|
||||||
|
hdr := http.Header{}
|
||||||
|
setVolcAuthHeaders(hdr, cfg.APIKey, cfg.ASRResourceID)
|
||||||
|
|
||||||
|
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
|
||||||
|
conn, resp, err := dialer.DialContext(ctx, asrEndpoint, hdr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("连接火山 ASR 失败: %w%s", err, handshakeDetail(resp))
|
||||||
|
}
|
||||||
|
if err := conn.WriteMessage(websocket.BinaryMessage, jsonFrame(msgFullClientReq, flagNone, buildASRRequest(uid))); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("发送 ASR 配置失败: %w", err)
|
||||||
|
}
|
||||||
|
s := &ASRSession{conn: conn, results: make(chan ASRResult, 16)}
|
||||||
|
go s.readLoop()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ASRSession) readLoop() {
|
||||||
|
defer close(s.results)
|
||||||
|
for {
|
||||||
|
mt, data, err := s.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
s.results <- ASRResult{Err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mt != websocket.BinaryMessage {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msgType, payload, ok := parseServerFrame(data)
|
||||||
|
if !ok || len(payload) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if msgType == msgServerError {
|
||||||
|
s.results <- ASRResult{Err: fmt.Errorf("火山 ASR 错误: %s", strings.TrimSpace(string(payload)))}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if text, final, ok := parseASRResult(payload); ok {
|
||||||
|
s.results <- ASRResult{Text: text, Final: final}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushAudio 喂一帧 PCM。
|
||||||
|
func (s *ASRSession) PushAudio(pcm []byte) error {
|
||||||
|
return s.conn.WriteMessage(websocket.BinaryMessage, audioFrame(pcm, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish 发结束标记(空音频 + 最终帧),告知火山本轮说完。
|
||||||
|
func (s *ASRSession) Finish() error {
|
||||||
|
return s.conn.WriteMessage(websocket.BinaryMessage, audioFrame(nil, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Results 返回识别结果流(部分/最终;出错或结束时推一条 Err 后关闭)。
|
||||||
|
func (s *ASRSession) Results() <-chan ASRResult { return s.results }
|
||||||
|
|
||||||
|
// Close 关闭底层连接(读 goroutine 随之退出)。
|
||||||
|
func (s *ASRSession) Close() { _ = s.conn.Close() }
|
||||||
|
|
||||||
|
// parseASRResult 从响应 JSON 提取转写文本 + 是否最终。空文本且非最终时 ok=false。
|
||||||
|
func parseASRResult(payload []byte) (text string, final bool, ok bool) {
|
||||||
|
var r struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(payload, &r) != nil {
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
final = r.Type == "final"
|
||||||
|
text = extractText(r.Result)
|
||||||
|
return text, final, text != "" || final
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractText 从 result 里取转写文本;result 可为 [{text}]/{text}/"string"。
|
||||||
|
func extractText(raw json.RawMessage) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var arr []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &arr) == nil && len(arr) > 0 {
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, a := range arr {
|
||||||
|
sb.WriteString(a.Text)
|
||||||
|
}
|
||||||
|
if sb.Len() > 0 {
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var obj struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &obj) == nil && obj.Text != "" {
|
||||||
|
return obj.Text
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package voice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 初始配置 JSON 关键字段必须对(错了火山直接拒识别)。
|
||||||
|
func TestBuildASRRequest(t *testing.T) {
|
||||||
|
var r asrReq
|
||||||
|
if err := json.Unmarshal(buildASRRequest("u1"), &r); err != nil {
|
||||||
|
t.Fatalf("配置 JSON 不合法: %v", err)
|
||||||
|
}
|
||||||
|
if r.User.UID != "u1" {
|
||||||
|
t.Fatalf("uid 应 u1,得 %q", r.User.UID)
|
||||||
|
}
|
||||||
|
if r.Audio.Rate != 16000 || r.Audio.Bits != 16 || r.Audio.Channel != 1 {
|
||||||
|
t.Fatalf("音频参数应 16k/16bit/单声道,得 %+v", r.Audio)
|
||||||
|
}
|
||||||
|
if r.Request.ModelName != "bigmodel" || !r.Request.VAD.VadEnable {
|
||||||
|
t.Fatalf("model/vad 配置错:%+v", r.Request)
|
||||||
|
}
|
||||||
|
// 空 uid 兜底
|
||||||
|
var r2 asrReq
|
||||||
|
_ = json.Unmarshal(buildASRRequest(""), &r2)
|
||||||
|
if r2.User.UID == "" {
|
||||||
|
t.Fatal("空 uid 应兜底非空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// result 三种形态(数组/对象/字符串)都要能取出文本;final 由 type 决定。
|
||||||
|
func TestParseASRResult(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
payload string
|
||||||
|
wantText string
|
||||||
|
wantFinal bool
|
||||||
|
}{
|
||||||
|
{`{"type":"interim","result":[{"text":"你好"}]}`, "你好", false},
|
||||||
|
{`{"type":"final","result":[{"text":"你好"},{"text":"世界"}]}`, "你好世界", true},
|
||||||
|
{`{"type":"final","result":{"text":"单对象"}}`, "单对象", true},
|
||||||
|
{`{"type":"interim","result":"纯字符串"}`, "纯字符串", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
text, final, ok := parseASRResult([]byte(c.payload))
|
||||||
|
if !ok || text != c.wantText || final != c.wantFinal {
|
||||||
|
t.Fatalf("parse(%s)=%q,%v,%v want %q,%v", c.payload, text, final, ok, c.wantText, c.wantFinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 坏 JSON 不 panic、ok=false
|
||||||
|
if _, _, ok := parseASRResult([]byte("not json")); ok {
|
||||||
|
t.Fatal("坏 JSON 应 ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package voice
|
||||||
|
|
||||||
|
import "github.com/sundynix/sundynix-shared/secrets"
|
||||||
|
|
||||||
|
// Config 是语音交互(火山引擎豆包语音)所需配置。APIKey 加密入库、后台明文回显(同微信配置)。
|
||||||
|
//
|
||||||
|
// 用**新版 API Key 鉴权**(非旧版 appid+access_token):WS 握手只带两个 header——
|
||||||
|
// Authorization(Bearer <APIKey>)+ X-Api-Resource-Id(区分服务,ASR 与双向 TTS 各一个)。
|
||||||
|
// 端点/音频格式(PCM 16k 单声道等)由代码固定,不入用户配置。
|
||||||
|
type Config struct {
|
||||||
|
APIKey string `json:"api_key"` // 新版控制台 API Key(X-Api-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 分别报告耳朵、嘴是否配齐(共用同一 API Key,各自还需对应 resource-id)。
|
||||||
|
func (c Config) ASREnabled() bool { return c.APIKey != "" && c.ASRResourceID != "" }
|
||||||
|
func (c Config) TTSEnabled() bool {
|
||||||
|
return c.APIKey != "" && c.TTSResourceID != "" && c.TTSVoiceType != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled 报告语音整体是否可用(耳朵 + 嘴都配齐)。
|
||||||
|
func (c Config) Enabled() bool { return c.ASREnabled() && c.TTSEnabled() }
|
||||||
|
|
||||||
|
// EncryptedForStore 返回 APIKey 已加密的副本,用于落库。
|
||||||
|
func (c Config) EncryptedForStore() (Config, error) {
|
||||||
|
if c.APIKey == "" {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
enc, err := secrets.Encrypt(c.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
c.APIKey = enc
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptFromStore 把库内密文 APIKey 还原为明文。
|
||||||
|
func (c Config) DecryptFromStore() Config {
|
||||||
|
if c.APIKey != "" {
|
||||||
|
if plain, err := secrets.Decrypt(c.APIKey); err == nil {
|
||||||
|
c.APIKey = plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package voice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setVolcAuthHeaders 按火山「新版控制台」API Key 鉴权挂头(文档 6561/1354869「新版本控制台」表)。
|
||||||
|
// 新版只需**单个 X-Api-Key**(控制台的 APP Key)——**不用**旧版的 X-Api-App-Key+X-Api-Access-Key+App-ID,
|
||||||
|
// 也不是 Authorization: Bearer。此前误用旧 5 头方案,火山始终 401 "grant not found in SaaS storage"。
|
||||||
|
// - X-Api-Key = 控制台 API Key(用户那把 key)
|
||||||
|
// - X-Api-Resource-Id= 资源 ID(如 volc.bigasr.sauc.duration / volc.seedasr.sauc.duration / volc.service_type.10029)
|
||||||
|
// - X-Api-Request-Id = 随机 UUID
|
||||||
|
// - X-Api-Sequence = 固定 "-1"
|
||||||
|
func setVolcAuthHeaders(hdr http.Header, apiKey, resourceID string) {
|
||||||
|
hdr["X-Api-Key"] = []string{apiKey}
|
||||||
|
hdr["X-Api-Resource-Id"] = []string{resourceID}
|
||||||
|
hdr["X-Api-Request-Id"] = []string{newConnectID()}
|
||||||
|
hdr["X-Api-Sequence"] = []string{"-1"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handshakeDetail 从失败的 WS 握手响应里榨出可诊断信息:HTTP 状态 + 火山排障用的
|
||||||
|
// X-Tt-Logid + 响应体(鉴权/权限/路径错都在这里能看出来)。resp 为 nil 时返回空串。
|
||||||
|
func handshakeDetail(resp *http.Response) string {
|
||||||
|
if resp == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
body := ""
|
||||||
|
if resp.Body != nil {
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
body = strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
logid := resp.Header.Get("X-Tt-Logid")
|
||||||
|
return fmt.Sprintf(" [HTTP %d logid=%s body=%q]", resp.StatusCode, logid, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 火山语音 WebSocket 二进制帧编解码(ASR / TTS 共用)。协议(从官方参考实现核实):
|
||||||
|
//
|
||||||
|
// byte0 = 0x11 版本(0001) + 头长(0001,=4字节)
|
||||||
|
// byte1 = (msgType<<4) | flags
|
||||||
|
// byte2 = (serialization<<4) | compression
|
||||||
|
// byte3 = 0x00 保留
|
||||||
|
// byte4..7 = uint32 大端 payload 长度
|
||||||
|
// byte8.. = payload(JSON 或 raw 音频)
|
||||||
|
//
|
||||||
|
// 服务端响应帧含 4 字节序列号,故 payload 从第 12 字节起(header4 + seq4 + size4)。
|
||||||
|
|
||||||
|
// 消息类型(高 4bit)。
|
||||||
|
const (
|
||||||
|
msgFullClientReq byte = 0x01 // 初始配置请求(JSON)
|
||||||
|
msgAudioReq byte = 0x02 // 音频帧
|
||||||
|
msgServerResp byte = 0x09 // 服务端识别/合成结果
|
||||||
|
msgServerError byte = 0x0F // 服务端错误
|
||||||
|
)
|
||||||
|
|
||||||
|
// flags(低 4bit)。
|
||||||
|
const (
|
||||||
|
flagNone byte = 0x00
|
||||||
|
flagLast byte = 0x02 // 最终/结束帧(最后一帧音频、或收尾)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 序列化方式(byte2 高 4bit)。
|
||||||
|
const (
|
||||||
|
serialRaw byte = 0x00 // raw(音频帧)
|
||||||
|
serialJSON byte = 0x01 // JSON(配置/文本)
|
||||||
|
)
|
||||||
|
|
||||||
|
const compNone byte = 0x00 // 不压缩
|
||||||
|
|
||||||
|
// encodeFrame 组装一帧。JSON payload 用 serialJSON,raw 音频用 serialRaw。
|
||||||
|
func encodeFrame(msgType, flags, serialization byte, payload []byte) []byte {
|
||||||
|
buf := make([]byte, 8+len(payload))
|
||||||
|
buf[0] = 0x11
|
||||||
|
buf[1] = (msgType << 4) | (flags & 0x0F)
|
||||||
|
buf[2] = (serialization << 4) | compNone
|
||||||
|
buf[3] = 0x00
|
||||||
|
binary.BigEndian.PutUint32(buf[4:8], uint32(len(payload)))
|
||||||
|
copy(buf[8:], payload)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonFrame 组装一帧 JSON 消息(如 ASR 初始配置、TTS 文本)。
|
||||||
|
func jsonFrame(msgType, flags byte, payload []byte) []byte {
|
||||||
|
return encodeFrame(msgType, flags, serialJSON, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioFrame 组装一帧 raw 音频;last=true 时打最终标记。
|
||||||
|
func audioFrame(pcm []byte, last bool) []byte {
|
||||||
|
flags := flagNone
|
||||||
|
if last {
|
||||||
|
flags = flagLast
|
||||||
|
}
|
||||||
|
return encodeFrame(msgAudioReq, flags, serialRaw, pcm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseServerFrame 解析服务端帧:返回消息类型 + payload。响应含 4B 序列号 → payload 从第 12 字节起。
|
||||||
|
// 帧不足以解析时 ok=false。
|
||||||
|
func parseServerFrame(data []byte) (msgType byte, payload []byte, ok bool) {
|
||||||
|
if len(data) < 4 {
|
||||||
|
return 0, nil, false
|
||||||
|
}
|
||||||
|
msgType = (data[1] >> 4) & 0x0F
|
||||||
|
if len(data) >= 12 {
|
||||||
|
return msgType, data[12:], true
|
||||||
|
}
|
||||||
|
return msgType, nil, true // 无 payload 的控制帧(如纯确认)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package voice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 帧头布局必须精确匹配火山协议(错一个 bit 服务端就拒)。
|
||||||
|
func TestEncodeFrame_HeaderLayout(t *testing.T) {
|
||||||
|
f := jsonFrame(msgFullClientReq, flagNone, []byte(`{"a":1}`))
|
||||||
|
if f[0] != 0x11 {
|
||||||
|
t.Fatalf("byte0 应 0x11,得 %#x", f[0])
|
||||||
|
}
|
||||||
|
if f[1] != (msgFullClientReq<<4)|flagNone { // 0x10
|
||||||
|
t.Fatalf("byte1 应 0x10,得 %#x", f[1])
|
||||||
|
}
|
||||||
|
if f[2] != (serialJSON<<4)|compNone { // 0x10
|
||||||
|
t.Fatalf("byte2(JSON) 应 0x10,得 %#x", f[2])
|
||||||
|
}
|
||||||
|
if got := binary.BigEndian.Uint32(f[4:8]); got != 7 {
|
||||||
|
t.Fatalf("payload 长度应 7,得 %d", got)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(f[8:], []byte(`{"a":1}`)) {
|
||||||
|
t.Fatalf("payload 错:%s", f[8:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 音频帧:raw 序列化 + 最终标记。
|
||||||
|
func TestAudioFrame_LastFlag(t *testing.T) {
|
||||||
|
f := audioFrame([]byte{1, 2, 3}, true)
|
||||||
|
if f[1] != (msgAudioReq<<4)|flagLast { // 0x22
|
||||||
|
t.Fatalf("音频最终帧 byte1 应 0x22,得 %#x", f[1])
|
||||||
|
}
|
||||||
|
if f[2] != (serialRaw<<4)|compNone { // 0x00
|
||||||
|
t.Fatalf("音频 byte2 应 0x00(raw),得 %#x", f[2])
|
||||||
|
}
|
||||||
|
non := audioFrame([]byte{1}, false)
|
||||||
|
if non[1]&0x0F != flagNone {
|
||||||
|
t.Fatalf("非最终帧 flags 应 0,得 %#x", non[1]&0x0F)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务端帧解析:跳 12 字节头(含序列号)取 payload;识别结果类型 0x09。
|
||||||
|
func TestParseServerFrame(t *testing.T) {
|
||||||
|
// 造一个响应帧:header(4) + seq(4) + size(4) + payload
|
||||||
|
body := []byte(`{"type":"final"}`)
|
||||||
|
resp := make([]byte, 12+len(body))
|
||||||
|
resp[1] = (msgServerResp << 4) // 0x90
|
||||||
|
copy(resp[12:], body)
|
||||||
|
mt, payload, ok := parseServerFrame(resp)
|
||||||
|
if !ok || mt != msgServerResp {
|
||||||
|
t.Fatalf("应解析出 msgServerResp,得 mt=%#x ok=%v", mt, ok)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(payload, body) {
|
||||||
|
t.Fatalf("payload 应从第 12 字节起,得 %s", payload)
|
||||||
|
}
|
||||||
|
// 过短帧不 panic
|
||||||
|
if _, _, ok := parseServerFrame([]byte{0x11}); ok {
|
||||||
|
t.Fatal("过短帧应 ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package voice
|
||||||
|
|
||||||
|
// 客户端 ↔ 网关的单条 WebSocket 消息协议(设计 VOICE_DESIGN.md D3):
|
||||||
|
// 一条连接同时承载上行音频、下行转写、下行 TTS 音频,用「帧类型 + JSON 消息类型」区分。
|
||||||
|
//
|
||||||
|
// - 二进制帧(BinaryMessage):纯音频 PCM
|
||||||
|
// · 上行 = 用户麦克风音频(喂 ASR)
|
||||||
|
// · 下行 = Agent 回答的 TTS 音频(客户端播放)
|
||||||
|
// - 文本帧(TextMessage, JSON):控制与事件(下方 ClientMsg / ServerMsg)
|
||||||
|
|
||||||
|
// ClientMsg 是客户端发来的控制消息(文本帧)。音频走二进制帧,不在此。
|
||||||
|
type ClientMsg struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
// start:一轮语音开始(可带当前画布编排图,用它跑而非默认 DSL)
|
||||||
|
Graph string `json:"graph,omitempty"`
|
||||||
|
// 其它类型无额外字段:end(用户说完)、barge_in(打断,用户又开口)、bye(结束会话)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户端消息类型。
|
||||||
|
const (
|
||||||
|
ClientStart = "start" // 一轮语音开始
|
||||||
|
ClientEnd = "end" // 用户说完(静音检测或手动结束)→ 触发任务
|
||||||
|
ClientBargeIn = "barge_in" // 打断:用户在 Agent 说话时又开口 → 停 TTS
|
||||||
|
ClientBye = "bye" // 结束整个语音会话
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerMsg 是网关下发的控制/事件消息(文本帧)。TTS 音频走二进制帧,不在此。
|
||||||
|
type ServerMsg struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
// transcript:ASR 转写(Final=false 为实时部分结果,true 为最终)
|
||||||
|
// reply:Agent 回答的增量文本(打字机效果,逐 token 下发,早于音频)
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Final bool `json:"final,omitempty"`
|
||||||
|
// task:转写完成、任务已提交,带 task_id 供客户端切运行视图
|
||||||
|
TaskID string `json:"task_id,omitempty"`
|
||||||
|
// error:出错文案
|
||||||
|
Msg string `json:"msg,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务端消息类型。
|
||||||
|
const (
|
||||||
|
ServerReady = "ready" // 会话就绪,可以开始说话
|
||||||
|
ServerTranscript = "transcript" // ASR 转写结果(部分/最终)
|
||||||
|
ServerTask = "task" // 任务已提交(带 task_id)
|
||||||
|
ServerReply = "reply" // Agent 回答增量文本(打字机;逐 token,早于音频)
|
||||||
|
ServerSpeaking = "speaking" // Agent 开始出声(首段 TTS 音频将至)
|
||||||
|
ServerTTSEnd = "tts_end" // 本轮 TTS 播放完毕
|
||||||
|
ServerError = "error" // 出错
|
||||||
|
)
|
||||||
|
|
||||||
|
// 音频格式(与火山 ASR/TTS 约定,客户端按此采集/播放)。
|
||||||
|
const (
|
||||||
|
AudioSampleRate = 16000 // 上行 ASR:16kHz
|
||||||
|
AudioBits = 16 // 16bit
|
||||||
|
AudioChannels = 1 // 单声道
|
||||||
|
)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Package voice 是语音交互(JARVIS)的网关内实现:把 LLM token 流转成语音、把用户语音转成任务。
|
||||||
|
// 设计见仓库 VOICE_DESIGN.md。本文件是下行 TTS 的核心:token 流攒句器(纯逻辑,无外部依赖)。
|
||||||
|
package voice
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// 攒句阈值:从句标点处断句前,至少要攒够这么多 rune,避免"你好,"这种半截就吐给 TTS。
|
||||||
|
const defaultMinClause = 12
|
||||||
|
|
||||||
|
// 首块阈值:本轮**第一次**出声用更低的门槛,让首字尽快合成、尽快听见(抢首字延迟)。
|
||||||
|
// 之后回到 defaultMinClause 保后续朗读顺畅、不碎。
|
||||||
|
const firstClauseMin = 5
|
||||||
|
|
||||||
|
// 句末标点:命中即成一句吐给 TTS。
|
||||||
|
func isSentenceEnd(r rune) bool {
|
||||||
|
switch r {
|
||||||
|
case '。', '!', '?', '.', '!', '?', '\n', ';', ';':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从句标点:命中且已攒够长度才断句(让长句尽早出声,又不至于碎成单字)。
|
||||||
|
func isClauseEnd(r rune) bool {
|
||||||
|
switch r {
|
||||||
|
case ',', ',', ':', ':':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SentenceBuffer 把 LLM 的逐 token 输出攒成"适合喂 TTS 的句子片段"。
|
||||||
|
// 逐字喂 TTS 太碎(单字合成不自然、首包延迟高);攒到句末标点即吐一句;攒到从句标点且够长也吐,
|
||||||
|
// 避免长句迟迟不出声。非并发安全——单个 VoiceSession 的下行 goroutine 串行使用。
|
||||||
|
type SentenceBuffer struct {
|
||||||
|
buf strings.Builder
|
||||||
|
minClause int
|
||||||
|
emitted bool // 本轮是否已出过第一句(决定用首块低阈值还是常规阈值)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSentenceBuffer 建一个默认阈值的攒句器。
|
||||||
|
func NewSentenceBuffer() *SentenceBuffer { return &SentenceBuffer{minClause: defaultMinClause} }
|
||||||
|
|
||||||
|
// clauseThreshold 首句用低阈值抢首字延迟,之后回常规阈值。
|
||||||
|
func (b *SentenceBuffer) clauseThreshold() int {
|
||||||
|
if !b.emitted {
|
||||||
|
return firstClauseMin
|
||||||
|
}
|
||||||
|
return b.minClause
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push 追加一段 token 文本,返回本次可以立即吐给 TTS 的完整句子(0..N 句,已 Trim 两端空白)。
|
||||||
|
// 未成句的尾巴留在内部缓冲,等后续 token 或 Flush。
|
||||||
|
func (b *SentenceBuffer) Push(text string) []string {
|
||||||
|
b.buf.WriteString(text)
|
||||||
|
runes := []rune(b.buf.String())
|
||||||
|
var out []string
|
||||||
|
lastCut := 0
|
||||||
|
for i, r := range runes {
|
||||||
|
cut := false
|
||||||
|
if isSentenceEnd(r) {
|
||||||
|
cut = true
|
||||||
|
} else if isClauseEnd(r) && (i+1-lastCut) >= b.clauseThreshold() {
|
||||||
|
cut = true
|
||||||
|
}
|
||||||
|
if cut {
|
||||||
|
if s := strings.TrimSpace(string(runes[lastCut : i+1])); s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
b.emitted = true // 出过一句后,后续回常规阈值(不碎)
|
||||||
|
}
|
||||||
|
lastCut = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.buf.Reset()
|
||||||
|
b.buf.WriteString(string(runes[lastCut:])) // 留下未成句的尾巴
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush 收尾:吐出缓冲里剩余的所有文字(可能不带句末标点,如模型直接结束)。空则返回空串。
|
||||||
|
func (b *SentenceBuffer) Flush() string {
|
||||||
|
s := strings.TrimSpace(b.buf.String())
|
||||||
|
b.buf.Reset()
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package voice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 逐 token 喂入,句末标点成句、跨 Push 的半句能续上。
|
||||||
|
func TestSentenceBuffer_SplitsAcrossPushes(t *testing.T) {
|
||||||
|
b := NewSentenceBuffer()
|
||||||
|
var got []string
|
||||||
|
// 模拟 token 流一个字一个字来
|
||||||
|
for _, tok := range []string{"帮", "你", "查", "一下", "天气", "。", "今天", "晴", ",", "气温二十五度", "。"} {
|
||||||
|
got = append(got, b.Push(tok)...)
|
||||||
|
}
|
||||||
|
got = append(got, nonEmpty(b.Flush())...)
|
||||||
|
want := []string{"帮你查一下天气。", "今天晴,气温二十五度。"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从句标点:够长才断(短逗号不单吐,攒进句末标点成一句)。
|
||||||
|
func TestSentenceBuffer_ShortClauseNotSplit(t *testing.T) {
|
||||||
|
b := NewSentenceBuffer()
|
||||||
|
out := b.Push("你好,") // 3 rune < 12 阈值,不该断
|
||||||
|
if len(out) != 0 {
|
||||||
|
t.Fatalf("短从句不该断句,得 %v", out)
|
||||||
|
}
|
||||||
|
out = append(out, b.Push("在。")...) // 到句末标点 → 整句吐
|
||||||
|
if len(out) != 1 || out[0] != "你好,在。" {
|
||||||
|
t.Fatalf("短从句应攒到句末再吐,得 %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 长从句:够长即在逗号处先吐,让长回答尽早出声。
|
||||||
|
func TestSentenceBuffer_LongClauseSplits(t *testing.T) {
|
||||||
|
b := NewSentenceBuffer()
|
||||||
|
out := b.Push("关于人工智能在医疗领域的应用,")
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("长从句应在逗号处先吐一段,得 %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush 收尾:模型没给句末标点也要把剩余吐出。
|
||||||
|
func TestSentenceBuffer_FlushRemainder(t *testing.T) {
|
||||||
|
b := NewSentenceBuffer()
|
||||||
|
b.Push("这是没有标点的结尾")
|
||||||
|
if s := b.Flush(); s != "这是没有标点的结尾" {
|
||||||
|
t.Fatalf("Flush 应吐出剩余,得 %q", s)
|
||||||
|
}
|
||||||
|
if s := b.Flush(); s != "" {
|
||||||
|
t.Fatalf("再 Flush 应为空,得 %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonEmpty(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{s}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package voice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ttsDebug 打开时(环境变量 VOICE_DEBUG=1)打印每个 TTS 帧,便于联调双向流事件/音频。
|
||||||
|
var ttsDebug = os.Getenv("VOICE_DEBUG") != ""
|
||||||
|
|
||||||
|
func ttsDebugf(format string, a ...any) {
|
||||||
|
if ttsDebug {
|
||||||
|
log.Printf("[tts-debug] "+format, a...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// preview 取 payload 前 n 字节的可读预览(音频用长度代替)。
|
||||||
|
func preview(b []byte, isAudio bool) string {
|
||||||
|
if isAudio {
|
||||||
|
return fmt.Sprintf("<audio %d bytes>", len(b))
|
||||||
|
}
|
||||||
|
if len(b) > 160 {
|
||||||
|
return string(b[:160]) + "…"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 火山「双向流式 TTS V3」客户端(seed-tts-2.0)。边推文字边收音频,配 LLM token 流做连续朗读。
|
||||||
|
// 帧协议见 tts_frame.go;鉴权走**新版控制台 API Key**(单个 X-Api-Key,见 frame.go)。
|
||||||
|
//
|
||||||
|
// 一轮朗读的生命周期:StartTTS(连接+StartSession) → Speak(逐句推文字/事件200) → Finish(FinishSession)
|
||||||
|
// → 客户端 range Audio() 收音频直到 channel 关闭 → Close()。一个 TTSSession = 一次 Agent 回答。
|
||||||
|
|
||||||
|
const (
|
||||||
|
ttsEndpoint = "wss://openspeech.bytedance.com/api/v3/tts/bidirection"
|
||||||
|
TTSSampleRate = 24000 // 双向 TTS 回 PCM 24kHz 单声道(客户端按此播放)
|
||||||
|
)
|
||||||
|
|
||||||
|
// TTSSession 是一路双向 TTS 会话。Speak 推文字、Audio 出 PCM、Finish 收尾、Close 关闭。
|
||||||
|
type TTSSession struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
sessionID string
|
||||||
|
reqBase map[string]any // req_params 基底(speaker+audio_params),Speak 时加 text 复用
|
||||||
|
audio chan []byte
|
||||||
|
closed chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
mu sync.Mutex
|
||||||
|
writeMu sync.Mutex // 串行化对火山连接的写(Speak 与 Finish 可能并发)
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ttsReqBase 组双向流式 TTS 的 req_params 基底(音色 + 音频参数 PCM 24k)。
|
||||||
|
// 对齐官方 python demo:StartSession 与 TaskRequest 都带这个 req_params,TaskRequest 再往里加 text。
|
||||||
|
func ttsReqBase(cfg Config) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"speaker": cfg.TTSVoiceType,
|
||||||
|
"audio_params": map[string]any{
|
||||||
|
"format": "pcm",
|
||||||
|
"sample_rate": TTSSampleRate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTTSStartSession 组 StartSession payload:{req_params:{speaker,audio_params}}(无 namespace/user)。
|
||||||
|
func buildTTSStartSession(base map[string]any) []byte {
|
||||||
|
b, _ := json.Marshal(map[string]any{"req_params": base})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTTS 连火山双向 TTS,握手(StartConnection→StartSession)成功后返回会话。
|
||||||
|
func StartTTS(ctx context.Context, cfg Config) (*TTSSession, error) {
|
||||||
|
if !cfg.TTSEnabled() {
|
||||||
|
return nil, fmt.Errorf("TTS 未配置")
|
||||||
|
}
|
||||||
|
hdr := http.Header{}
|
||||||
|
setVolcAuthHeaders(hdr, cfg.APIKey, cfg.TTSResourceID)
|
||||||
|
|
||||||
|
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
|
||||||
|
conn, resp, err := dialer.DialContext(ctx, ttsEndpoint, hdr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("连接火山 TTS 失败: %w%s", err, handshakeDetail(resp))
|
||||||
|
}
|
||||||
|
sid := newConnectID()
|
||||||
|
base := ttsReqBase(cfg)
|
||||||
|
|
||||||
|
// StartConnection → 期待 ConnectionStarted。
|
||||||
|
if err := conn.WriteMessage(websocket.BinaryMessage, connEventFrame(evStartConnection, []byte("{}"))); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("发送 StartConnection 失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := expectTTSEvent(conn, evConnectionStarted); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// StartSession → 期待 SessionStarted。
|
||||||
|
if err := conn.WriteMessage(websocket.BinaryMessage, sessionEventFrame(evStartSession, sid, buildTTSStartSession(base))); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("发送 StartSession 失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := expectTTSEvent(conn, evSessionStarted); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &TTSSession{
|
||||||
|
conn: conn, sessionID: sid, reqBase: base,
|
||||||
|
audio: make(chan []byte, 64), closed: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go s.readLoop()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// expectTTSEvent 同步读一帧,校验是期望的事件(握手阶段用,此时还没起 readLoop)。
|
||||||
|
func expectTTSEvent(conn *websocket.Conn, want int32) error {
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
_, data, err := conn.ReadMessage()
|
||||||
|
conn.SetReadDeadline(time.Time{}) // 清除
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读 TTS 握手响应失败: %w", err)
|
||||||
|
}
|
||||||
|
r, perr := parseTTSFrame(data)
|
||||||
|
if perr != nil {
|
||||||
|
return fmt.Errorf("解析 TTS 握手响应失败: %w", perr)
|
||||||
|
}
|
||||||
|
ttsDebugf("握手帧: event=%d msgType=%d payload=%s", r.Event, r.MsgType, preview(r.Payload, r.IsAudio))
|
||||||
|
if r.MsgType == ttsServerErr {
|
||||||
|
return fmt.Errorf("TTS 握手被拒 code=%d: %s", r.Code, string(r.Payload))
|
||||||
|
}
|
||||||
|
if r.Event != want && r.Event != 0 { // 0=未带事件号的容错
|
||||||
|
return fmt.Errorf("TTS 握手期待事件 %d,得 %d", want, r.Event)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speak 推一段文字给 TTS(TaskRequest / 事件 200)。可多次调用逐句推。
|
||||||
|
// payload 必须带完整 req_params(speaker+audio_params)再加 text——只发 {text} 火山收不到文字
|
||||||
|
// (联调实证:会回一个空 text 的 TTSSentenceStart 后直接结束,无音频)。
|
||||||
|
func (s *TTSSession) Speak(text string) error {
|
||||||
|
rp := make(map[string]any, len(s.reqBase)+1)
|
||||||
|
for k, v := range s.reqBase {
|
||||||
|
rp[k] = v
|
||||||
|
}
|
||||||
|
rp["text"] = text
|
||||||
|
payload, _ := json.Marshal(map[string]any{"req_params": rp})
|
||||||
|
s.writeMu.Lock()
|
||||||
|
defer s.writeMu.Unlock()
|
||||||
|
return s.conn.WriteMessage(websocket.BinaryMessage, sessionEventFrame(evTaskRequest, s.sessionID, payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish 发 FinishSession,告知本轮文字推完;服务端把剩余音频吐完后回 SessionFinished(Audio 随之关闭)。
|
||||||
|
func (s *TTSSession) Finish() error {
|
||||||
|
s.writeMu.Lock()
|
||||||
|
defer s.writeMu.Unlock()
|
||||||
|
return s.conn.WriteMessage(websocket.BinaryMessage, sessionEventFrame(evFinishSession, s.sessionID, []byte("{}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio 返回下行音频流(PCM 24k);会话结束/出错时关闭。
|
||||||
|
func (s *TTSSession) Audio() <-chan []byte { return s.audio }
|
||||||
|
|
||||||
|
// Err 返回会话错误(Audio 关闭后读取)。
|
||||||
|
func (s *TTSSession) Err() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TTSSession) setErr(err error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.err == nil {
|
||||||
|
s.err = err
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭底层连接(读 goroutine 随之退出,Audio 关闭)。幂等。
|
||||||
|
func (s *TTSSession) Close() {
|
||||||
|
s.closeOnce.Do(func() {
|
||||||
|
close(s.closed)
|
||||||
|
_ = s.conn.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TTSSession) readLoop() {
|
||||||
|
defer close(s.audio)
|
||||||
|
for {
|
||||||
|
_, data, err := s.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return // 连接关闭 / 读错误
|
||||||
|
}
|
||||||
|
r, perr := parseTTSFrame(data)
|
||||||
|
if perr != nil {
|
||||||
|
ttsDebugf("解析失败(%d 字节): %v", len(data), perr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ttsDebugf("帧: event=%d msgType=%d isAudio=%v payload=%s", r.Event, r.MsgType, r.IsAudio, preview(r.Payload, r.IsAudio))
|
||||||
|
switch {
|
||||||
|
case r.MsgType == ttsServerErr:
|
||||||
|
s.setErr(fmt.Errorf("火山 TTS 错误 code=%d: %s", r.Code, string(r.Payload)))
|
||||||
|
return
|
||||||
|
case r.Event == evSessionFailed:
|
||||||
|
s.setErr(fmt.Errorf("火山 TTS 会话失败: %s", string(r.Payload)))
|
||||||
|
return
|
||||||
|
case r.Event == evSessionFinished || r.Event == evConnectionFinished:
|
||||||
|
return // 本轮朗读完毕
|
||||||
|
case r.IsAudio && len(r.Payload) > 0:
|
||||||
|
select {
|
||||||
|
case s.audio <- r.Payload:
|
||||||
|
case <-s.closed:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package voice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 火山「双向流式 TTS V3」二进制帧编解码。与 ASR(frame.go)是**不同族**协议:
|
||||||
|
// 这一族带「事件号 + 会话 ID + gzip payload」,是全双工事件协议(StartConnection/StartSession/
|
||||||
|
// TaskRequest/FinishSession…)。字节级布局从官方参考实现核实(generate_header / parse_response):
|
||||||
|
//
|
||||||
|
// byte0 = (version<<4)|headerSize version=1, headerSize=1(=4字节)
|
||||||
|
// byte1 = (msgType<<4)|flags flags 含 ttsFlagWithEvent 表示带事件号
|
||||||
|
// byte2 = (serialization<<4)|compression 请求一律 JSON+GZIP;音频响应为 raw
|
||||||
|
// byte3 = 0x00 保留
|
||||||
|
// [event 4B 大端] flags 带 withEvent 时存在
|
||||||
|
// [会话级事件]:sessionIdLen 4B 大端 + sessionId
|
||||||
|
// payloadLen 4B 大端 + payload(gzip)
|
||||||
|
//
|
||||||
|
// 连接级事件(StartConnection=1 / FinishConnection=2)无会话 ID;会话级(100/200/102…)带会话 ID。
|
||||||
|
|
||||||
|
// 消息类型(byte1 高 4bit)。
|
||||||
|
const (
|
||||||
|
ttsClientFull byte = 0b0001 // 客户端完整请求(JSON)
|
||||||
|
ttsClientAudio byte = 0b0010 // 客户端纯音频(TTS 上行不用;保留)
|
||||||
|
ttsServerFull byte = 0b1001 // 服务端完整响应
|
||||||
|
ttsServerAck byte = 0b1011 // 服务端 ACK(音频帧走这个)
|
||||||
|
ttsServerErr byte = 0b1111 // 服务端错误
|
||||||
|
)
|
||||||
|
|
||||||
|
// flags(byte1 低 4bit)。
|
||||||
|
const (
|
||||||
|
ttsFlagNone byte = 0b0000
|
||||||
|
ttsFlagNegSeq byte = 0b0010 // 带序列号
|
||||||
|
ttsFlagWithEvent byte = 0b0100 // 带事件号
|
||||||
|
)
|
||||||
|
|
||||||
|
// 序列化 / 压缩(byte2)。
|
||||||
|
const (
|
||||||
|
ttsSerialNone byte = 0b0000
|
||||||
|
ttsSerialJSON byte = 0b0001
|
||||||
|
ttsCompNone byte = 0b0000
|
||||||
|
ttsCompGzip byte = 0b0001
|
||||||
|
)
|
||||||
|
|
||||||
|
// 事件号(客户端发 / 服务端回)。
|
||||||
|
const (
|
||||||
|
evStartConnection int32 = 1
|
||||||
|
evFinishConnection int32 = 2
|
||||||
|
evConnectionStarted int32 = 50
|
||||||
|
evConnectionFailed int32 = 51
|
||||||
|
evConnectionFinished int32 = 52
|
||||||
|
|
||||||
|
evStartSession int32 = 100
|
||||||
|
evFinishSession int32 = 102
|
||||||
|
evSessionStarted int32 = 150
|
||||||
|
evSessionFinished int32 = 152
|
||||||
|
evSessionFailed int32 = 153
|
||||||
|
|
||||||
|
evTaskRequest int32 = 200 // 客户端发文本(TTS 逐句推)
|
||||||
|
|
||||||
|
evTTSSentenceStart int32 = 350
|
||||||
|
evTTSSentenceEnd int32 = 351
|
||||||
|
evTTSResponse int32 = 352 // 服务端回音频帧
|
||||||
|
)
|
||||||
|
|
||||||
|
func gzipBytes(b []byte) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := gzip.NewWriter(&buf)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
_ = w.Close()
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func gunzip(b []byte) ([]byte, error) {
|
||||||
|
r, err := gzip.NewReader(bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
return io.ReadAll(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ttsHeader(msgType, flags, serial, comp byte) []byte {
|
||||||
|
return []byte{
|
||||||
|
(0b0001 << 4) | 0b0001, // version=1, headerSize=1
|
||||||
|
(msgType << 4) | (flags & 0x0F),
|
||||||
|
(serial << 4) | comp,
|
||||||
|
0x00,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// connEventFrame 组连接级事件帧(无会话 ID),payload 为 JSON(gzip)。
|
||||||
|
func connEventFrame(event int32, jsonPayload []byte) []byte {
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.Write(ttsHeader(ttsClientFull, ttsFlagWithEvent, ttsSerialJSON, ttsCompGzip))
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, event)
|
||||||
|
pl := gzipBytes(jsonPayload)
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(len(pl)))
|
||||||
|
b.Write(pl)
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionEventFrame 组会话级事件帧(带会话 ID),payload 为 JSON(gzip)。
|
||||||
|
func sessionEventFrame(event int32, sessionID string, jsonPayload []byte) []byte {
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.Write(ttsHeader(ttsClientFull, ttsFlagWithEvent, ttsSerialJSON, ttsCompGzip))
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, event)
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(len(sessionID)))
|
||||||
|
b.WriteString(sessionID)
|
||||||
|
pl := gzipBytes(jsonPayload)
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(len(pl)))
|
||||||
|
b.Write(pl)
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ttsResponse 是解析后的服务端帧。
|
||||||
|
type ttsResponse struct {
|
||||||
|
MsgType byte
|
||||||
|
Event int32
|
||||||
|
Payload []byte // 音频帧=raw PCM;JSON 事件=JSON 原文;错误=错误 JSON
|
||||||
|
Code uint32 // 错误码(MsgType==ttsServerErr 时)
|
||||||
|
IsAudio bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTTSFrame 解析服务端帧(对齐参考实现 parse_response)。
|
||||||
|
func parseTTSFrame(data []byte) (*ttsResponse, error) {
|
||||||
|
if len(data) < 4 {
|
||||||
|
return nil, fmt.Errorf("帧过短 %d", len(data))
|
||||||
|
}
|
||||||
|
headerSize := int(data[0] & 0x0F)
|
||||||
|
msgType := data[1] >> 4
|
||||||
|
flags := data[1] & 0x0F
|
||||||
|
comp := data[2] & 0x0F
|
||||||
|
off := headerSize * 4
|
||||||
|
if off > len(data) {
|
||||||
|
return nil, fmt.Errorf("头长越界")
|
||||||
|
}
|
||||||
|
res := &ttsResponse{MsgType: msgType}
|
||||||
|
|
||||||
|
if msgType == ttsServerErr {
|
||||||
|
if len(data) < off+8 {
|
||||||
|
return nil, fmt.Errorf("错误帧过短")
|
||||||
|
}
|
||||||
|
res.Code = binary.BigEndian.Uint32(data[off : off+4])
|
||||||
|
sz := binary.BigEndian.Uint32(data[off+4 : off+8])
|
||||||
|
res.Payload = clampTail(data, off+8, sz)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p := off
|
||||||
|
if flags&ttsFlagNegSeq != 0 { // 跳过序列号
|
||||||
|
p += 4
|
||||||
|
}
|
||||||
|
if flags&ttsFlagWithEvent != 0 {
|
||||||
|
if len(data) < p+4 {
|
||||||
|
return nil, fmt.Errorf("事件号越界")
|
||||||
|
}
|
||||||
|
res.Event = int32(binary.BigEndian.Uint32(data[p : p+4]))
|
||||||
|
p += 4
|
||||||
|
}
|
||||||
|
// 会话 ID(有符号长度;服务端正常响应都带)。
|
||||||
|
if len(data) >= p+4 {
|
||||||
|
sidLen := int32(binary.BigEndian.Uint32(data[p : p+4]))
|
||||||
|
p += 4
|
||||||
|
if sidLen > 0 && len(data) >= p+int(sidLen) {
|
||||||
|
p += int(sidLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(data) < p+4 {
|
||||||
|
return res, nil // 无 payload 的控制帧(如 SessionFinished 可能无体)
|
||||||
|
}
|
||||||
|
sz := binary.BigEndian.Uint32(data[p : p+4])
|
||||||
|
p += 4
|
||||||
|
payload := clampTail(data, p, sz)
|
||||||
|
|
||||||
|
// 音频响应(TTSResponse/SERVER_ACK 且非 JSON)→ raw PCM,按压缩位解压。
|
||||||
|
res.IsAudio = res.Event == evTTSResponse || msgType == ttsServerAck
|
||||||
|
if comp == ttsCompGzip && len(payload) > 0 {
|
||||||
|
if dec, err := gunzip(payload); err == nil {
|
||||||
|
payload = dec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.Payload = payload
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampTail 从 off 起最多取 sz 字节(防伪造长度越界)。
|
||||||
|
func clampTail(data []byte, off int, sz uint32) []byte {
|
||||||
|
if off > len(data) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
end := off + int(sz)
|
||||||
|
if end > len(data) || sz == 0 {
|
||||||
|
end = len(data)
|
||||||
|
}
|
||||||
|
return data[off:end]
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package voice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serverFrame 按参考实现 parse_response 的布局手搓一个服务端帧,供解析测试。
|
||||||
|
// 布局:header(4) [+event4] + sessionIdLen(4) + sessionId + payloadLen(4) + payload。
|
||||||
|
func serverFrame(msgType, flags, serial, comp byte, event int32, sid string, payload []byte) []byte {
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.WriteByte((0b0001 << 4) | 0b0001)
|
||||||
|
b.WriteByte((msgType << 4) | flags)
|
||||||
|
b.WriteByte((serial << 4) | comp)
|
||||||
|
b.WriteByte(0x00)
|
||||||
|
if flags&ttsFlagWithEvent != 0 {
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, event)
|
||||||
|
}
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(len(sid)))
|
||||||
|
b.WriteString(sid)
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(len(payload)))
|
||||||
|
b.Write(payload)
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 音频响应(SERVER_ACK + 事件 352 + raw payload)应被识别为音频、取到原始 PCM。
|
||||||
|
func TestParseTTSFrame_Audio(t *testing.T) {
|
||||||
|
pcm := []byte{0x01, 0x02, 0x03, 0x04, 0xff, 0xfe}
|
||||||
|
frame := serverFrame(ttsServerAck, ttsFlagWithEvent, ttsSerialNone, ttsCompNone, evTTSResponse, "sess-1", pcm)
|
||||||
|
r, err := parseTTSFrame(frame)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析音频帧失败: %v", err)
|
||||||
|
}
|
||||||
|
if !r.IsAudio {
|
||||||
|
t.Errorf("应识别为音频帧")
|
||||||
|
}
|
||||||
|
if r.Event != evTTSResponse {
|
||||||
|
t.Errorf("event=%d,期望 %d", r.Event, evTTSResponse)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(r.Payload, pcm) {
|
||||||
|
t.Errorf("PCM 不一致:得 %v,期望 %v", r.Payload, pcm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gzip 压缩的 JSON 事件帧(如 SessionStarted)应被正确解压。
|
||||||
|
func TestParseTTSFrame_GzipJSON(t *testing.T) {
|
||||||
|
js := []byte(`{"event":"SessionStarted"}`)
|
||||||
|
frame := serverFrame(ttsServerFull, ttsFlagWithEvent, ttsSerialJSON, ttsCompGzip, evSessionStarted, "sess-1", gzipBytes(js))
|
||||||
|
r, err := parseTTSFrame(frame)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析 JSON 帧失败: %v", err)
|
||||||
|
}
|
||||||
|
if r.Event != evSessionStarted {
|
||||||
|
t.Errorf("event=%d,期望 %d", r.Event, evSessionStarted)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(r.Payload, js) {
|
||||||
|
t.Errorf("gzip 解压后 JSON 不一致:得 %s", string(r.Payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误帧(SERVER_ERROR):code(4) + payloadLen(4) + payload,无 event/session。
|
||||||
|
func TestParseTTSFrame_Error(t *testing.T) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.WriteByte((0b0001 << 4) | 0b0001)
|
||||||
|
b.WriteByte((ttsServerErr << 4) | ttsFlagNone)
|
||||||
|
b.WriteByte((ttsSerialJSON << 4) | ttsCompNone)
|
||||||
|
b.WriteByte(0x00)
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(45000001)) // code
|
||||||
|
msg := []byte(`{"error":"quota"}`)
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(len(msg)))
|
||||||
|
b.Write(msg)
|
||||||
|
|
||||||
|
r, err := parseTTSFrame(b.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析错误帧失败: %v", err)
|
||||||
|
}
|
||||||
|
if r.MsgType != ttsServerErr {
|
||||||
|
t.Errorf("msgType=%d,期望错误类型", r.MsgType)
|
||||||
|
}
|
||||||
|
if r.Code != 45000001 {
|
||||||
|
t.Errorf("code=%d,期望 45000001", r.Code)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(r.Payload, msg) {
|
||||||
|
t.Errorf("错误体不一致:得 %s", string(r.Payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户端会话级事件帧应带上事件号与会话 ID,且 payload 为 gzip。
|
||||||
|
func TestSessionEventFrame_RoundTrip(t *testing.T) {
|
||||||
|
frame := sessionEventFrame(evTaskRequest, "sess-x", []byte(`{"text":"你好"}`))
|
||||||
|
// 手工核对头两个字节 + 事件号 + 会话 ID 长度。
|
||||||
|
if frame[0] != ((0b0001<<4)|0b0001) || (frame[1]>>4) != ttsClientFull {
|
||||||
|
t.Fatalf("头字节不对: %08b %08b", frame[0], frame[1])
|
||||||
|
}
|
||||||
|
if frame[1]&0x0f&ttsFlagWithEvent == 0 {
|
||||||
|
t.Errorf("应带 withEvent flag")
|
||||||
|
}
|
||||||
|
ev := int32(binary.BigEndian.Uint32(frame[4:8]))
|
||||||
|
if ev != evTaskRequest {
|
||||||
|
t.Errorf("event=%d,期望 %d", ev, evTaskRequest)
|
||||||
|
}
|
||||||
|
sidLen := binary.BigEndian.Uint32(frame[8:12])
|
||||||
|
if int(sidLen) != len("sess-x") {
|
||||||
|
t.Errorf("sessionId 长度=%d,期望 %d", sidLen, len("sess-x"))
|
||||||
|
}
|
||||||
|
if got := string(frame[12 : 12+sidLen]); got != "sess-x" {
|
||||||
|
t.Errorf("sessionId=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,12 +31,17 @@ import (
|
|||||||
"github.com/sundynix/sundynix-shared/secrets"
|
"github.com/sundynix/sundynix-shared/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultWelcome 是关注后自动回复的默认欢迎语(管理端可覆盖,建议填上下载链接)。
|
||||||
|
const DefaultWelcome = "欢迎关注!\n下载客户端并扫码登录,即可开始体验。"
|
||||||
|
|
||||||
// Config 是公众号登录所需配置。AppSecret 加密入库、只写不回显(同微信支付 APIv3 密钥)。
|
// Config 是公众号登录所需配置。AppSecret 加密入库、只写不回显(同微信支付 APIv3 密钥)。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppID string `json:"appid"`
|
AppID string `json:"appid"`
|
||||||
AppSecret string `json:"app_secret"`
|
AppSecret string `json:"app_secret"`
|
||||||
// Token:消息推送签名校验用,与公众平台「服务器配置」里填的一致。
|
// Token:消息推送签名校验用,与公众平台「服务器配置」里填的一致。
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
|
// Welcome:用户关注后自动回复的欢迎语(被动回复)。空则用 DefaultWelcome。非密文。
|
||||||
|
Welcome string `json:"welcome"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enabled 报告配置是否完整到可用(登录二维码需要 appid+secret;回调验签需要 token)。
|
// Enabled 报告配置是否完整到可用(登录二维码需要 appid+secret;回调验签需要 token)。
|
||||||
@@ -144,9 +149,15 @@ func PullToken(ctx context.Context, tokenURL, secret string) (string, int, error
|
|||||||
return r.AccessToken, r.ExpiresIn, nil
|
return r.AccessToken, r.ExpiresIn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateLoginQR 用带参数「临时」二维码承载 scene(=登录 ticket)。
|
// CreateLoginQR 用带参数「临时」二维码承载登录 ticket。登录与邀请共用底层 CreateSceneQR。
|
||||||
// expireSec:二维码有效期,登录场景取 ticket 的 TTL。返回可直接 <img> 展示的二维码图 URL。
|
|
||||||
func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) {
|
func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) {
|
||||||
|
return c.CreateSceneQR(ctx, accessToken, scene, expireSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSceneQR 建一张带参数「临时」二维码,scene 为任意字符串(≤64 字符)。
|
||||||
|
// 登录用它承载 ticket、邀请用它承载 inv_<token>。expireSec 为二维码有效期。
|
||||||
|
// 返回可直接 <img> 展示的二维码图 URL。
|
||||||
|
func (c Config) CreateSceneQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) {
|
||||||
reqBody := map[string]any{
|
reqBody := map[string]any{
|
||||||
"expire_seconds": expireSec,
|
"expire_seconds": expireSec,
|
||||||
"action_name": "QR_STR_SCENE", // 字符串型 scene,便于放我们的随机 ticket
|
"action_name": "QR_STR_SCENE", // 字符串型 scene,便于放我们的随机 ticket
|
||||||
@@ -174,13 +185,58 @@ func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, ex
|
|||||||
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(r.Ticket), nil
|
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(r.Ticket), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event 是微信推送的事件(明文 XML)。只取登录需要的字段。
|
// SendCustomText 发一条「客服消息」文本给指定 openid(主动推送)。
|
||||||
|
// 约束:微信只允许在用户 48 小时内与公众号有过互动时下发(否则 45015 errcode)。
|
||||||
|
// 适合支付回执这类「用户刚操作完」的即时通知;隔天的提醒需改用模板消息。
|
||||||
|
// 需 access_token(走中控/直连均可)。
|
||||||
|
func SendCustomText(ctx context.Context, accessToken, openID, content string) error {
|
||||||
|
reqBody := map[string]any{
|
||||||
|
"touser": openID,
|
||||||
|
"msgtype": "text",
|
||||||
|
"text": map[string]any{"content": content},
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(reqBody)
|
||||||
|
endpoint := "https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=" + url.QueryEscape(accessToken)
|
||||||
|
body, err := httpPost(ctx, endpoint, raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
ErrMsg string `json:"errmsg"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &r); err != nil {
|
||||||
|
return fmt.Errorf("解析客服消息响应失败: %s", strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
if r.ErrCode != 0 {
|
||||||
|
return fmt.Errorf("发送客服消息失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event 是微信推送的事件(明文 XML)。只取登录/回复需要的字段。
|
||||||
type Event struct {
|
type Event struct {
|
||||||
XMLName xml.Name `xml:"xml"`
|
XMLName xml.Name `xml:"xml"`
|
||||||
MsgType string `xml:"MsgType"` // event
|
MsgType string `xml:"MsgType"` // event
|
||||||
Event string `xml:"Event"` // subscribe / SCAN / unsubscribe ...
|
Event string `xml:"Event"` // subscribe / SCAN / unsubscribe ...
|
||||||
EventKey string `xml:"EventKey"` // subscribe: qrscene_<scene>;SCAN: <scene>
|
EventKey string `xml:"EventKey"` // subscribe: qrscene_<scene>;SCAN: <scene>
|
||||||
FromUserName string `xml:"FromUserName"` // 用户 openid
|
FromUserName string `xml:"FromUserName"` // 用户 openid
|
||||||
|
ToUserName string `xml:"ToUserName"` // 公众号原始 ID(回复时作 FromUserName)
|
||||||
|
CreateTime int64 `xml:"CreateTime"` // 事件时间(秒)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSubscribe 报告是否是「新关注」事件(用于自动回复欢迎语)。
|
||||||
|
func (e Event) IsSubscribe() bool { return e.MsgType == "event" && e.Event == "subscribe" }
|
||||||
|
|
||||||
|
// BuildTextReply 组装「被动回复」的文本消息 XML(回调 HTTP 响应体)。
|
||||||
|
// 被动回复不需要 access_token、不受 IP 白名单限制,微信收到即转发给用户。
|
||||||
|
// toUser=用户 openid(=事件的 FromUserName);fromUser=公众号原始 ID(=事件的 ToUserName)。
|
||||||
|
func BuildTextReply(toUser, fromUser, content string, createTime int64) string {
|
||||||
|
content = strings.ReplaceAll(content, "]]>", "]] >") // 防 CDATA 提前闭合
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"<xml><ToUserName><![CDATA[%s]]></ToUserName><FromUserName><![CDATA[%s]]></FromUserName>"+
|
||||||
|
"<CreateTime>%d</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[%s]]></Content></xml>",
|
||||||
|
toUser, fromUser, createTime, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scene 从事件里还原出我们的 scene(登录 ticket)。subscribe 事件带 qrscene_ 前缀,SCAN 不带。
|
// Scene 从事件里还原出我们的 scene(登录 ticket)。subscribe 事件带 qrscene_ 前缀,SCAN 不带。
|
||||||
|
|||||||
@@ -97,6 +97,48 @@ func TestParseEvent_IgnoresNonLogin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关注事件要能识别(自动回复欢迎语),并解析出回复所需的 openid + 公众号原始 ID。
|
||||||
|
func TestParseEvent_SubscribeReplyFields(t *testing.T) {
|
||||||
|
sub := `<xml><ToUserName><![CDATA[gh_808ba17576cf]]></ToUserName>
|
||||||
|
<FromUserName><![CDATA[openid_x]]></FromUserName>
|
||||||
|
<CreateTime>1700000000</CreateTime>
|
||||||
|
<MsgType><![CDATA[event]]></MsgType>
|
||||||
|
<Event><![CDATA[subscribe]]></Event>
|
||||||
|
<EventKey><![CDATA[]]></EventKey></xml>`
|
||||||
|
ev, err := ParseEvent([]byte(sub))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !ev.IsSubscribe() {
|
||||||
|
t.Fatal("subscribe 应被识别为新关注")
|
||||||
|
}
|
||||||
|
if ev.ToUserName != "gh_808ba17576cf" {
|
||||||
|
t.Fatalf("公众号原始 ID 取错:%q", ev.ToUserName)
|
||||||
|
}
|
||||||
|
// 无 scene 的直接关注:不是登录扫码,但仍是 subscribe(要回欢迎语)
|
||||||
|
if ev.IsLoginScan() {
|
||||||
|
t.Fatal("无 scene 关注不该触发登录")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 被动回复 XML:ToUser=用户、FromUser=公众号、含文本内容,且防 CDATA 提前闭合。
|
||||||
|
func TestBuildTextReply(t *testing.T) {
|
||||||
|
xml := BuildTextReply("openid_user", "gh_pub", "你好]]>危险", 1700000000)
|
||||||
|
for _, want := range []string{
|
||||||
|
"<ToUserName><![CDATA[openid_user]]>",
|
||||||
|
"<FromUserName><![CDATA[gh_pub]]>",
|
||||||
|
"<MsgType><![CDATA[text]]>",
|
||||||
|
"<CreateTime>1700000000</CreateTime>",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(xml, want) {
|
||||||
|
t.Fatalf("回复 XML 缺 %q:\n%s", want, xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(xml, "你好]]>危险") {
|
||||||
|
t.Fatal("内容里的 ]]> 应被转义,避免 CDATA 提前闭合")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfig_SecretRoundTrip(t *testing.T) {
|
func TestConfig_SecretRoundTrip(t *testing.T) {
|
||||||
c := Config{AppID: "x", AppSecret: "plain-secret", Token: "t"}
|
c := Config{AppID: "x", AppSecret: "plain-secret", Token: "t"}
|
||||||
stored, err := c.EncryptedForStore()
|
stored, err := c.EncryptedForStore()
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user