Merge pull request 'fix(rag): 全文索引在容器里根本没持久化 —— 补 /data 卷 + BLEVE_PATH' (#8) from feat/site into main
deploy-132 / deploy (push) Successful in 3m45s

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-07-20 09:58:15 +00:00
48 changed files with 2803 additions and 208 deletions
+5
View File
@@ -23,6 +23,11 @@ POSTGRES_PASSWORD=
# 图数据库密码(Neo4j,知识图谱)。 # 图数据库密码(Neo4j,知识图谱)。
NEO4J_PASSWORD= NEO4J_PASSWORD=
# 全文(bleve)索引落盘目录。留空则用相对路径 .data/bleve —— 相对的是 mcp-go 的**启动目录**
# 从仓库根起和从 sundynix-mcp-go/ 起会写出两份互不相干的索引(本地排查全文召回时最容易被这个坑到)。
# 本地开发建议显式写成绝对路径;容器部署已在 compose 里固定为 /data/bleve + 持久卷。
BLEVE_PATH=
# ── 必填:管理员 ───────────────────────────────────────────── # ── 必填:管理员 ─────────────────────────────────────────────
# 管理员用户 ID 白名单(逗号分隔)。生产模式下只有这些用户能进 /admin 控制台。 # 管理员用户 ID 白名单(逗号分隔)。生产模式下只有这些用户能进 /admin 控制台。
# 首次部署:先留空起服务 → 注册第一个账号 → 从返回的 user id 填进来 → 重启 gateway。 # 首次部署:先留空起服务 → 注册第一个账号 → 从返回的 user id 填进来 → 重启 gateway。
+15
View File
@@ -31,12 +31,17 @@ services:
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-} ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN:-*} CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN:-*}
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318 OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318
# 微信域名校验文件目录(容器内路径);未设则该路由不注册
WECHAT_VERIFY_DIR: /etc/sundynix/wechat-verify
ports: ["3000:8080"] # frp 外网 → 132:3000 → 容器 8080 ports: ["3000:8080"] # frp 外网 → 132:3000 → 容器 8080
volumes: volumes:
# 微信支付证书(商户私钥 + 微信支付公钥):宿主机 132 的目录只读挂进容器。 # 微信支付证书(商户私钥 + 微信支付公钥):宿主机 132 的目录只读挂进容器。
# ⚠️ admin「系统配置 → 支付」里填的路径必须是**容器内路径**/etc/sundynix/wechat-cert/...), # ⚠️ admin「系统配置 → 支付」里填的路径必须是**容器内路径**/etc/sundynix/wechat-cert/...),
# 不是宿主机路径——容器看不到宿主机的 /home/workspace/...。私钥不进镜像、不进 git。 # 不是宿主机路径——容器看不到宿主机的 /home/workspace/...。私钥不进镜像、不进 git。
- /home/workspace/wechat-pay-cert:/etc/sundynix/wechat-cert:ro - /home/workspace/wechat-pay-cert:/etc/sundynix/wechat-cert:ro
# 微信域名校验文件(MP_verify_xxx.txt):配置 JS安全域名/网页授权域名时微信要求
# 能从域名根目录访问到。放宿主机、只读挂进来,不进镜像也不进 git。
- /home/workspace/wechat-verify:/etc/sundynix/wechat-verify:ro
dispatcher: dispatcher:
build: { context: ../.., dockerfile: sundynix-dispatcher/Dockerfile } build: { context: ../.., dockerfile: sundynix-dispatcher/Dockerfile }
@@ -72,6 +77,11 @@ services:
MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-docs} MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-docs}
SUNDYNIX_SECRET_KEY: ${SUNDYNIX_SECRET_KEY:?} SUNDYNIX_SECRET_KEY: ${SUNDYNIX_SECRET_KEY:?}
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318 OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318
# 全文(bleve)索引落盘位置。不设则相对 CWD,写进容器可写层 → 每次发版重建容器就清零,
# 而混合检索只是静默少一路召回、不报错,很难发现。必须配合下面的持久卷。
BLEVE_PATH: /data/bleve
volumes:
- mcp-go-data:/data
mcp-py: mcp-py:
build: { context: ../../sundynix-mcp-py, dockerfile: Dockerfile } build: { context: ../../sundynix-mcp-py, dockerfile: Dockerfile }
@@ -83,3 +93,8 @@ services:
# Docker 暴露给该服务(可逃逸);不需要代码执行工具可注释掉这两行。mcp-py 无对外端口。 # Docker 暴露给该服务(可逃逸);不需要代码执行工具可注释掉这两行。mcp-py 无对外端口。
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
# 全文索引持久化:随容器重建保留(向量在 Milvus、图谱在 Neo4j,都在 128
# 只有 bleve 是跟着 mcp-go 走的本地索引,唯独它需要这个卷)。
volumes:
mcp-go-data:
+6
View File
@@ -71,9 +71,14 @@ services:
MINIO_BUCKET: sundynix-docs MINIO_BUCKET: sundynix-docs
SUNDYNIX_SECRET_KEY: *secret-key SUNDYNIX_SECRET_KEY: *secret-key
OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318 OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318
# 全文(bleve)索引落盘位置。不设则相对 CWD,写进容器可写层 → 每次重建容器就清零,
# 而混合检索只是静默少一路召回、不报错,很难发现。必须配合下面的持久卷。
BLEVE_PATH: /data/bleve
depends_on: depends_on:
nats: { condition: service_started } nats: { condition: service_started }
milvus: { condition: service_healthy } # mcp-go 须在 Milvus 后起,否则工具 no responders milvus: { condition: service_healthy } # mcp-go 须在 Milvus 后起,否则工具 no responders
volumes:
- mcp_go_data:/data
mcp-py: mcp-py:
build: { context: sundynix-mcp-py, dockerfile: Dockerfile } build: { context: sundynix-mcp-py, dockerfile: Dockerfile }
@@ -181,3 +186,4 @@ volumes:
minio_data: minio_data:
milvus_data: milvus_data:
neo4j_data: neo4j_data:
mcp_go_data:
+2
View File
@@ -8,6 +8,7 @@ import { me, clearToken, type AuthUser } from "./api";
import { SiteLayout } from "./site/components/layout/site-layout"; import { SiteLayout } from "./site/components/layout/site-layout";
import HomePage from "./site/pages/home"; import HomePage from "./site/pages/home";
import DownloadPage from "./site/pages/download"; import DownloadPage from "./site/pages/download";
import PricingPage from "./site/pages/pricing";
import NotFoundPage from "./site/pages/not-found"; import NotFoundPage from "./site/pages/not-found";
// 后台鉴权门:只在访问 /admin 时跑 me()(官网公开、不打鉴权)。 // 后台鉴权门:只在访问 /admin 时跑 me()(官网公开、不打鉴权)。
@@ -54,6 +55,7 @@ export default function App() {
{/* 官网:根 / + /download,公开;未知路径落官网 404(保留 header/footer */} {/* 官网:根 / + /download,公开;未知路径落官网 404(保留 header/footer */}
<Route element={<SiteLayout />}> <Route element={<SiteLayout />}>
<Route index element={<HomePage />} /> <Route index element={<HomePage />} />
<Route path="pricing" element={<PricingPage />} />
<Route path="download" element={<DownloadPage />} /> <Route path="download" element={<DownloadPage />} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Route> </Route>
+114 -6
View File
@@ -333,6 +333,59 @@ export async function payOrderStatus(orderId: string): Promise<{ order: PayOrder
return { order: d.order as PayOrder, warn: d.warn }; return { order: d.order as PayOrder, warn: d.warn };
} }
// ---- 订阅(手动购买制:买一个周期,期内每 N 天发一次积分,到期即失效)----
export interface SubPlan {
id: string;
name: string;
price_fen: number;
duration_days: number; // 一个周期多少天
refill_credits_micro: number; // 每次发放的积分
refill_interval_days: number; // 每几天发一次
active: boolean;
sort: number;
}
export interface SubRow {
id: string;
tenant_id: string;
tenant_name: string;
plan_name: string;
status: string; // active / expired
started_at: string;
expires_at: string;
refill_seq: number; // 已发放次数
}
export async function adminSubPlans(): Promise<SubPlan[]> {
const res = guard(await fetch(`${ADMIN}/sub-plans`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { plans?: SubPlan[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `sub plans failed: ${res.status}`);
return d.plans ?? [];
}
// refill_credits 以「积分」为单位(面向人),服务端转 micro。
export async function saveSubPlan(p: {
id?: string;
name: string;
price_fen: number;
duration_days: number;
refill_credits: number;
refill_interval_days: number;
active: boolean;
sort: number;
}): Promise<void> {
const res = guard(await fetch(`${ADMIN}/sub-plans`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(p) }));
const d = (await res.json().catch(() => ({}))) as { error?: string };
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
}
export async function adminSubscriptions(): Promise<SubRow[]> {
const res = guard(await fetch(`${ADMIN}/subscriptions`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { subscriptions?: SubRow[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `subscriptions failed: ${res.status}`);
return d.subscriptions ?? [];
}
// 人工退款:仅对已入账(paid)单——置 refunded + 记 adjust 负分录 + 回退余额(幂等)。 // 人工退款:仅对已入账(paid)单——置 refunded + 记 adjust 负分录 + 回退余额(幂等)。
// status="noop" 表示该单本就无需退(已退/未支付)。真渠道钱的原路退回需 admin 另在商户后台操作。 // status="noop" 表示该单本就无需退(已退/未支付)。真渠道钱的原路退回需 admin 另在商户后台操作。
export async function adminRefundOrder(id: string, memo: string): Promise<{ status: string; detail?: string }> { export async function adminRefundOrder(id: string, memo: string): Promise<{ status: string; detail?: string }> {
@@ -361,6 +414,34 @@ export interface AdminTask {
eval_overall: number; eval_overall: number;
} }
export interface AdminEval {
overall: number;
rule: number;
llm: number;
faithful: number;
level: string;
flags: string; // JSON 数组字符串
reason: string;
sources: number;
corrected: boolean;
}
export interface AdminTaskDetail extends AdminTask {
graph: string;
output: string;
trace: string;
eval: AdminEval | null;
}
// 任务下钻。走 /admin 而非用户面的 /tasks/:id/replay —— 后者受租户插件过滤,
// 管理员看别的租户的任务会静默拿到空输出/空轨迹(不报错),排查时极具误导性。
export async function adminTaskDetail(id: string): Promise<{ task: AdminTaskDetail; exec: unknown[] }> {
const res = guard(await fetch(`${ADMIN}/tasks/${encodeURIComponent(id)}`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { task?: AdminTaskDetail; exec?: unknown[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `task detail failed: ${res.status}`);
return { task: d.task as AdminTaskDetail, exec: d.exec ?? [] };
}
// adminTasks 全平台任务流 + 状态计数。status 空=全部;tenant 空=全租户;含 HITL 待审批(status=waiting)。 // adminTasks 全平台任务流 + 状态计数。status 空=全部;tenant 空=全租户;含 HITL 待审批(status=waiting)。
export async function adminTasks( export async function adminTasks(
status = "", status = "",
@@ -485,9 +566,24 @@ export interface KbHit {
/** 检索模式:单路用于逐路定位是哪一路没召回;hybrid=纯 RRF 融合(不 rerank)""=生产链路(混合+rerank)。 */ /** 检索模式:单路用于逐路定位是哪一路没召回;hybrid=纯 RRF 融合(不 rerank)""=生产链路(混合+rerank)。 */
export type SearchMode = "" | "vector" | "fulltext" | "graph" | "hybrid"; export type SearchMode = "" | "vector" | "fulltext" | "graph" | "hybrid";
/** RouteDiag 一路召回的诊断。三种"空"必须能分辨:没配置(disabled)、报错(error)、确实没匹配(empty)。 */
export interface RouteDiag {
name: "vector" | "fulltext" | "graph";
status: "ok" | "empty" | "disabled" | "error";
hits: number;
ms: number;
error?: string;
note?: string;
}
// adminKbSearch 检索试验台:按完整作用域键跨租户检索任意知识库。 // adminKbSearch 检索试验台:按完整作用域键跨租户检索任意知识库。
// kb 传 `${space_id}/${name}`(来自 adminDatasources)。 // kb 传 `${space_id}/${name}`(来自 adminDatasources)。返回命中 + 每一路的诊断。
export async function adminKbSearch(kb: string, q: string, topK = 5, mode: SearchMode = ""): Promise<KbHit[]> { export async function adminKbSearch(
kb: string,
q: string,
topK = 5,
mode: SearchMode = "",
): Promise<{ hits: KbHit[]; routes: RouteDiag[] }> {
const res = guard( const res = guard(
await fetch(`${ADMIN}/kb/search`, { await fetch(`${ADMIN}/kb/search`, {
method: "POST", method: "POST",
@@ -495,9 +591,9 @@ export async function adminKbSearch(kb: string, q: string, topK = 5, mode: Searc
body: JSON.stringify({ kb, q, topK, mode }), body: JSON.stringify({ kb, q, topK, mode }),
}), }),
); );
const d = (await res.json().catch(() => ({}))) as { hits?: KbHit[]; error?: string }; const d = (await res.json().catch(() => ({}))) as { hits?: KbHit[]; routes?: RouteDiag[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `search failed: ${res.status}`); if (!res.ok) throw new Error(d.error ?? `search failed: ${res.status}`);
return d.hits ?? []; return { hits: d.hits ?? [], routes: d.routes ?? [] };
} }
export async function adminDatasources(): Promise<{ counts: { users: number; kbs: number; docs: number }; datasources: DatasourceKB[] }> { export async function adminDatasources(): Promise<{ counts: { users: number; kbs: number; docs: number }; datasources: DatasourceKB[] }> {
@@ -647,8 +743,20 @@ export interface GuardrailEventItem {
at: string; at: string;
} }
export async function listAudit(limit = 50, offset = 0): Promise<AuditEntry[]> { // 筛选走服务端(全库匹配)。此前是取回一页再在前端过滤,翻页外的记录搜不到——
const res = guard(await fetch(`${ADMIN}/audit?limit=${limit}&offset=${offset}`, { headers: authHeaders() })); // 对审计来说,“搜不到”会被当成“没发生过”,是会误导结论的。
export interface AuditQuery {
action?: string; // HTTP 方法,精确
path?: string; // 路径前缀
q?: string; // actor / ip / detail / path 模糊
}
export async function listAudit(limit = 50, offset = 0, f: AuditQuery = {}): Promise<AuditEntry[]> {
const p = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (f.action) p.set("action", f.action);
if (f.path) p.set("path", f.path);
if (f.q?.trim()) p.set("q", f.q.trim());
const res = guard(await fetch(`${ADMIN}/audit?${p}`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`audit failed: ${res.status}`); if (!res.ok) throw new Error(`audit failed: ${res.status}`);
return ((await res.json()) as { logs?: AuditEntry[] }).logs ?? []; return ((await res.json()) as { logs?: AuditEntry[] }).logs ?? [];
} }
@@ -108,7 +108,15 @@ export function ModelManager({
</button> </button>
)} )}
<button <button
onClick={() => deleteModel(m.id).then(refresh)} onClick={() => {
// 删除不可撤销,且删掉在用模型会直接打断线上推理——所以要二次确认,
// 且把"这条正在用"讲明白(此前是一点就删,无任何提示)。
const warn = m.active
? `${m.model}」正在使用中,删除后该 ${m.kind === "embedding" ? "向量" : "对话"}能力立即不可用。`
: `将删除「${m.model}」(${m.provider})。`;
if (!window.confirm(`${warn}\n此操作不可撤销,确定继续?`)) return;
void deleteModel(m.id).then(refresh);
}}
className="rounded border px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-50" className="rounded border px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-50"
> >
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { adminKbSearch, type DatasourceKB, type KbHit, type SearchMode } from "../api"; import { adminKbSearch, type DatasourceKB, type KbHit, type RouteDiag, type SearchMode } from "../api";
// 检索试验台:对同一个 query 同时跑「生产链路」与「三路 + RRF 融合」,并排看各自召回。 // 检索试验台:对同一个 query 同时跑「生产链路」与「三路 + RRF 融合」,并排看各自召回。
// 用途:线上召回不准时定位是哪一环 —— 向量路空=embedding/切块问题;全文路空=分词/索引问题; // 用途:线上召回不准时定位是哪一环 —— 向量路空=embedding/切块问题;全文路空=分词/索引问题;
@@ -14,7 +14,17 @@ const ROUTES: Array<{ mode: SearchMode; label: string; hint: string }> = [
{ mode: "hybrid", label: "RRF 融合", hint: "三路融合 · 不含 rerank" }, { mode: "hybrid", label: "RRF 融合", hint: "三路融合 · 不含 rerank" },
]; ];
type Results = Partial<Record<string, { hits: KbHit[]; err?: string }>>; type Results = Partial<Record<string, { hits: KbHit[]; routes?: RouteDiag[]; err?: string }>>;
// 每一路的诊断徽章。三种"空"必须分得清 —— 没配置 / 报错 / 确实没匹配,
// 以前它们在界面上长得一模一样(都是 0 条),排查时只能靠猜。
const STATUS_TONE: Record<string, { label: string; cls: string }> = {
ok: { label: "正常", cls: "bg-emerald-50 text-emerald-600" },
empty: { label: "无匹配", cls: "bg-gray-100 text-gray-500" },
disabled: { label: "未启用", cls: "bg-amber-50 text-amber-700" },
error: { label: "报错", cls: "bg-rose-50 text-rose-600" },
};
const ROUTE_CN: Record<string, string> = { vector: "向量", fulltext: "全文", graph: "图谱" };
export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) { export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) {
const withDocs = kbs.filter((k) => k.doc_count > 0); const withDocs = kbs.filter((k) => k.doc_count > 0);
@@ -34,7 +44,7 @@ export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) {
const settled = await Promise.all( const settled = await Promise.all(
modes.map(async (m) => { modes.map(async (m) => {
try { try {
return [m, { hits: await adminKbSearch(kbKey, q.trim(), topK, m) }] as const; return [m, await adminKbSearch(kbKey, q.trim(), topK, m)] as const;
} catch (e) { } catch (e) {
return [m, { hits: [], err: (e as Error).message }] as const; return [m, { hits: [], err: (e as Error).message }] as const;
} }
@@ -107,6 +117,37 @@ export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) {
{ran && ( {ran && (
<div className="mt-5 space-y-4"> <div className="mt-5 space-y-4">
{/* 各路健康状况:先回答"哪一路能用",再看"召回了什么" */}
{(() => {
const routes = res[""]?.routes ?? res["hybrid"]?.routes ?? [];
if (!routes.length) return null;
return (
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-gray-50 px-3 py-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400"></span>
{routes.map((r) => {
const tone = STATUS_TONE[r.status] ?? STATUS_TONE.empty;
return (
<span
key={r.name}
title={r.error || r.note || ""}
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] ${tone.cls}`}
>
{ROUTE_CN[r.name] ?? r.name} · {tone.label}
<span className="tabular-nums opacity-60">{r.hits}/{r.ms}ms</span>
</span>
);
})}
</div>
);
})()}
{/* 非正常路的原因写全,别让人去翻日志 */}
{(res[""]?.routes ?? res["hybrid"]?.routes ?? [])
.filter((r) => r.status === "disabled" || r.status === "error")
.map((r) => (
<p key={r.name} className={`text-[11px] ${r.status === "error" ? "text-rose-600" : "text-amber-700"}`}>
{ROUTE_CN[r.name] ?? r.name}{r.status === "error" ? "报错" : "未启用"}{r.error || r.note}
</p>
))}
{/* 生产链路:用户实际拿到的结果 */} {/* 生产链路:用户实际拿到的结果 */}
<RouteCard <RouteCard
label="生产链路" label="生产链路"
@@ -121,9 +162,11 @@ export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) {
</div> </div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{ROUTES.map((r) => ( {ROUTES.map((r) => {
<RouteCard key={r.mode} label={r.label} hint={r.hint} data={res[r.mode]} /> // 单路那次请求里,取它自己那一路的诊断(RRF 融合没有单独一路,故为空)
))} const diag = res[r.mode]?.routes?.find((d) => d.name === r.mode);
return <RouteCard key={r.mode} label={r.label} hint={r.hint} data={res[r.mode]} diag={diag} />;
})}
</div> </div>
</div> </div>
</div> </div>
@@ -136,11 +179,13 @@ function RouteCard({
label, label,
hint, hint,
data, data,
diag,
highlight, highlight,
}: { }: {
label: string; label: string;
hint: string; hint: string;
data?: { hits: KbHit[]; err?: string }; data?: { hits: KbHit[]; routes?: RouteDiag[]; err?: string };
diag?: RouteDiag;
highlight?: boolean; highlight?: boolean;
}) { }) {
const hits = data?.hits ?? []; const hits = data?.hits ?? [];
@@ -157,7 +202,14 @@ function RouteCard({
{data?.err ? ( {data?.err ? (
<p className="mt-2 text-[11px] text-rose-500">{data.err}</p> <p className="mt-2 text-[11px] text-rose-500">{data.err}</p>
) : hits.length === 0 ? ( ) : hits.length === 0 ? (
<p className="mt-2 text-[11px] text-gray-400"></p> // 空的原因比"空"本身重要:没配置 / 报错 / 确实没匹配,处理方式完全不同
<p className={`mt-2 text-[11px] leading-relaxed ${diag?.status === "error" ? "text-rose-500" : diag?.status === "disabled" ? "text-amber-600" : "text-gray-400"}`}>
{diag?.status === "disabled"
? `未启用:${diag.note || "该路未配置"}`
: diag?.status === "error"
? `报错:${diag.error}`
: diag?.note || "这一路没召回(索引里确实没有匹配内容)"}
</p>
) : ( ) : (
<ol className="mt-2 space-y-1.5"> <ol className="mt-2 space-y-1.5">
{hits.map((h, i) => ( {hits.map((h, i) => (
@@ -0,0 +1,255 @@
import { useEffect, useState } from "react";
import { adminTaskDetail, type AdminTask, type AdminTaskDetail } from "../api";
// 任务下钻:点列表一行看这一次运行到底发生了什么 —— 轨迹、最终输出、评测明细。
//
// 数据全部取自 sundynix_task 收尾时落库的列,不依赖 Redis 流(那个只有 10min TTL
// 历史任务早没了)。所以这里看到的是"复盘"而非"实时",正在跑的任务轨迹会是空的——
// 这一点在 UI 上要讲清楚,否则会被当成"轨迹丢了"。
//
// 不含审批操作:审批是客户端用户的行为(桌面端 ApprovalBar),管理端只做观测。
// 字段名以库里真实轨迹为准(不是猜的):seq/ts/node/kind/phase/label/detail。
// ts 是毫秒时间戳;kind ∈ system|model|tool|agent|plan|section|render|approval
// phase ∈ info|start|end|await|error。
type ExecEvent = {
seq?: number;
ts?: number;
node?: string;
kind?: string;
phase?: string;
label?: string;
detail?: string;
[k: string]: unknown;
};
const KIND_TONE: Record<string, string> = {
system: "bg-gray-100 text-gray-600",
model: "bg-violet-50 text-violet-600",
tool: "bg-sky-50 text-sky-600",
agent: "bg-indigo-50 text-indigo-600",
plan: "bg-amber-50 text-amber-700",
section: "bg-emerald-50 text-emerald-600",
render: "bg-teal-50 text-teal-600",
approval: "bg-orange-50 text-orange-600",
};
// 只有 error/await 值得跳出来:前者是失败点,后者是卡在人工审批。
const PHASE_TONE: Record<string, string> = {
error: "text-rose-600 font-medium",
await: "text-orange-600",
};
const hhmmss = (ms?: number) => (ms ? new Date(ms).toLocaleTimeString("zh-CN", { hour12: false }) : "");
export function TaskDetailDrawer({ task, onClose }: { task: AdminTask; onClose: () => void }) {
const [d, setD] = useState<AdminTaskDetail | null>(null);
const [exec, setExec] = useState<ExecEvent[]>([]);
const [err, setErr] = useState("");
const [tab, setTab] = useState<"trace" | "output" | "eval" | "graph">("trace");
useEffect(() => {
let alive = true;
adminTaskDetail(task.task_id)
.then((r) => {
if (!alive) return;
setD(r.task);
setExec(r.exec as ExecEvent[]);
})
.catch((e) => alive && setErr((e as Error).message));
return () => {
alive = false;
};
}, [task.task_id]);
// flags 是库里存的 JSON 字符串,实测可能是 "null"(不是 "[]")——JSON.parse 出来是 null
// 不校验就会在 flags.length 上抛 TypeError 把整个控制台白屏掉。必须确认真是数组。
const flags: string[] = (() => {
try {
const v: unknown = d?.eval?.flags ? JSON.parse(d.eval.flags) : null;
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
})();
return (
<div className="fixed inset-0 z-50 flex justify-end bg-gray-900/30" onClick={onClose}>
<div
className="flex h-full w-full max-w-2xl flex-col bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="border-b border-gray-100 px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-gray-800">{task.topic || "(无主题)"}</h3>
<p className="mt-0.5 font-mono text-[11px] text-gray-400">{task.task_id}</p>
</div>
<button onClick={onClose} className="shrink-0 text-xs text-gray-400 hover:text-gray-600">
</button>
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-1 text-[11px]">
<Row k="租户" v={task.tenant_name || task.tenant_id || "—"} />
<Row k="提交人" v={task.owner_email || task.owner || "—"} />
<Row k="状态" v={task.status} />
<Row k="提交时间" v={new Date(task.at).toLocaleString("zh-CN")} />
{task.detail && <Row k="失败原因" v={task.detail} span />}
</dl>
</div>
{/* 页签 */}
<div className="flex gap-1 border-b border-gray-100 px-5 pt-3">
{(
[
["trace", `执行轨迹${exec.length ? ` (${exec.length})` : ""}`],
["output", "最终输出"],
["eval", "评测"],
["graph", "DSL"],
] as const
).map(([k, label]) => (
<button
key={k}
onClick={() => setTab(k)}
className={`rounded-t px-3 py-1.5 text-xs ${
tab === k ? "border-b-2 border-violet-500 font-medium text-violet-600" : "text-gray-400 hover:text-gray-600"
}`}
>
{label}
</button>
))}
</div>
{/* 内容 */}
<div className="flex-1 overflow-auto px-5 py-4">
{err && <p className="text-xs text-rose-500">{err}</p>}
{!d && !err && <p className="text-xs text-gray-400"></p>}
{d && tab === "trace" && (
exec.length === 0 ? (
<Empty
text={
task.status === "running" || task.status === "submitted"
? "任务还在跑,轨迹要等收尾才落库(这里是复盘视图,不是实时流)"
: "这次运行没有留下轨迹(早于轨迹落库功能上线,或收尾时落库失败——后者会在 gateway 日志里有 [task] 告警)"
}
/>
) : (
<ol className="space-y-2">
{exec.map((e, i) => (
<li
key={e.seq ?? i}
className={`rounded-lg border p-2.5 text-[11px] ${
e.phase === "error" ? "border-rose-200 bg-rose-50/50" : "border-gray-100 bg-gray-50/60"
}`}
>
<div className="flex items-baseline gap-2">
<span className="shrink-0 tabular-nums text-gray-300">#{e.seq ?? i + 1}</span>
{e.label && <span className={`font-medium text-gray-700 ${PHASE_TONE[e.phase ?? ""] ?? ""}`}>{e.label}</span>}
{e.kind && (
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_TONE[e.kind] ?? "bg-gray-100 text-gray-500"}`}>
{e.kind}
</span>
)}
{e.phase && e.phase !== "info" && (
<span className={`text-[10px] ${PHASE_TONE[e.phase] ?? "text-gray-400"}`}>{e.phase}</span>
)}
<span className="ml-auto shrink-0 tabular-nums text-gray-300">{hhmmss(e.ts)}</span>
</div>
{e.node && <div className="mt-0.5 font-mono text-[10px] text-gray-400">{e.node}</div>}
{e.detail && <p className="mt-1 whitespace-pre-wrap break-words text-gray-600">{e.detail}</p>}
{/* 兜底:字段形状变了也别把线索吞掉(上一版就是猜错字段名,白渲染了一列空行) */}
{!e.label && !e.detail && (
<pre className="mt-1 overflow-x-auto text-[10px] text-gray-500">{JSON.stringify(e)}</pre>
)}
</li>
))}
</ol>
)
)}
{d && tab === "output" && (
d.output ? (
<pre className="whitespace-pre-wrap break-words text-[11px] leading-relaxed text-gray-700">{d.output}</pre>
) : (
<Empty text="没有最终输出(任务未完成或失败)" />
)
)}
{d && tab === "eval" && (
d.eval ? (
<div className="space-y-3">
<div className="grid grid-cols-4 gap-2">
<Score label="综合" v={d.eval.overall} />
<Score label="规则" v={d.eval.rule} />
<Score label="质量" v={d.eval.llm} />
<Score label="忠实度" v={d.eval.faithful} hint={d.eval.sources === 0 ? "无检索来源,未评" : undefined} />
</div>
<dl className="space-y-1 rounded-lg bg-gray-50 p-3 text-[11px]">
<Row k="分级" v={d.eval.level} />
<Row k="检索来源数" v={String(d.eval.sources)} />
<Row k="是否纠偏重生成" v={d.eval.corrected ? "是(低分自动纠偏后采纳)" : "否"} />
</dl>
{flags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{flags.map((f) => (
<span key={f} className="rounded bg-amber-50 px-2 py-0.5 text-[10px] text-amber-700">
{f}
</span>
))}
</div>
)}
{d.eval.reason && (
<p className="whitespace-pre-wrap rounded-lg border border-gray-100 p-3 text-[11px] leading-relaxed text-gray-600">
{d.eval.reason}
</p>
)}
</div>
) : (
<Empty text="尚无评测结果(任务未完成,或评测仍在进行)" />
)
)}
{d && tab === "graph" && (
d.graph ? (
<pre className="overflow-x-auto text-[10px] leading-relaxed text-gray-600">
{(() => {
try {
return JSON.stringify(JSON.parse(d.graph), null, 2);
} catch {
return d.graph; // 解析不了就原样显示,别把排查线索吞掉
}
})()}
</pre>
) : (
<Empty text="没有记录提交时的 DSL" />
)
)}
</div>
</div>
</div>
);
}
function Row({ k, v, span }: { k: string; v: string; span?: boolean }) {
return (
<div className={`flex items-baseline gap-2 ${span ? "col-span-2" : ""}`}>
<dt className="shrink-0 text-gray-400">{k}</dt>
<dd className="min-w-0 break-words text-gray-700">{v}</dd>
</div>
);
}
function Score({ label, v, hint }: { label: string; v: number; hint?: string }) {
return (
<div className="rounded-lg border border-gray-100 p-2 text-center" title={hint}>
<div className="text-[10px] text-gray-400">{label}</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums text-gray-800">{(v * 100).toFixed(0)}</div>
</div>
);
}
function Empty({ text }: { text: string }) {
return <p className="py-10 text-center text-[11px] leading-relaxed text-gray-400">{text}</p>;
}
+19 -15
View File
@@ -47,7 +47,11 @@ export function AuditPage() {
try { try {
const offset = (pageNum - 1) * LIMIT; const offset = (pageNum - 1) * LIMIT;
// 从后端载入比 LIMIT 稍微多一条,以此判断是否有下一页 // 从后端载入比 LIMIT 稍微多一条,以此判断是否有下一页
const data = await listAudit(LIMIT + 1, offset); const data = await listAudit(LIMIT + 1, offset, {
action: methodFilter,
path: routeFilter,
q: searchQuery,
});
if (data.length > LIMIT) { if (data.length > LIMIT) {
setAudit(data.slice(0, LIMIT)); setAudit(data.slice(0, LIMIT));
setHasMore(true); setHasMore(true);
@@ -70,20 +74,20 @@ export function AuditPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]); }, [page]);
// 前端过滤(配合后端分页后的过滤,或按需做简单的前端实时匹配) // 筛选条件变化 → 回第 1 页重新向服务端查询(防抖 300ms,免得每敲一个字打一次库)。
const filteredAudit = audit.filter((a) => { // 已在第 1 页时 setPage 不会触发上面的 effect,所以这里直接 load。
if (methodFilter && a.action !== methodFilter) return false; useEffect(() => {
if (routeFilter && !a.path.startsWith(routeFilter)) return false; const t = window.setTimeout(() => {
if (searchQuery) { if (page !== 1) setPage(1);
const q = searchQuery.toLowerCase(); else void load(1);
const matchActor = a.actor.toLowerCase().includes(q); }, 300);
const matchIp = a.ip.toLowerCase().includes(q); return () => window.clearTimeout(t);
const matchDetail = a.detail?.toLowerCase().includes(q) ?? false; // eslint-disable-next-line react-hooks/exhaustive-deps
const matchPath = a.path.toLowerCase().includes(q); }, [methodFilter, routeFilter, searchQuery]);
if (!matchActor && !matchIp && !matchDetail && !matchPath) return false;
} // 筛选已由服务端完成(全库匹配 + 分页),前端不再二次过滤:
return true; // 否则会在"服务端已筛过的一页"上再筛一次,翻页计数与实际结果对不上。
}); const filteredAudit = audit;
const handlePrevPage = () => { const handlePrevPage = () => {
if (page > 1) setPage(page - 1); if (page > 1) setPage(page - 1);
+14 -2
View File
@@ -17,6 +17,8 @@ const INFRA_META: Record<string, { role: string; port: string; icon: IconName }>
milvus: { role: "向量检索库", port: "19530", icon: "db" }, milvus: { role: "向量检索库", port: "19530", icon: "db" },
neo4j: { role: "图关系库", port: "7687", icon: "bus" }, neo4j: { role: "图关系库", port: "7687", icon: "bus" },
minio: { role: "对象存储 (OSS)", port: "9000", icon: "db" }, minio: { role: "对象存储 (OSS)", port: "9000", icon: "db" },
// 全文索引不是独立服务,是 mcp-go 进程内的本地 bleve —— 没有端口,落在容器卷上。
: { role: "全文检索 (bleve)", port: "本地卷", icon: "db" },
}; };
function toolCategory(t: string): string { function toolCategory(t: string): string {
@@ -289,7 +291,11 @@ export function StatusPage() {
{s.name === "mcp-go" || s.name === "mcp-py" ? "已注册工具数" : "网络延迟"} {s.name === "mcp-go" || s.name === "mcp-py" ? "已注册工具数" : "网络延迟"}
</span> </span>
<span className="text-xs font-bold text-slate-700 font-mono"> <span className="text-xs font-bold text-slate-700 font-mono">
{s.name === "mcp-go" ? "23 个" : s.name === "mcp-py" ? "4 个" : `${s.latency_ms ?? 0} ms`} {/* 取实际上报的注册工具数。曾写死 23/4,加减工具后这里一直骗人,
而且服务离线时也照样显示,看不出工具其实一个都没注册上。 */}
{s.name === "mcp-go" || s.name === "mcp-py"
? `${data.tools.find((g) => g.server === s.name)?.tools?.length ?? 0}`
: `${s.latency_ms ?? 0} ms`}
</span> </span>
</div> </div>
</div> </div>
@@ -313,9 +319,15 @@ export function StatusPage() {
const meta = INFRA_META[item.name]; const meta = INFRA_META[item.name];
return ( return (
<div key={item.name} className="flex items-center justify-between text-xs pb-3 border-b border-slate-100 last:border-0 last:pb-0"> <div key={item.name} className="flex items-center justify-between text-xs pb-3 border-b border-slate-100 last:border-0 last:pb-0">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="font-bold text-slate-800">{item.name}</span> <span className="font-bold text-slate-800">{item.name}</span>
<span className="text-[10px] text-slate-400">{meta?.role || "后端组件"}</span> <span className="text-[10px] text-slate-400">{meta?.role || "后端组件"}</span>
{/* detail 是降级原因/后果的唯一载体(如"内存兜底·重启即清零"),必须露出来 */}
{item.detail && (
<span className={`truncate text-[10px] ${item.up ? "text-slate-400" : "text-amber-600"}`} title={item.detail}>
· {item.detail}
</span>
)}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<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">
@@ -0,0 +1,241 @@
import { useEffect, useState } from "react";
import { adminSubPlans, saveSubPlan, adminSubscriptions, type SubPlan, type SubRow } from "../api";
// 支付 · 订阅:套餐配置(价格/时长/发放节奏)+ 全平台订阅观测。
//
// 语义要在界面上讲清楚,否则配错了很难发现:
// - 手动购买制,**不自动续费**(微信 Native 无代扣能力),到期即失效;
// - 期内每 N 天发一次积分,发放是**累加**(不清零用户已有积分);
// - 因此一个周期实际发放次数 = floor(时长 / 间隔),配的时候直接算给人看。
const MICRO = 1_000_000;
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const yuan = (fen: number) => (fen / 100).toFixed(2);
export function SubscriptionPage() {
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">
·
</p>
</div>
<PlansBlock />
<SubsBlock />
</div>
);
}
function PlansBlock() {
const [rows, setRows] = useState<SubPlan[]>([]);
const [name, setName] = useState("");
const [price, setPrice] = useState("");
const [duration, setDuration] = useState("30");
const [refill, setRefill] = useState("");
const [interval, setIntervalDays] = useState("7");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const load = () => adminSubPlans().then(setRows).catch((e) => setErr((e as Error).message));
useEffect(() => {
void load();
}, []);
const add = async () => {
const p = Number(price), d = Number(duration), r = Number(refill), i = Number(interval);
if (!name.trim() || !p || !d || !r || !i || busy) return;
setBusy(true);
setErr("");
try {
await saveSubPlan({
name: name.trim(), price_fen: Math.round(p * 100), duration_days: d,
refill_credits: r, refill_interval_days: i, active: true, sort: 0,
});
setName(""); setPrice(""); setRefill("");
await load();
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
};
const toggle = async (p: SubPlan) => {
try {
await saveSubPlan({
id: p.id, name: p.name, price_fen: p.price_fen, duration_days: p.duration_days,
refill_credits: p.refill_credits_micro / MICRO, refill_interval_days: p.refill_interval_days,
active: !p.active, sort: p.sort,
});
await load();
} catch (e) {
setErr((e as Error).message);
}
};
// 一个周期发几次:让人在配的时候就看见后果,而不是上线后才发现只发了一次。
const times = (p: { duration_days: number; refill_interval_days: number }) =>
p.refill_interval_days > 0 ? Math.floor(p.duration_days / p.refill_interval_days) : 0;
const previewTimes = times({ duration_days: Number(duration) || 0, refill_interval_days: Number(interval) || 0 });
const previewTotal = previewTimes * (Number(refill) || 0);
return (
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<span className="text-[11px] text-gray-400"></span>
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="text-xs text-gray-500">
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:专业版月付"
className="mt-1 block w-36 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>
<label className="text-xs text-gray-500">
¥
<input value={price} onChange={(e) => setPrice(e.target.value)} inputMode="decimal" placeholder="99"
className="mt-1 block w-20 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>
<label className="text-xs text-gray-500">
<input value={duration} onChange={(e) => setDuration(e.target.value)} inputMode="numeric"
className="mt-1 block w-20 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>
<label className="text-xs text-gray-500">
<input value={refill} onChange={(e) => setRefill(e.target.value)} inputMode="numeric" placeholder="1000"
className="mt-1 block w-28 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>
<label className="text-xs text-gray-500">
<input value={interval} onChange={(e) => setIntervalDays(e.target.value)} inputMode="numeric"
className="mt-1 block w-20 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>
<button onClick={() => void add()} 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>
{/* 配置后果预览:省得上线后才发现节奏配错 */}
{previewTimes > 0 && (
<p className="mt-2 text-[11px] text-gray-500">
<b className="text-violet-600">{previewTimes}</b> {" "}
<b className="text-violet-600">{previewTotal.toLocaleString("zh-CN")}</b>
{Number(duration) % Number(interval) !== 0 && (
<span className="text-amber-600">{Number(duration)} {Number(interval)} </span>
)}
</p>
)}
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
<div className="mt-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((p) => (
<tr key={p.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 text-gray-800">{p.name}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">¥{yuan(p.price_fen)}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{p.duration_days} </td>
<td className="py-2 pr-3 text-right text-[11px] tabular-nums text-gray-600">
{p.refill_interval_days} · {credits(p.refill_credits_micro)}
</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">
{credits(p.refill_credits_micro * times(p))}
<span className="ml-1 text-[10px] text-gray-400">({times(p)} )</span>
</td>
<td className="py-2 text-right">
<button onClick={() => void toggle(p)}
className={`rounded border px-2 py-0.5 text-[11px] ${p.active ? "border-emerald-200 text-emerald-600 hover:bg-emerald-50" : "border-gray-200 text-gray-400 hover:bg-gray-50"}`}>
{p.active ? "在售 · 点击下架" : "已下架 · 点击上架"}
</button>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="py-6 text-center text-xs text-gray-400"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function SubsBlock() {
const [rows, setRows] = useState<SubRow[]>([]);
const [err, setErr] = useState("");
useEffect(() => {
adminSubscriptions().then(setRows).catch((e) => setErr((e as Error).message));
}, []);
const now = Date.now();
const daysLeft = (iso: string) => Math.ceil((new Date(iso).getTime() - now) / 86400000);
return (
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<span className="text-[11px] text-gray-400"></span>
</div>
{err && <p className="mb-2 text-xs text-rose-500">{err}</p>}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((s) => {
const left = daysLeft(s.expires_at);
return (
<tr key={s.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 text-gray-800">{s.tenant_name || s.tenant_id}</td>
<td className="py-2 pr-3 text-gray-600">{s.plan_name || "—"}</td>
<td className="py-2 pr-3">
<span className={`rounded px-1.5 py-0.5 text-[10px] ${s.status === "active" ? "bg-emerald-50 text-emerald-600" : "bg-gray-100 text-gray-500"}`}>
{s.status === "active" ? "生效中" : "已过期"}
</span>
</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-600">{s.refill_seq} </td>
<td className="py-2 text-right text-[11px] tabular-nums">
<span className="text-gray-500">{new Date(s.expires_at).toLocaleDateString("zh-CN")}</span>
{s.status === "active" && (
// 快到期的要显眼:到期即失效,没有自动续费兜底
<span className={`ml-1.5 ${left <= 3 ? "font-medium text-rose-600" : left <= 7 ? "text-amber-600" : "text-gray-400"}`}>
{left}
</span>
)}
</td>
</tr>
);
})}
{rows.length === 0 && (
<tr>
<td colSpan={5} className="py-6 text-center text-xs text-gray-400"></td>
</tr>
)}
</tbody>
</table>
</div>
);
}
+14 -2
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { TaskDetailDrawer } from "../components/TaskDetailDrawer";
import { adminTasks, listTenants, type AdminTask, type TenantRow } from "../api"; import { adminTasks, listTenants, type AdminTask, type TenantRow } from "../api";
// 全平台任务/运行观测:跨租户看所有任务(状态分布 + 列表 + 提交人/租户/评测)。 // 全平台任务/运行观测:跨租户看所有任务(状态分布 + 列表 + 提交人/租户/评测)。
@@ -31,6 +32,7 @@ export function TasksPage() {
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
const [tenantFilter, setTenantFilter] = useState(""); const [tenantFilter, setTenantFilter] = useState("");
const [tenants, setTenants] = useState<TenantRow[]>([]); const [tenants, setTenants] = useState<TenantRow[]>([]);
const [drill, setDrill] = useState<AdminTask | null>(null); // 正在下钻的任务
const [copiedId, setCopiedId] = useState(""); const [copiedId, setCopiedId] = useState("");
const [err, setErr] = useState(""); const [err, setErr] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -124,13 +126,21 @@ export function TasksPage() {
</thead> </thead>
<tbody> <tbody>
{tasks.map((t) => ( {tasks.map((t) => (
<tr key={t.task_id} className="border-b border-gray-50 last:border-0 align-top"> <tr
key={t.task_id}
onClick={() => setDrill(t)}
className="cursor-pointer border-b border-gray-50 align-top last:border-0 hover:bg-violet-50/40"
title="点击查看执行轨迹 / 输出 / 评测"
>
<td className="py-2 pr-3 text-xs text-gray-500 whitespace-nowrap">{new Date(t.at).toLocaleString("zh-CN")}</td> <td className="py-2 pr-3 text-xs text-gray-500 whitespace-nowrap">{new Date(t.at).toLocaleString("zh-CN")}</td>
<td className="py-2 pr-3"> <td className="py-2 pr-3">
<div className="text-gray-800 flex items-center gap-1.5"> <div className="text-gray-800 flex items-center gap-1.5">
<span>{t.topic || <code className="text-[11px] text-gray-500">{t.task_id.slice(0, 8)}</code>}</span> <span>{t.topic || <code className="text-[11px] text-gray-500">{t.task_id.slice(0, 8)}</code>}</span>
<button <button
onClick={() => handleCopy(t.task_id)} onClick={(e) => {
e.stopPropagation(); // 复制不应顺带打开下钻
handleCopy(t.task_id);
}}
className="text-[10px] text-gray-400 hover:text-violet-600 transition" className="text-[10px] text-gray-400 hover:text-violet-600 transition"
title="复制任务 ID" title="复制任务 ID"
> >
@@ -174,6 +184,8 @@ export function TasksPage() {
</table> </table>
</div> </div>
</div> </div>
{drill && <TaskDetailDrawer task={drill} onClose={() => setDrill(null)} />}
</div> </div>
); );
} }
+9
View File
@@ -1,3 +1,4 @@
import { SubscriptionPage } from "./pages/SubscriptionPage";
import { lazy, type ReactNode } from "react"; import { lazy, type ReactNode } from "react";
// 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。 // 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。
@@ -98,6 +99,14 @@ export const routes: RouteDef[] = [
ready: true, ready: true,
element: <PaymentConfigPage />, element: <PaymentConfigPage />,
}, },
{
path: "payment/subscription",
label: "订阅",
group: "运维",
parent: "支付",
ready: true,
element: <SubscriptionPage />,
},
{ {
path: "payment/orders", path: "payment/orders",
label: "订单与对账", label: "订单与对账",
@@ -10,6 +10,7 @@ const NAV_ITEMS = [
{ label: '产品', to: '/', end: true }, { label: '产品', to: '/', end: true },
{ label: '功能', to: '/#features' }, { label: '功能', to: '/#features' },
{ label: '架构', to: '/#architecture' }, { label: '架构', to: '/#architecture' },
{ label: '定价', to: '/pricing' },
] ]
export function SiteHeader() { export function SiteHeader() {
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useState } from 'react'
import { GATEWAY } from '../../api'
// 官网定价页。数据来自后台配置的「订阅套餐 / 积分包」,**不写死在前端**——
// 运营改价、上下架、调发放节奏都在管理端完成,这里跟着变,不用发版。
//
// 公开可见(/api/v1/pricing 不挂鉴权):让人在登录前就看得到价格,
// 这是购买转化的前提,也是官网存在的意义。
const MICRO = 1_000_000
const credits = (m: number) => (m / MICRO).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
const yuan = (fen: number) => (fen / 100).toLocaleString('zh-CN', { minimumFractionDigits: fen % 100 === 0 ? 0 : 2 })
interface SubPlan {
id: string
name: string
price_fen: number
duration_days: number
refill_credits_micro: number
refill_interval_days: number
}
interface Pack {
id: string
name: string
credits_micro: number
price_fen: number
}
// 结账在 Web 面完成(那里有登录态、租户上下文与支付轮询)。官网只负责"看价 → 去买"
// 避免同一套支付流程在两处各实现一遍。
const CHECKOUT = '/usage'
export default function PricingPage() {
const [plans, setPlans] = useState<SubPlan[]>([])
const [packs, setPacks] = useState<Pack[]>([])
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading')
useEffect(() => {
// GATEWAY 生产构建为空串(同源相对),开发时指向 :8080——写死相对路径会打到 vite
fetch(`${GATEWAY}/api/v1/pricing`)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((d: { plans?: SubPlan[]; packs?: Pack[] }) => {
setPlans(d.plans ?? [])
setPacks(d.packs ?? [])
setState('ready')
})
.catch(() => setState('error'))
}, [])
return (
<main className="mx-auto max-w-6xl px-6 py-16 md:py-24">
<header className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-accent">PRICING</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-ink md:text-4xl"></h1>
<p className="mt-4 text-sm leading-relaxed text-ink-2">
</p>
</header>
{state === 'loading' && <p className="mt-12 text-sm text-ink-3"></p>}
{state === 'error' && (
<p className="mt-12 text-sm text-ink-3">
<a href={CHECKOUT} className="ml-1 text-accent underline underline-offset-4">
</a>
</p>
)}
{state === 'ready' && (
<>
{plans.length > 0 && (
<section className="mt-12">
<h2 className="text-lg font-semibold text-ink"></h2>
<p className="mt-1 text-sm text-ink-2">
</p>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{plans.map((p) => {
const times = p.refill_interval_days > 0 ? Math.floor(p.duration_days / p.refill_interval_days) : 0
return (
<article
key={p.id}
className="flex flex-col rounded-2xl border border-hairline bg-surface p-6 transition-colors hover:border-hairline-strong"
>
<h3 className="text-base font-semibold text-ink">{p.name}</h3>
<div className="mt-3 flex items-baseline gap-1">
<span className="text-3xl font-semibold tracking-tight text-ink">¥{yuan(p.price_fen)}</span>
<span className="text-sm text-ink-3">/ {p.duration_days} </span>
</div>
<ul className="mt-5 space-y-2 text-sm text-ink-2">
<li>
{p.refill_interval_days} {' '}
<b className="text-ink">{credits(p.refill_credits_micro)}</b>
</li>
<li>
{times} {' '}
<b className="text-ink">{credits(p.refill_credits_micro * times)}</b>
</li>
<li className="text-ink-3"></li>
</ul>
<a
href={CHECKOUT}
className="mt-6 inline-flex items-center justify-center rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-90"
>
</a>
</article>
)
})}
</div>
</section>
)}
{packs.length > 0 && (
<section className="mt-14">
<h2 className="text-lg font-semibold text-ink"></h2>
<p className="mt-1 text-sm text-ink-2"></p>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{packs.map((p) => (
<article key={p.id} className="rounded-2xl border border-hairline bg-surface p-5">
<h3 className="text-sm font-semibold text-ink">{p.name}</h3>
<div className="mt-2 text-2xl font-semibold tracking-tight text-ink">¥{yuan(p.price_fen)}</div>
<p className="mt-1 text-sm text-ink-2">{credits(p.credits_micro)} </p>
<a
href={CHECKOUT}
className="mt-4 inline-flex text-sm font-medium text-accent underline underline-offset-4"
>
</a>
</article>
))}
</div>
</section>
)}
{plans.length === 0 && packs.length === 0 && (
<p className="mt-12 text-sm text-ink-3">线</p>
)}
<p className="mt-14 text-xs leading-relaxed text-ink-3">
</p>
</>
)}
</main>
)
}
+25
View File
@@ -150,6 +150,31 @@ export interface MyTenant {
members: number; members: number;
} }
// ---- 订阅(只读)----
// 桌面端**不做**购买流程:购买入口在 Web 面(一个功能一个入口)。这里只读状态,
// 因为"还剩几天到期"必须在用户干活的地方看得见——到期即失效且没有任何续费通知。
export interface MySubscription {
id: string;
plan_id: string;
status: string;
expires_at: string;
refill_seq: number;
}
export interface MySubPlan {
id: string;
name: string;
refill_credits_micro: number;
refill_interval_days: number;
}
export async function mySubscription(): Promise<{ subscription: MySubscription | null; plan: MySubPlan | null }> {
const res = await fetch(`${GATEWAY}/api/v1/billing/subscription`, { headers: bearer() });
if (!res.ok) return { subscription: null, plan: null };
const d = (await res.json()) as { subscription?: MySubscription | null; plan?: MySubPlan | null };
return { subscription: d.subscription ?? null, plan: d.plan ?? null };
}
export async function myTenants(): Promise<{ tenants: MyTenant[]; active: string }> { export async function myTenants(): Promise<{ tenants: MyTenant[]; active: string }> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/tenants`, { headers: bearer() })); const res = guard401(await fetch(`${GATEWAY}/api/v1/me/tenants`, { headers: bearer() }));
if (!res.ok) return { tenants: [], active: "" }; if (!res.ok) return { tenants: [], active: "" };
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Coins, RefreshCw, Wallet, Cpu, Receipt, Activity } from "lucide-react"; import { Coins, RefreshCw, Wallet, Cpu, Receipt, Activity } from "lucide-react";
import { myUsage, type MyUsage, type UsageDay } from "../lib/api"; import { myUsage, GATEWAY, type MyUsage, type UsageDay, mySubscription, type MySubscription, type MySubPlan } from "../lib/api";
import { openExternal } from "../lib/version";
import { Card, Panel, EmptyState, cn } from "../ui"; import { Card, Panel, EmptyState, cn } from "../ui";
// 桌面端「用量」= 用户自己租户的计量观测:积分余额 + 消耗趋势 + 最近消耗。 // 桌面端「用量」= 用户自己租户的计量观测:积分余额 + 消耗趋势 + 最近消耗。
@@ -34,11 +35,20 @@ export function UsageView() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [err, setErr] = useState(""); const [err, setErr] = useState("");
const [sub, setSub] = useState<MySubscription | null>(null);
const [subPlan, setSubPlan] = useState<MySubPlan | null>(null);
const load = async () => { const load = async () => {
setRefreshing(true); setRefreshing(true);
try { try {
setData(await myUsage(days)); setData(await myUsage(days));
// 订阅只读展示;失败不影响用量页主体(没订阅也是常态)
mySubscription()
.then((r) => {
setSub(r.subscription);
setSubPlan(r.plan);
})
.catch(() => {});
setErr(""); setErr("");
} catch (e) { } catch (e) {
setErr((e as Error).message); setErr((e as Error).message);
@@ -114,6 +124,10 @@ export function UsageView() {
<Metric icon={Receipt} label="估算成本" value={money(t.cost_micros)} sub="按定价折算(配置币种)" accent="text-slate-100" /> <Metric icon={Receipt} label="估算成本" value={money(t.cost_micros)} sub="按定价折算(配置币种)" accent="text-slate-100" />
</div> </div>
{/* 订阅状态:到期即失效、无自动续费、无扣款通知 —— 必须在用户干活的地方看得见。
购买/续订不在这里做(入口在 Web 面账单页),避免同一功能两个入口。 */}
{sub && subPlan && <SubBar sub={sub} plan={subPlan} />}
{/* 消耗趋势 */} {/* 消耗趋势 */}
<Panel title="积分消耗趋势" icon={Activity} className="min-h-[200px]"> <Panel title="积分消耗趋势" icon={Activity} className="min-h-[200px]">
<TrendBars series={series} /> <TrendBars series={series} />
@@ -182,3 +196,31 @@ function TrendBars({ series }: { series: UsageDay[] }) {
</div> </div>
); );
} }
// SubBar 订阅状态条。只读:桌面端不做购买流程,续订跳 Web 面账单页。
function SubBar({ sub, plan }: { sub: MySubscription; plan: MySubPlan }) {
const left = Math.ceil((new Date(sub.expires_at).getTime() - Date.now()) / 86400000);
const tone = left <= 3 ? "text-danger" : left <= 7 ? "text-warn" : "text-slate-400";
return (
<Card className={cn("flex flex-wrap items-center justify-between gap-3 p-4", left <= 7 ? "border-warn/40" : "")}>
<div>
<div className="text-sm font-medium text-slate-200"> · {plan.name}</div>
<div className="mt-0.5 text-[11px] text-slate-500">
{plan.refill_interval_days} {credits(plan.refill_credits_micro)} · {sub.refill_seq} ·
</div>
</div>
<div className="flex items-center gap-3">
<span className={cn("text-xs tabular-nums", tone)}>
{new Date(sub.expires_at).toLocaleDateString("zh-CN")} {left}
</span>
<button
onClick={() => openExternal(`${GATEWAY}/usage`)}
className="rounded-lg border border-line px-3 py-1.5 text-xs text-slate-300 transition-colors hover:border-brand/50 hover:text-brand"
>
</button>
</div>
</Card>
);
}
+14 -3
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -30,7 +31,14 @@ func (h *Handler) AuditList(c *gin.Context) {
offset = n offset = n
} }
} }
rows, err := h.db.ListAudit(c.Request.Context(), limit, offset) // 筛选下沉到 SQL:此前是前端在当前页 50 条里过滤,翻页外的记录搜不到,
// 对审计来说等于给出错误结论。
f := store.AuditFilter{
Action: c.Query("action"),
Path: c.Query("path"),
Q: strings.TrimSpace(c.Query("q")),
}
rows, err := h.db.ListAudit(c.Request.Context(), limit, offset, f)
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
@@ -375,7 +383,6 @@ func (h *Handler) AdminSetTenantStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
} }
// AdminMembers: GET /api/v1/admin/tenants/:id/members —— 某租户成员列表。 // AdminMembers: GET /api/v1/admin/tenants/:id/members —— 某租户成员列表。
func (h *Handler) AdminMembers(c *gin.Context) { func (h *Handler) AdminMembers(c *gin.Context) {
rows, err := h.db.ListMembers(c.Request.Context(), c.Param("id")) rows, err := h.db.ListMembers(c.Request.Context(), c.Param("id"))
@@ -557,7 +564,11 @@ func (h *Handler) TestModel(c *gin.Context) {
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} {
if cfg := h.db.ActiveConfig(ctx, kind); cfg != nil { if cfg := h.db.ActiveConfig(ctx, kind); cfg != nil {
_ = h.bus.PublishConfigUpdated(kind, cfg) // 广播失败 = dispatcher/mcp-go 拿不到新配置,症状是"控制台改了模型却不生效",
// 而改配置的人这边一切正常。必须留痕,否则只能靠猜。
if err := h.bus.PublishConfigUpdated(kind, cfg); err != nil {
log.Printf("[admin] ⚠️ %s 配置广播失败: %vdispatcher/mcp-go 仍在用旧配置,需重启或重试保存)", kind, err)
}
} }
} }
} }
@@ -2,6 +2,7 @@ package handler
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -38,10 +39,11 @@ const orderTTL = 30 * time.Minute
func (h *Handler) BillingCreateOrder(c *gin.Context) { func (h *Handler) BillingCreateOrder(c *gin.Context) {
var b struct { var b struct {
PackID string `json:"pack_id"` PackID string `json:"pack_id"`
PlanID string `json:"plan_id"` // 传它=买订阅周期;与 pack_id 二选一
Channel string `json:"channel"` Channel string `json:"channel"`
} }
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.PackID) == "" { if err := c.ShouldBindJSON(&b); err != nil || (strings.TrimSpace(b.PackID) == "" && strings.TrimSpace(b.PlanID) == "") {
c.JSON(http.StatusBadRequest, gin.H{"error": "pack_id 必填"}) c.JSON(http.StatusBadRequest, gin.H{"error": "pack_id 或 plan_id 必填"})
return return
} }
channel := strings.TrimSpace(b.Channel) channel := strings.TrimSpace(b.Channel)
@@ -60,21 +62,40 @@ func (h *Handler) BillingCreateOrder(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无计费租户上下文"}) c.JSON(http.StatusBadRequest, gin.H{"error": "无计费租户上下文"})
return return
} }
pk, err := h.db.GetPack(ctx, b.PackID) // 订阅单与积分包单走同一条支付链路:只有订单内容不同,下单/回调/查单/掉单补偿全复用。
if err != nil { var o *store.PaymentOrder
c.JSON(http.StatusBadRequest, gin.H{"error": "积分包不存在或已下架"}) var desc string
return if pid := strings.TrimSpace(b.PlanID); pid != "" {
} pl := h.db.GetSubPlan(ctx, pid)
o := &store.PaymentOrder{ if pl == nil || !pl.Active {
TenantID: billing, UserID: uid, PackID: pk.ID, c.JSON(http.StatusBadRequest, gin.H{"error": "订阅套餐不存在或已下架"})
AmountFen: pk.PriceFen, CreditsMicro: pk.CreditsMicro, return
Channel: channel, Status: store.OrderPending, }
// 订阅单 credits_micro 恒为 0:积分不在付款时一次给,而是订阅期内按周期发放。
o = &store.PaymentOrder{
TenantID: billing, UserID: uid, Kind: store.OrderKindSub, PlanID: pl.ID,
AmountFen: pl.PriceFen, CreditsMicro: 0,
Channel: channel, Status: store.OrderPending,
}
desc = "sundynix 订阅 · " + pl.Name
} else {
pk, err := h.db.GetPack(ctx, b.PackID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "积分包不存在或已下架"})
return
}
o = &store.PaymentOrder{
TenantID: billing, UserID: uid, PackID: pk.ID, Kind: store.OrderKindPack,
AmountFen: pk.PriceFen, CreditsMicro: pk.CreditsMicro,
Channel: channel, Status: store.OrderPending,
}
desc = "sundynix 积分充值 · " + pk.Name
} }
if err := h.db.CreateOrder(ctx, o); err != nil { if err := h.db.CreateOrder(ctx, o); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return return
} }
intent, err := ch.CreatePay(ctx, o.ID, "sundynix 积分充值 · "+pk.Name, pk.PriceFen) intent, err := ch.CreatePay(ctx, o.ID, desc, o.AmountFen)
if err != nil { if err != nil {
// 渠道下单失败的单直接作废,不留一堆永远付不了的 pending。 // 渠道下单失败的单直接作废,不留一堆永远付不了的 pending。
_ = h.db.ExpireOrder(ctx, o.ID) _ = h.db.ExpireOrder(ctx, o.ID)
@@ -332,6 +353,25 @@ func (h *Handler) AdminTasks(c *gin.Context) {
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)})
} }
// AdminTaskDetail: GET /api/v1/admin/tasks/:id —— 任务下钻(跨租户)。
// 不复用用户面的 /tasks/:id/replay:那条走请求 ctx,受租户插件过滤,管理员看别的租户
// 的任务会静默拿到空输出/空轨迹(不报错),排查时极具误导性。
func (h *Handler) AdminTaskDetail(c *gin.Context) {
d := h.db.TaskDetail(c.Request.Context(), c.Param("id"))
if d == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"})
return
}
// trace 是收尾时落库的事件数组原文;空串是正常情况(任务未跑完/早于该功能上线)。
exec := []json.RawMessage{}
if d.Trace != "" {
if err := json.Unmarshal([]byte(d.Trace), &exec); err != nil {
exec = []json.RawMessage{} // 脏数据不该让整个下钻 500
}
}
c.JSON(http.StatusOK, gin.H{"task": d, "exec": exec})
}
// 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 := 200
+18 -5
View File
@@ -18,9 +18,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-shared/blob"
"github.com/sundynix/sundynix-gateway/internal/nats" "github.com/sundynix/sundynix-gateway/internal/nats"
"github.com/sundynix/sundynix-gateway/internal/store" "github.com/sundynix/sundynix-gateway/internal/store"
"github.com/sundynix/sundynix-shared/blob"
"github.com/sundynix/sundynix-shared/contract" "github.com/sundynix/sundynix-shared/contract"
) )
@@ -573,7 +573,10 @@ func (h *Handler) AdminKbSearch(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "kb 与 q 必填"}) c.JSON(http.StatusBadRequest, gin.H{"error": "kb 与 q 必填"})
return return
} }
args := map[string]any{"kb": body.KB, "q": body.Q} // diag=true:让 mcp-go 连每一路的诊断一起回(ok/empty/disabled/error)。
// 试验台要回答的是"这一路为什么空"——只有命中数回答不了:没配置、报错、
// 确实没匹配,三者在结果上都是空数组。
args := map[string]any{"kb": body.KB, "q": body.Q, "diag": true}
if body.TopK > 0 { if body.TopK > 0 {
args["topK"] = body.TopK args["topK"] = body.TopK
} }
@@ -590,9 +593,19 @@ func (h *Handler) AdminKbSearch(c *gin.Context) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": res.Error}) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": res.Error})
return return
} }
var hits []map[string]any // 带 diag 时 mcp-go 回的是 {hits, routes} 对象;老版本回裸数组,做兼容降级
_ = json.Unmarshal([]byte(res.Content), &hits) // (少了诊断而已,不该让整个试验台打不开)。
c.JSON(http.StatusOK, gin.H{"hits": hits}) var out struct {
Hits []map[string]any `json:"hits"`
Routes []map[string]any `json:"routes"`
}
if err := json.Unmarshal([]byte(res.Content), &out); err != nil {
var hits []map[string]any
_ = json.Unmarshal([]byte(res.Content), &hits)
c.JSON(http.StatusOK, gin.H{"hits": hits})
return
}
c.JSON(http.StatusOK, gin.H{"hits": out.Hits, "routes": out.Routes})
} }
// KbGraph: GET /api/v1/kb/graph?kb= —— 某知识库的图谱三元组(→ mcp-go kb_graphNeo4j)。 // KbGraph: GET /api/v1/kb/graph?kb= —— 某知识库的图谱三元组(→ mcp-go kb_graphNeo4j)。
@@ -56,6 +56,7 @@ func (h *Handler) AdminStatus(c *gin.Context) {
wg sync.WaitGroup wg sync.WaitGroup
milvus, neo4j bool // mcp-go health milvus, neo4j bool // mcp-go health
ftDisk bool // 全文索引是否落盘持久(false=退内存兜底,重启清零)
goUp bool // mcp-go 在线 goUp bool // mcp-go 在线
goTools []toolInfo // mcp-go 注册工具 goTools []toolInfo // mcp-go 注册工具
goLatency int // mcp-go 探针耗时 goLatency int // mcp-go 探针耗时
@@ -81,6 +82,7 @@ func (h *Handler) AdminStatus(c *gin.Context) {
var sub map[string]bool var sub map[string]bool
if json.Unmarshal([]byte(res.Content), &sub) == nil { if json.Unmarshal([]byte(res.Content), &sub) == nil {
milvus, neo4j = sub["milvus"], sub["neo4j"] milvus, neo4j = sub["milvus"], sub["neo4j"]
ftDisk = sub["fulltext_disk"]
} }
} }
}() }()
@@ -138,6 +140,9 @@ func (h *Handler) AdminStatus(c *gin.Context) {
{Name: "milvus", Up: milvus}, {Name: "milvus", Up: milvus},
{Name: "neo4j", Up: neo4j}, {Name: "neo4j", Up: neo4j},
{Name: "minio", Up: minioUp}, // 对象存储(报告/KB 正文/blob126 {Name: "minio", Up: minioUp}, // 对象存储(报告/KB 正文/blob126
// 全文索引:mcp-go 本地 bleve,是唯一不在 128 集中存储上的检索路,
// 也是唯一会"静默降级"的一路(退内存后重启清零,检索只是变差不报错)。
{Name: "全文索引", Up: goUp && ftDisk, Detail: fulltextDetail(goUp, ftDisk)},
}, },
Services: []statusItem{ Services: []statusItem{
{Name: "gateway", Up: true, Detail: "在线"}, {Name: "gateway", Up: true, Detail: "在线"},
@@ -207,3 +212,16 @@ func humanDuration(s int) string {
} }
return fmt.Sprintf("%dh%dm", m/60, m%60) return fmt.Sprintf("%dh%dm", m/60, m%60)
} }
// fulltextDetail 说明全文(bleve)索引的持久化状态。退内存兜底时必须讲清后果——
// 否则一盏灰灯没人知道意味着"重启就没了"。
func fulltextDetail(goUp, disk bool) string {
switch {
case !goUp:
return "mcp-go 离线,无法判定"
case disk:
return "落盘持久"
default:
return "内存兜底 · 重启即清零(检查 BLEVE_PATH 与挂载卷权限)"
}
}
@@ -0,0 +1,95 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/store"
)
// 订阅 API。用户面只有「看套餐 / 看我的订阅」,下单复用现有 /billing/orders
// (多传 plan_id 即可),因为支付链路、幂等、掉单补偿都已经在那条路上验过了,
// 没必要为订阅再造一条支付路径。
// ---- 用户面 ----
// BillingSubPlans: GET /api/v1/billing/sub-plans —— 在售订阅套餐。
func (h *Handler) BillingSubPlans(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"plans": h.db.ListSubPlans(c.Request.Context(), true)})
}
// MySubscription: GET /api/v1/billing/subscription —— 我的当前订阅(无则 null)。
func (h *Handler) MySubscription(c *gin.Context) {
ctx := c.Request.Context()
billing := h.db.ResolveBillingTenantID(ctx, userID(c), tenantID(c))
if billing == "" {
c.JSON(http.StatusOK, gin.H{"subscription": nil})
return
}
sub := h.db.ActiveSubscription(ctx, billing)
if sub == nil {
c.JSON(http.StatusOK, gin.H{"subscription": nil})
return
}
c.JSON(http.StatusOK, gin.H{"subscription": sub, "plan": h.db.GetSubPlan(ctx, sub.PlanID)})
}
// PublicPricing: GET /api/v1/pricing —— **公开**(无需登录):官网定价页用。
// 官网要在用户登录前就能看到价格,所以这条不挂鉴权;只暴露在售项,且不含任何
// 内部字段(成本、权重、租户信息一概不出去)。
func (h *Handler) PublicPricing(c *gin.Context) {
ctx := c.Request.Context()
packs, _ := h.db.ActivePacks(ctx)
c.JSON(http.StatusOK, gin.H{
"plans": h.db.ListSubPlans(ctx, true),
"packs": packs,
})
}
// ---- 管理端 ----
// AdminSubPlans: GET /api/v1/admin/sub-plans —— 全部套餐(含下架)。
func (h *Handler) AdminSubPlans(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"plans": h.db.ListSubPlans(c.Request.Context(), false)})
}
// AdminSaveSubPlan: PUT /api/v1/admin/sub-plans —— 新增/改套餐(id 空=新增)。
// 积分以「积分」为单位收(面向人),服务端转 micro。
func (h *Handler) AdminSaveSubPlan(c *gin.Context) {
var b struct {
ID string `json:"id"`
Name string `json:"name"`
PriceFen int64 `json:"price_fen"`
DurationDays int `json:"duration_days"`
RefillCredits float64 `json:"refill_credits"`
RefillInterval int `json:"refill_interval_days"`
Active bool `json:"active"`
Sort int `json:"sort"`
}
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name 必填"})
return
}
pl := &store.SubscriptionPlan{
BaseModel: store.BaseModel{ID: b.ID},
Name: strings.TrimSpace(b.Name),
PriceFen: b.PriceFen,
DurationDays: b.DurationDays,
RefillCreditsMicro: int64(b.RefillCredits * 1e6),
RefillIntervalDays: b.RefillInterval,
Active: b.Active,
Sort: b.Sort,
}
if err := h.db.SaveSubPlan(c.Request.Context(), pl); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"id": pl.ID})
}
// AdminSubscriptions: GET /api/v1/admin/subscriptions —— 全平台订阅观测。
func (h *Handler) AdminSubscriptions(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"subscriptions": h.db.AllSubscriptions(c.Request.Context(), 200)})
}
@@ -0,0 +1,58 @@
package handler
import (
"context"
"log"
"time"
)
// 订阅推进定时器:周期扫 active 订阅 → 该发的发、该过期的置过期。
// 与掉单补偿(payment_reconcile.go)同一范式:定时器只是"兜底触发器",
// 真正的语义与幂等都在 store.TickSubscription 里,两处不会漂移。
//
// 为什么需要它:订阅是"有效期内每 N 天发一次积分",没有用户请求来驱动这个节拍。
// 进程停机期间欠下的发放,由 TickSubscription 的补发逻辑一次性补齐。
const subTickInterval = 10 * time.Minute
// StartSubscriptionTicker 随进程生命周期运行;多实例并发也安全(发放靠 ledger 唯一索引幂等)。
func (h *Handler) StartSubscriptionTicker(ctx context.Context) {
go func() {
t := time.NewTicker(subTickInterval)
defer t.Stop()
h.tickSubscriptions(ctx) // 启动即跑一次,把停机期间欠的补上
for {
select {
case <-ctx.Done():
return
case <-t.C:
h.tickSubscriptions(ctx)
}
}
}()
log.Printf("[sub] 订阅推进定时器已启动(每 %s 扫一次)", subTickInterval)
}
func (h *Handler) tickSubscriptions(ctx context.Context) {
subs := h.db.DueSubscriptions(ctx, 200)
if len(subs) == 0 {
return
}
now := time.Now()
var granted, expired int
for i := range subs {
g, exp, err := h.db.TickSubscription(ctx, &subs[i], now)
if err != nil {
// 单条失败不影响其它订阅;下一轮会重试(幂等,不会重复发)
log.Printf("[sub] ⚠️ 推进订阅 %s 失败: %v", subs[i].ID, err)
continue
}
granted += g
if exp {
expired++
}
}
// 只在有变化时记一行,避免空转刷屏
if granted+expired > 0 {
log.Printf("[sub] 推进:发放 %d 笔、过期 %d 条(本轮 %d 条订阅)", granted, expired, len(subs))
}
}
@@ -16,11 +16,11 @@ import (
"github.com/gin-contrib/sse" "github.com/gin-contrib/sse"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-shared/blob"
"github.com/sundynix/sundynix-gateway/internal/dsl" "github.com/sundynix/sundynix-gateway/internal/dsl"
"github.com/sundynix/sundynix-gateway/internal/nats" "github.com/sundynix/sundynix-gateway/internal/nats"
"github.com/sundynix/sundynix-gateway/internal/payment" "github.com/sundynix/sundynix-gateway/internal/payment"
"github.com/sundynix/sundynix-gateway/internal/store" "github.com/sundynix/sundynix-gateway/internal/store"
"github.com/sundynix/sundynix-shared/blob"
"github.com/sundynix/sundynix-shared/contract" "github.com/sundynix/sundynix-shared/contract"
) )
@@ -139,7 +139,11 @@ func (h *Handler) startTokenRecorder(taskID string) {
func() { func() {
_ = h.cache.StreamAppend(ctx, store.ChannelToken, taskID, "done", "") _ = h.cache.StreamAppend(ctx, store.ChannelToken, taskID, "done", "")
if out.Len() > 0 { if out.Len() > 0 {
_ = h.db.SaveTaskOutput(context.Background(), taskID, out.String()) // 失败必须出声:这是**唯一**的持久副本(Redis 流 10min 后就没了),
// 丢了就再也复盘不了,界面上只会显示"这次运行没有输出"。
if err := h.db.SaveTaskOutput(context.Background(), taskID, out.String()); err != nil {
log.Printf("[task] ⚠️ 输出落库失败 task=%s len=%d: %v(该次运行将无法复盘)", taskID, out.Len(), err)
}
} }
cancel() cancel()
}, },
@@ -170,8 +174,12 @@ func (h *Handler) startExecRecorder(taskID string) {
func() { func() {
_ = h.cache.StreamAppend(ctx, store.ChannelExec, taskID, "done", "") _ = h.cache.StreamAppend(ctx, store.ChannelExec, taskID, "done", "")
if len(evs) > 0 { if len(evs) > 0 {
if b, err := json.Marshal(evs); err == nil { // 同上:轨迹只有这一份持久副本,静默失败会表现为"这次运行没有轨迹"。
_ = h.db.SaveTaskTrace(context.Background(), taskID, string(b)) b, err := json.Marshal(evs)
if err != nil {
log.Printf("[task] ⚠️ 轨迹序列化失败 task=%s events=%d: %v(轨迹将丢失)", taskID, len(evs), err)
} else if err := h.db.SaveTaskTrace(context.Background(), taskID, string(b)); err != nil {
log.Printf("[task] ⚠️ 轨迹落库失败 task=%s events=%d: %v(该次运行将无法复盘)", taskID, len(evs), err)
} }
} }
cancel() cancel()
@@ -2,6 +2,7 @@ package middleware
import ( import (
"context" "context"
"log"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -35,6 +36,10 @@ func Audit(db *store.Postgres) gin.HandlerFunc {
} }
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
_ = db.AppendAudit(ctx, entry) // best-effort:审计失败不阻断主流程(业务已经做完了),但必须留痕 ——
// 静默失败意味着敏感操作没有记录,而没人知道记录缺了。
if err := db.AppendAudit(ctx, entry); err != nil {
log.Printf("[audit] ⚠️ 审计留痕写入失败 actor=%s %s %s: %v", entry.Actor, entry.Action, entry.Path, err)
}
} }
} }
+92 -69
View File
@@ -7,18 +7,19 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"github.com/sundynix/sundynix-shared/blob"
"github.com/sundynix/sundynix-gateway/internal/handler" "github.com/sundynix/sundynix-gateway/internal/handler"
"github.com/sundynix/sundynix-gateway/internal/middleware" "github.com/sundynix/sundynix-gateway/internal/middleware"
"github.com/sundynix/sundynix-gateway/internal/nats" "github.com/sundynix/sundynix-gateway/internal/nats"
"github.com/sundynix/sundynix-gateway/internal/store" "github.com/sundynix/sundynix-gateway/internal/store"
"github.com/sundynix/sundynix-gateway/internal/webui" "github.com/sundynix/sundynix-gateway/internal/webui"
"github.com/sundynix/sundynix-shared/blob"
) )
// New 构建带有 Guardrail / 限流中间件的 Gin 引擎。 // New 构建带有 Guardrail / 限流中间件的 Gin 引擎。
@@ -28,18 +29,19 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
r.Use(otelgin.Middleware("sundynix-gateway")) // OTel: HTTP server span(链路根 + 提取上游 traceparent r.Use(otelgin.Middleware("sundynix-gateway")) // OTel: HTTP server span(链路根 + 提取上游 traceparent
r.Use(middleware.RequestID()) // 生成/透传 X-Request-ID(日志关联) r.Use(middleware.RequestID()) // 生成/透传 X-Request-ID(日志关联)
r.Use(middleware.Observe()) // Prometheus 指标 + 结构化访问日志(替代 gin 默认文本日志) r.Use(middleware.Observe()) // Prometheus 指标 + 结构化访问日志(替代 gin 默认文本日志)
r.Use(cors()) // 桌面端/浏览器跨源访问 r.Use(cors()) // 桌面端/浏览器跨源访问
r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)——须在限流前,供按用户限流 r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)——须在限流前,供按用户限流
r.Use(middleware.TenantContext(db)) // 多租户:注入当前 tenant_id(已登录才解析;须在 Auth 之后) r.Use(middleware.TenantContext(db)) // 多租户:注入当前 tenant_id(已登录才解析;须在 Auth 之后)
r.Use(middleware.SpaceContext(db)) // 共享工作区:注入当前 space_id(须在 TenantContext 之后) r.Use(middleware.SpaceContext(db)) // 共享工作区:注入当前 space_id(须在 TenantContext 之后)
r.Use(middleware.RateLimit(cache)) // 已认证按用户限流,否则按 IP(企业网多人共享 IP 不再互相拖累) r.Use(middleware.RateLimit(cache)) // 已认证按用户限流,否则按 IP(企业网多人共享 IP 不再互相拖累)
r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event
h := handler.New(db, cache, bus, blobStore) h := handler.New(db, cache, bus, blobStore)
// 微信支付渠道装配:DB 配置优先(admin 控制面热重载)→ env 兜底 → 隐藏。失败不阻断启动。 // 微信支付渠道装配:DB 配置优先(admin 控制面热重载)→ env 兜底 → 隐藏。失败不阻断启动。
h.InitWechat(context.Background()) h.InitWechat(context.Background())
// 掉单补偿定时器:周期扫 pending 微信单确认到账(用户扫完码关页面也能补入账)。 // 掉单补偿定时器:周期扫 pending 微信单确认到账(用户扫完码关页面也能补入账)。
h.StartReconcile(context.Background()) h.StartReconcile(context.Background())
h.StartSubscriptionTicker(context.Background()) // 订阅按周期发放积分 + 到期置失效
// 可观测性根端点:Prometheus 抓取 + k8s 存活/就绪探针(不挂业务中间件鉴权)。 // 可观测性根端点:Prometheus 抓取 + k8s 存活/就绪探针(不挂业务中间件鉴权)。
r.GET("/metrics", gin.WrapH(promhttp.Handler())) r.GET("/metrics", gin.WrapH(promhttp.Handler()))
@@ -49,73 +51,76 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
api := r.Group("/api/v1") api := r.Group("/api/v1")
{ {
// —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)—— // —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)——
api.POST("/auth/register", h.Register) // 注册 + 签发 JWT api.GET("/pricing", h.PublicPricing) // 公开定价(官网未登录也要能看价,故不挂鉴权)
api.POST("/auth/login", h.Login) // 登录 + 签发 JWT api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401 api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯 api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401
api.GET("/tasks/:id/stream", h.StreamTask) // SSE 回流 Token Streamtask_id 寻址 api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯
api.GET("/tasks/:id/exec", h.StreamExec) // SSE 回流执行轨迹task_id 寻址) api.GET("/tasks/:id/stream", h.StreamTask) // SSE 回流 Token Streamtask_id 寻址)
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSEjob_id 寻址) api.GET("/tasks/:id/exec", h.StreamExec) // SSE 回流执行轨迹(task_id 寻址)
api.GET("/reports/:id/export", h.ExportReport) // 按需导出(report_id 寻址) api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSEjob_id 寻址)
api.GET("/reports/:id/download", h.ExportReport) // 兼容旧入口(默认 docx api.GET("/reports/:id/export", h.ExportReport) // 按需导出(report_id 寻址
api.GET("/reports/:id/download", h.ExportReport) // 兼容旧入口(默认 docx
api.POST("/billing/callback/:channel", h.PaymentCallback) // 支付回调(渠道服务器带不了 Bearer;渠道验签是唯一的门) api.POST("/billing/callback/:channel", h.PaymentCallback) // 支付回调(渠道服务器带不了 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.Audit(db), h.ApproveTask) // HITL 人工审批决定(批准/拒绝,审计)
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,建完切入)
p.POST("/me/tenant", h.SwitchTenant) // 切换当前活跃租户 p.POST("/me/tenant", h.SwitchTenant) // 切换当前活跃租户
// 租户成员自助管理(薄 Web 面):作用于活跃租户,看 ≥viewer、写 ≥admin + 审计。 // 租户成员自助管理(薄 Web 面):作用于活跃租户,看 ≥viewer、写 ≥admin + 审计。
p.GET("/tenants/current/members", middleware.RequireTenantRole(db, store.RoleViewer), h.TenantMembers) p.GET("/tenants/current/members", middleware.RequireTenantRole(db, store.RoleViewer), h.TenantMembers)
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)
p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗) p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗)
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) // 列出当前用户偏好(记忆面板)
p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key= p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key=
p.GET("/kb/list", h.KbList) // 当前空间的知识库列表(共享工作区) p.GET("/kb/list", h.KbList) // 当前空间的知识库列表(共享工作区)
p.POST("/kb/create", middleware.RequireSpaceRole(db, store.RoleMember), h.KbCreate) // 新建知识库:空间只读 viewer 拦下 p.POST("/kb/create", middleware.RequireSpaceRole(db, store.RoleMember), h.KbCreate) // 新建知识库:空间只读 viewer 拦下
p.POST("/kb/ingest", middleware.RequireSpaceRole(db, store.RoleMember), h.KbIngest) // 文本入库:viewer 拦下 p.POST("/kb/ingest", middleware.RequireSpaceRole(db, store.RoleMember), h.KbIngest) // 文本入库:viewer 拦下
p.POST("/kb/ingest_file", middleware.RequireSpaceRole(db, store.RoleMember), h.KbIngestFile) // 文件入库:viewer 拦下 p.POST("/kb/ingest_file", middleware.RequireSpaceRole(db, store.RoleMember), h.KbIngestFile) // 文件入库:viewer 拦下
p.POST("/kb/search", h.KbSearch) // 检索台(读,全员) p.POST("/kb/search", h.KbSearch) // 检索台(读,全员)
p.GET("/kb/vault", h.KbVault) // 文库列表 p.GET("/kb/vault", h.KbVault) // 文库列表
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) // 列出全部版本 + 可配键 p.GET("/prompts", h.PromptList) // 列出全部版本 + 可配键
p.POST("/prompts/version", h.PromptCreateVersion) // 建新版本(不自动激活) p.POST("/prompts/version", h.PromptCreateVersion) // 建新版本(不自动激活)
p.POST("/prompts/activate", middleware.Audit(db), h.PromptActivate) // 激活某版本 → 广播热更新(审计) p.POST("/prompts/activate", middleware.Audit(db), h.PromptActivate) // 激活某版本 → 广播热更新(审计)
p.POST("/prompts/deactivate", middleware.Audit(db), h.PromptDeactivate) // 撤销激活 → 回退代码默认(热,审计) p.POST("/prompts/deactivate", 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) // 知识图谱三元组
p.GET("/agents", h.AgentList) // 当前空间的编排列表(共享工作区,含创建人) p.GET("/agents", h.AgentList) // 当前空间的编排列表(共享工作区,含创建人)
p.POST("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentSave) // 保存/更新编排:空间只读 viewer 拦下 p.POST("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentSave) // 保存/更新编排:空间只读 viewer 拦下
p.DELETE("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentDelete) // 删除编排:viewer 拦下(删他人另需 admin,见 handler p.DELETE("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentDelete) // 删除编排:viewer 拦下(删他人另需 admin,见 handler
// 共享工作区(Space):切换 / 列表 / 建 / 成员管理(增量3) // 共享工作区(Space):切换 / 列表 / 建 / 成员管理(增量3)
p.GET("/me/spaces", h.SpacesList) // 活跃租户内我所属的空间(供切换) p.GET("/me/spaces", h.SpacesList) // 活跃租户内我所属的空间(供切换)
p.POST("/me/space", h.SwitchSpace) // 切换活跃空间 p.POST("/me/space", h.SwitchSpace) // 切换活跃空间
p.GET("/spaces/current", h.SpaceCurrent) // 当前空间上下文 + 我的角色 p.GET("/spaces/current", h.SpaceCurrent) // 当前空间上下文 + 我的角色
p.POST("/spaces", middleware.RequireTenantRole(db, store.RoleMember), h.SpaceCreate) // 建空间:租户只读 viewer 拦下 p.POST("/spaces", middleware.RequireTenantRole(db, store.RoleMember), h.SpaceCreate) // 建空间:租户只读 viewer 拦下
p.GET("/spaces/:id/members", h.SpaceMembers) // 空间成员列表 p.GET("/spaces/:id/members", h.SpaceMembers) // 空间成员列表
p.POST("/spaces/:id/members", h.SpaceAddMember) // 拉人进空间(handler 内校验空间 admin p.POST("/spaces/:id/members", h.SpaceAddMember) // 拉人进空间(handler 内校验空间 admin
p.PUT("/spaces/:id/members/:uid", h.SpaceSetMemberRole) // 改空间成员角色 p.PUT("/spaces/:id/members/:uid", h.SpaceSetMemberRole) // 改空间成员角色
p.DELETE("/spaces/:id/members/:uid", h.SpaceRemoveMember) // 移除空间成员 p.DELETE("/spaces/:id/members/:uid", h.SpaceRemoveMember) // 移除空间成员
p.POST("/spaces/:id/archive", h.SpaceArchive) // 归档空间 p.POST("/spaces/:id/archive", h.SpaceArchive) // 归档空间
p.POST("/reports", middleware.RequireTenantRole(db, store.RoleMember), h.GenerateReport) // 报告生成(同样烧租户积分):viewer 只读拦下 p.POST("/reports", middleware.RequireTenantRole(db, store.RoleMember), h.GenerateReport) // 报告生成(同样烧租户积分):viewer 只读拦下
p.GET("/billing", h.Billing) p.GET("/billing", h.Billing)
// 充值(P5.1 兑换码 + P5.2 微信 Native):动钱的 ≥member + 审计;查询全员可看。 // 充值(P5.1 兑换码 + P5.2 微信 Native):动钱的 ≥member + 审计;查询全员可看。
p.GET("/billing/packs", h.BillingPacks) p.GET("/billing/packs", h.BillingPacks)
p.GET("/billing/sub-plans", h.BillingSubPlans) // 在售订阅套餐
p.GET("/billing/subscription", h.MySubscription) // 我的订阅(到期时间/发放次数)
p.GET("/billing/orders", h.BillingOrders) p.GET("/billing/orders", h.BillingOrders)
p.GET("/billing/orders/:id", h.BillingOrderStatus) // 轮询单态(pending 时顺路主动查单确认) p.GET("/billing/orders/:id", h.BillingOrderStatus) // 轮询单态(pending 时顺路主动查单确认)
p.POST("/billing/redeem", middleware.RequireTenantRole(db, store.RoleMember), middleware.Audit(db), h.BillingRedeem) p.POST("/billing/redeem", middleware.RequireTenantRole(db, store.RoleMember), middleware.Audit(db), h.BillingRedeem)
@@ -133,9 +138,9 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
admin.POST("/models/:id/active", h.SetActiveModel) admin.POST("/models/:id/active", h.SetActiveModel)
admin.DELETE("/models/:id", h.DeleteModel) admin.DELETE("/models/:id", h.DeleteModel)
admin.POST("/models/test", h.TestModel) admin.POST("/models/test", h.TestModel)
admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱 + 积分权重) admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱 + 积分权重)
admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价 + 积分权重 admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价 + 积分权重
admin.GET("/billing-config", h.BillingConfig) // 全局计费规则(token→积分汇率 + 硬拦截开关) admin.GET("/billing-config", h.BillingConfig) // 全局计费规则(token→积分汇率 + 硬拦截开关)
admin.PUT("/billing-config", h.SaveBillingConfig) admin.PUT("/billing-config", h.SaveBillingConfig)
admin.POST("/credits/grant", h.GrantCredits) // 给租户充值/发放积分 admin.POST("/credits/grant", h.GrantCredits) // 给租户充值/发放积分
// 支付配置面(P5.1/P5.2):兑换码生成/查看 + 积分包配置 + 微信支付配置(DB 热生效) // 支付配置面(P5.1/P5.2):兑换码生成/查看 + 积分包配置 + 微信支付配置(DB 热生效)
@@ -145,31 +150,35 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
admin.PUT("/packs", h.AdminSavePack) admin.PUT("/packs", h.AdminSavePack)
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("/orders", h.AdminOrders) // 全平台充值订单流 + 状态计数 admin.GET("/sub-plans", h.AdminSubPlans) // 订阅套餐(含下架)
admin.GET("/orders/reconcile", h.AdminReconcile) // 日终对账:paid 单 ↔ 账本 grant admin.PUT("/sub-plans", h.AdminSaveSubPlan) // 配价格/时长/发放节奏
admin.GET("/subscriptions", h.AdminSubscriptions) // 全平台订阅观测
admin.GET("/orders", h.AdminOrders) // 全平台充值订单流 + 状态计数
admin.GET("/orders/reconcile", h.AdminReconcile) // 日终对账:paid 单 ↔ 账本 grant
admin.POST("/orders/:id/refund", h.AdminRefundOrder) // 人工退款:置 refunded + adjust 负分录 + 回退余额(审计) admin.POST("/orders/:id/refund", h.AdminRefundOrder) // 人工退款:置 refunded + adjust 负分录 + 回退余额(审计)
// 多租户成员管理(平台运维口径) // 多租户成员管理(平台运维口径)
admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额) admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额)
admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner
admin.PUT("/tenants/:id/shared-billing", h.AdminSetSharedBilling) // 共享计费开关 admin.PUT("/tenants/:id/shared-billing", h.AdminSetSharedBilling) // 共享计费开关
admin.PUT("/tenants/:id/plan", h.AdminSetTenantPlan) // 方案等级 admin.PUT("/tenants/:id/plan", h.AdminSetTenantPlan) // 方案等级
admin.PUT("/tenants/:id/status", h.AdminSetTenantStatus) // 租户状态 admin.PUT("/tenants/:id/status", h.AdminSetTenantStatus) // 租户状态
admin.GET("/tenants/:id/members", h.AdminMembers) // 成员列表 admin.GET("/tenants/:id/members", h.AdminMembers) // 成员列表
admin.POST("/tenants/:id/members", h.AdminAddMember) // 按邮箱加成员 admin.POST("/tenants/:id/members", h.AdminAddMember) // 按邮箱加成员
admin.PUT("/tenants/:id/members/:uid", h.AdminSetMemberRole) // 改角色 admin.PUT("/tenants/:id/members/:uid", h.AdminSetMemberRole) // 改角色
admin.DELETE("/tenants/:id/members/:uid", h.AdminRemoveMember) // 移除成员 admin.DELETE("/tenants/:id/members/:uid", h.AdminRemoveMember) // 移除成员
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册 admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康 admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康
admin.GET("/tasks", h.AdminTasks) // 全平台任务/运行观测(状态/租户筛 + HITL 待审批) admin.GET("/tasks", h.AdminTasks) // 全平台任务/运行观测(状态/租户筛 + HITL 待审批)
admin.GET("/spaces", h.AdminSpaces) // 全平台空间观测(跨租户 Space + 成员数 admin.GET("/tasks/:id", h.AdminTaskDetail) // 任务下钻:DSL/输出/轨迹/评测(跨租户,走 WithoutTenant
admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额 admin.GET("/spaces", h.AdminSpaces) // 全平台空间观测(跨租户 Space + 成员数)
admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据) admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额
admin.GET("/datasources", h.AdminDatasources) // 数据源清单:全平台知识库 + 文档数(真数据) admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据)
admin.POST("/kb/search", h.AdminKbSearch) // 检索试验台:按完整作用域键跨租户检索(支持单路 mode 对比) admin.GET("/datasources", h.AdminDatasources) // 数据源清单:全平台知识库 + 文档数(真数据)
admin.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性) admin.POST("/kb/search", h.AdminKbSearch) // 检索试验台:按完整作用域键跨租户检索(支持单路 mode 对比)
admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页 admin.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性
admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页) admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页)
admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页)
} }
} }
@@ -187,6 +196,20 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
c.JSON(http.StatusNotFound, gin.H{"error": "接口不存在"}) c.JSON(http.StatusNotFound, gin.H{"error": "接口不存在"})
return return
} }
// 微信域名校验文件(MP_verify_xxx.txt):配置 JS安全域名/网页授权域名时微信要求
// 能从域名根目录直接访问。文件放 WECHAT_VERIFY_DIR(容器里挂宿主机目录),
// 不进镜像也不进 git。
// 放在 NoRoute 里而不是注册 /:mpfile —— 后者会匹配**所有单段路径**,
// 把官网的 /pricing、/download 全变成 404(实测踩过)。
if dir := os.Getenv("WECHAT_VERIFY_DIR"); dir != "" {
name := strings.TrimPrefix(p, "/")
// 只放行这一种文件名,且不含路径分隔符,避免变成任意文件下载口子
if strings.HasPrefix(name, "MP_verify_") && strings.HasSuffix(name, ".txt") &&
!strings.ContainsAny(name, `/\`) && !strings.Contains(name, "..") {
c.File(filepath.Join(dir, name))
return
}
}
if rel := strings.TrimPrefix(p, "/"); rel != "" { if rel := strings.TrimPrefix(p, "/"); rel != "" {
if _, statErr := fs.Stat(adminDist, rel); statErr == nil { if _, statErr := fs.Stat(adminDist, rel); statErr == nil {
adminServer.ServeHTTP(c.Writer, c.Request) // /assets/* 等真实文件 adminServer.ServeHTTP(c.Writer, c.Request) // /assets/* 等真实文件
+40 -4
View File
@@ -1,6 +1,9 @@
package store package store
import "context" import (
"context"
"strings"
)
// AppendAudit 追加一条审计留痕(best-effort:审计失败不应影响主流程,调用方忽略返回)。 // AppendAudit 追加一条审计留痕(best-effort:审计失败不应影响主流程,调用方忽略返回)。
func (p *Postgres) AppendAudit(ctx context.Context, a *AuditLog) error { func (p *Postgres) AppendAudit(ctx context.Context, a *AuditLog) error {
@@ -10,14 +13,47 @@ func (p *Postgres) AppendAudit(ctx context.Context, a *AuditLog) error {
return p.db.WithContext(ctx).Create(a).Error return p.db.WithContext(ctx).Create(a).Error
} }
// ListAudit 倒序列出审计留痕(管理端审计流;limit 限流、offset 翻页)。 // AuditFilter 是审计流的服务端筛选条件。筛选必须落到 SQL:审计的用途是查证,
func (p *Postgres) ListAudit(ctx context.Context, limit, offset int) ([]AuditLog, error) { // 若只在“当前页”里筛,搜不到就等于给出“没有这条记录”的错误结论。
type AuditFilter struct {
Action string // HTTP 方法,精确匹配
Path string // 路径前缀
Q string // 跨 actor / ip / detail / path 的模糊匹配
}
// escapeLike 转义 LIKE 的通配符,让用户输入的 % 和 _ 按字面量匹配
// (否则搜 "100%" 会退化成匹配任意串)。
// 配套的 SQL 必须显式写 ESCAPE '\\'Postgres 默认就拿反斜杠当转义符,但 SQLite 不写
// ESCAPE 就压根没有转义符——依赖隐式默认会在换库/单测时静默失效。
func escapeLike(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
// ListAudit 倒序列出审计留痕(管理端审计流;limit 限流、offset 翻页,筛选走 SQL)。
func (p *Postgres) ListAudit(ctx context.Context, limit, offset int, f AuditFilter) ([]AuditLog, error) {
if p.db == nil { if p.db == nil {
return nil, errStoreDisabled return nil, errStoreDisabled
} }
limit, offset = clampPage(limit, offset) limit, offset = clampPage(limit, offset)
q := p.db.WithContext(ctx).Model(&AuditLog{})
if f.Action != "" {
q = q.Where("action = ?", f.Action)
}
if f.Path != "" {
q = q.Where(`path LIKE ? ESCAPE '\'`, escapeLike(f.Path)+"%")
}
if f.Q != "" {
like := "%" + strings.ToLower(escapeLike(f.Q)) + "%"
// 用 LOWER()+LIKE 而非 Postgres 专有的 ILIKE:语义一样,但 SQLite 也支持,
// 于是这段能被内存库单测覆盖(审计搜索量小,放弃索引可忽略)。
// 括号不能省:gorm 把每个 Where 以 AND 拼接,裸的 OR 串会让优先级变成
// `action = ? AND actor LIKE ? OR ip LIKE ? ...`,前面的条件直接失效。
q = q.Where(`(LOWER(actor) LIKE ? ESCAPE '\' OR LOWER(ip) LIKE ? ESCAPE '\' `+
`OR LOWER(detail) LIKE ? ESCAPE '\' OR LOWER(path) LIKE ? ESCAPE '\')`,
like, like, like, like)
}
var out []AuditLog var out []AuditLog
err := p.db.WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&out).Error err := q.Order("created_at desc").Limit(limit).Offset(offset).Find(&out).Error
return out, err return out, err
} }
@@ -0,0 +1,107 @@
package store
import (
"context"
"testing"
)
// 审计筛选必须落到 SQL。此前是前端在“当前页 50 条”里过滤,翻页外的记录搜不到 ——
// 审计的用途就是查证,“搜不到”会被当成“没发生过”,所以这里把筛选语义固化成回归测试。
func seedAudit(t *testing.T, p *Postgres, rows []AuditLog) {
t.Helper()
ctx := context.Background()
for i := range rows {
if rows[i].ID == "" {
rows[i].ID = rows[i].Actor + rows[i].Path + rows[i].IP + itoa(i)
}
if err := p.AppendAudit(ctx, &rows[i]); err != nil {
t.Fatalf("写审计失败: %v", err)
}
}
}
func itoa(i int) string { return string(rune('a' + i)) }
func TestListAudit_FilterAction(t *testing.T) {
p := newTestStore(t)
seedAudit(t, p, []AuditLog{
{Actor: "u1", Action: "POST", Path: "/api/v1/admin/models", IP: "10.0.0.1"},
{Actor: "u2", Action: "DELETE", Path: "/api/v1/admin/models/9", IP: "10.0.0.2"},
})
got, err := ListAuditT(p, AuditFilter{Action: "DELETE"})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(got) != 1 || got[0].Actor != "u2" {
t.Fatalf("按方法筛应只剩 u2 那条,得 %d 条 %+v", len(got), got)
}
}
// 最关键的一条:q 的 OR 组必须带括号。少了括号时 SQL 会变成
// `action = 'DELETE' AND actor LIKE .. OR ip LIKE .. OR ..`
// AND 优先级高于 OR → 只要 ip/detail/path 命中,action 条件就被绕过。
func TestListAudit_ActionAndQueryAreConjunctive(t *testing.T) {
p := newTestStore(t)
seedAudit(t, p, []AuditLog{
{Actor: "alice", Action: "DELETE", Path: "/x", IP: "10.0.0.7"},
{Actor: "bob", Action: "GET", Path: "/y", IP: "10.0.0.7"}, // 同 IP 但方法不符
})
got, err := ListAuditT(p, AuditFilter{Action: "DELETE", Q: "10.0.0.7"})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(got) != 1 || got[0].Actor != "alice" {
t.Fatalf("方法与关键词应同时生效(AND),得 %d 条 %+v —— 括号可能丢了", len(got), got)
}
}
func TestListAudit_QueryMatchesAcrossFields(t *testing.T) {
p := newTestStore(t)
seedAudit(t, p, []AuditLog{
{Actor: "u1", Action: "POST", Path: "/tenants", IP: "1.1.1.1", Detail: "移除成员 zhang"},
{Actor: "u2", Action: "POST", Path: "/models", IP: "2.2.2.2", Detail: "保存模型"},
})
for _, tc := range []struct{ name, q, want string }{
{"命中 detail", "zhang", "u1"},
{"命中 ip", "2.2.2.2", "u2"},
{"命中 path", "/tenants", "u1"},
{"命中 actor", "u2", "u2"},
{"大小写不敏感", "ZHANG", "u1"},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := ListAuditT(p, AuditFilter{Q: tc.q})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(got) != 1 || got[0].Actor != tc.want {
t.Fatalf("q=%q 应只命中 %s,得 %d 条 %+v", tc.q, tc.want, len(got), got)
}
})
}
}
// 用户输入里的 % / _ 必须按字面量匹配,否则搜 "100%" 会退化成匹配任意串。
func TestListAudit_QueryEscapesWildcards(t *testing.T) {
p := newTestStore(t)
seedAudit(t, p, []AuditLog{
{Actor: "u1", Action: "POST", Path: "/a", IP: "1.1.1.1", Detail: "折扣 100% 生效"},
{Actor: "u2", Action: "POST", Path: "/b", IP: "2.2.2.2", Detail: "无关记录"},
})
got, err := ListAuditT(p, AuditFilter{Q: "100%"})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(got) != 1 || got[0].Actor != "u1" {
t.Fatalf("%% 应按字面量匹配,得 %d 条 %+v", len(got), got)
}
}
// ListAuditT 是测试用的薄封装,省去每处都写 ctx/limit/offset。
func ListAuditT(p *Postgres, f AuditFilter) ([]AuditLog, error) {
return p.ListAudit(context.Background(), 50, 0, f)
}
+2 -2
View File
@@ -57,7 +57,7 @@ func applyUsageCredit(tx *gorm.DB, tenantID, taskID string, creditsMicro int64)
return err return err
} }
return tx.Model(&Tenant{}).Where("id = ?", tenantID). return tx.Model(&Tenant{}).Where("id = ?", tenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro - ?", creditsMicro)).Error UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) - ?", creditsMicro)).Error
} }
// upsertRollup 在事务内累加 租户/天 聚合。 // upsertRollup 在事务内累加 租户/天 聚合。
@@ -165,6 +165,6 @@ func (p *Postgres) GrantCredits(ctx context.Context, tenantID, kind string, cred
return err return err
} }
return tx.Model(&Tenant{}).Where("id = ?", tenantID). return tx.Model(&Tenant{}).Where("id = ?", tenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", creditsMicro)).Error UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) + ?", creditsMicro)).Error
}) })
} }
+13 -11
View File
@@ -10,9 +10,9 @@ type User struct {
BaseModel BaseModel
Email string `gorm:"uniqueIndex;size:255"` Email string `gorm:"uniqueIndex;size:255"`
Name string `gorm:"size:64"` Name string `gorm:"size:64"`
PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON
ActiveTenantID string `gorm:"size:64" json:"-"` // 当前活跃租户(多租户切换;空=用默认) ActiveTenantID string `gorm:"size:64" json:"-"` // 当前活跃租户(多租户切换;空=用默认)
ActiveSpaceID string `gorm:"size:64" json:"-"` // 当前活跃工作区(Space)(增量3;空/失效=用活跃租户的个人空间) ActiveSpaceID string `gorm:"size:64" json:"-"` // 当前活跃工作区(Space)(增量3;空/失效=用活跃租户的个人空间)
} }
// Task 是一次提交的 Agent 编排任务(DSL)。 // Task 是一次提交的 Agent 编排任务(DSL)。
@@ -35,8 +35,8 @@ func (Task) isTenantScoped() {}
// Eval 是一次任务的自动化评测结果(dispatcher 评完经 NATS 回写,每任务一条,按 task_id upsert)。 // Eval 是一次任务的自动化评测结果(dispatcher 评完经 NATS 回写,每任务一条,按 task_id upsert)。
type Eval struct { type Eval struct {
BaseModel BaseModel
TenantID string `gorm:"size:64;index"` // 多租户作用域(SaveEval 从对应 task 复制) TenantID string `gorm:"size:64;index"` // 多租户作用域(SaveEval 从对应 task 复制)
Owner string `gorm:"size:64;index"` // 提交者 user.id(从对应 task 复制) Owner string `gorm:"size:64;index"` // 提交者 user.id(从对应 task 复制)
TaskID string `gorm:"uniqueIndex;size:64"` TaskID string `gorm:"uniqueIndex;size:64"`
Overall float64 // 综合分 [0,1] Overall float64 // 综合分 [0,1]
Rule float64 // 规则分 Rule float64 // 规则分
@@ -85,12 +85,14 @@ func (GuardrailEvent) TableName() string { return "sundynix_guardrail_event" }
// Tenant 是多租户的计费/隔离单位(组织/账户)。个人用户 = 一个单人默认租户;团队/企业 = 多成员。 // Tenant 是多租户的计费/隔离单位(组织/账户)。个人用户 = 一个单人默认租户;团队/企业 = 多成员。
type Tenant struct { type Tenant struct {
BaseModel BaseModel
Name string `gorm:"size:128"` Name string `gorm:"size:128"`
Slug string `gorm:"size:64;uniqueIndex"` // 唯一短标识(默认租户用 default-<uid> Slug string `gorm:"size:64;uniqueIndex"` // 唯一短标识(默认租户用 default-<uid>
Plan string `gorm:"size:32;default:free"` // free / pro / enterprise Plan string `gorm:"size:32;default:free"` // free / pro / enterprise
Status string `gorm:"size:16;default:active"` // active / suspended Status string `gorm:"size:16;default:active"` // active / suspended
CreditBalanceMicro int64 `gorm:"column:credit_balance_micro"` // 物化积分余额 ×10⁻⁶(= credit_ledger 之和;用量扣、充值增) // 只给 default**不加 not null**:存量库里这一列有历史 NULL 行,AutoMigrate 若尝试
SharedBilling bool `gorm:"column:shared_billing"` // 共享计费:开=成员消耗计本租户池;关=成员计各自个人池(owner 恒计本租户) // SET NOT NULL 会直接失败(且它在回填之前跑)。空值由入账处的 coalesce + 启动回填兜住。
CreditBalanceMicro int64 `gorm:"column:credit_balance_micro;default:0"` // 物化积分余额 ×10⁻⁶(= credit_ledger 之和;用量扣、充值增)
SharedBilling bool `gorm:"column:shared_billing"` // 共享计费:开=成员消耗计本租户池;关=成员计各自个人池(owner 恒计本租户)
} }
func (Tenant) TableName() string { return "sundynix_tenant" } func (Tenant) TableName() string { return "sundynix_tenant" }
+40 -14
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"errors" "errors"
"log"
"strings" "strings"
"time" "time"
@@ -19,6 +20,12 @@ import (
// - CreditPack / RedeemCode 是平台级配置与凭证,不属于任何租户。 // - CreditPack / RedeemCode 是平台级配置与凭证,不属于任何租户。
// 订单状态机:pending → paid | failed | expiredpaid →(人工)refunded。 // 订单状态机:pending → paid | failed | expiredpaid →(人工)refunded。
const (
// 订单类型:一次性积分包 vs 订阅周期
OrderKindPack = "pack"
OrderKindSub = "sub"
)
const ( const (
OrderPending = "pending" OrderPending = "pending"
OrderPaid = "paid" OrderPaid = "paid"
@@ -49,9 +56,11 @@ func (CreditPack) TableName() string { return "sundynix_credit_pack" }
// 兑换码入账也写一行(channel=redeem、amount_fen=0、即时 paid),全部充值一个查法。 // 兑换码入账也写一行(channel=redeem、amount_fen=0、即时 paid),全部充值一个查法。
type PaymentOrder struct { type PaymentOrder struct {
BaseModel BaseModel
TenantID string `gorm:"size:64;index" json:"tenant_id"` // 计费租户(下单时解析并锁定) TenantID string `gorm:"size:64;index" json:"tenant_id"` // 计费租户(下单时解析并锁定)
UserID string `gorm:"size:64;index" json:"user_id"` // 操作人(审计) UserID string `gorm:"size:64;index" json:"user_id"` // 操作人(审计)
PackID string `gorm:"size:24" json:"pack_id"` // redeem 渠道为空 PackID string `gorm:"size:24" json:"pack_id"` // redeem 渠道为空
Kind string `gorm:"size:16;default:pack" json:"kind"` // pack=积分包(一次性) / sub=订阅
PlanID string `gorm:"size:24" json:"plan_id"` // kind=sub 时的订阅套餐
AmountFen int64 `gorm:"column:amount_fen" json:"amount_fen"` AmountFen int64 `gorm:"column:amount_fen" json:"amount_fen"`
CreditsMicro int64 `gorm:"column:credits_micro" json:"credits_micro"` CreditsMicro int64 `gorm:"column:credits_micro" json:"credits_micro"`
Channel string `gorm:"size:16;index" json:"channel"` Channel string `gorm:"size:16;index" json:"channel"`
@@ -179,7 +188,7 @@ func (p *Postgres) Redeem(ctx context.Context, code, tenantID, userID string) (*
return err return err
} }
if err := tx.Model(&Tenant{}).Where("id = ?", tenantID). if err := tx.Model(&Tenant{}).Where("id = ?", tenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", rc.CreditsMicro)).Error; err != nil { UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) + ?", rc.CreditsMicro)).Error; err != nil {
return err return err
} }
order = o order = o
@@ -219,6 +228,7 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
return false, errStoreDisabled return false, errStoreDisabled
} }
changed := false changed := false
var paid *PaymentOrder
err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error { err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error {
now := time.Now() now := time.Now()
res := tx.Model(&PaymentOrder{}). res := tx.Model(&PaymentOrder{}).
@@ -234,19 +244,35 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
if err := tx.First(&o, "id = ?", orderID).Error; err != nil { if err := tx.First(&o, "id = ?", orderID).Error; err != nil {
return err return err
} }
if err := tx.Create(&CreditLedger{ paid = &o
TenantID: o.TenantID, Kind: LedgerGrant, CreditsMicro: o.CreditsMicro, Ref: o.ID, Memo: "充值 " + o.Channel, // 订阅单自身不带积分(积分由订阅按周期发放),跳过零额分录避免账本噪声。
}).Error; err != nil { if o.CreditsMicro != 0 {
return err if err := tx.Create(&CreditLedger{
} TenantID: o.TenantID, Kind: LedgerGrant, CreditsMicro: o.CreditsMicro, Ref: o.ID, Memo: "充值 " + o.Channel,
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID). }).Error; err != nil {
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", o.CreditsMicro)).Error; err != nil { return err
return err }
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) + ?", o.CreditsMicro)).Error; err != nil {
return err
}
} }
changed = true changed = true
return nil return nil
}) })
return changed, err if err != nil {
return changed, err
}
// 订阅开通放在这里、而不是各调用方:回调与掉单补偿两条路都经过 MarkOrderPaid
// 放在这一处才没人能漏掉。ActivateSubscription 按 orderID 幂等,重复调用无害。
if paid != nil && paid.Kind == OrderKindSub && paid.PlanID != "" {
if _, aerr := p.ActivateSubscription(ctx, paid.TenantID, paid.PlanID, paid.ID); aerr != nil {
// 钱已收、订单已 paid:这里失败**不能**回滚订单(否则用户付了钱订单还回到 pending,
// 补偿定时器会再入账一次)。出声即可,可人工或下次回调补开通。
log.Printf("[payment] ⚠️ 订单 %s 已入账但订阅开通失败: %v(需人工补开通)", paid.ID, aerr)
}
}
return changed, nil
} }
// RefundOrder 人工退款(PAYMENT_DESIGN §5admin 发起 → 订单置 refunded + 记 adjust 负分录 + 回退余额)。 // RefundOrder 人工退款(PAYMENT_DESIGN §5admin 发起 → 订单置 refunded + 记 adjust 负分录 + 回退余额)。
@@ -289,7 +315,7 @@ func (p *Postgres) RefundOrder(ctx context.Context, orderID, operatorUserID, mem
return err return err
} }
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID). if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro - ?", o.CreditsMicro)).Error; err != nil { UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) - ?", o.CreditsMicro)).Error; err != nil {
return err return err
} }
changed = true changed = true
+11 -1
View File
@@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres {
migrateLegacyIntIDs(db) migrateLegacyIntIDs(db)
migrateDocLinkToID(db) migrateDocLinkToID(db)
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{}); err != nil { 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) log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
return &Postgres{} return &Postgres{}
} }
@@ -80,6 +80,16 @@ func OpenPostgres(dsn string) *Postgres {
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 { 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) 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)
}
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}
@@ -0,0 +1,299 @@
package store
import (
"context"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
)
// 订阅(手动购买制)。刻意**不做自动续费**:微信 Native 扫码支付没有代扣能力,
// 真自动续费要走「委托代扣」——另一套产品与资质。这里的语义是:
//
// 用户扫码买一个订阅周期 → 有效期内每 N 天发一次积分 → 到期即失效,要续得再买一次。
//
// N(间隔天数)与每次发多少积分都在套餐里配,后台可改。
//
// **发放语义是「累加」而非「重置」**:每次刷新写一条 grant 分录、余额累加,
// 用不完的会留着,也绝不会清掉用户自己充值的积分。重置型(月度配额清零)会让
// 「余额 = SUM(ledger)」这条对账不变量变复杂,且有误删用户已付费积分的风险,
// 故不采用。
// SubscriptionPlan 订阅套餐(价格 / 时长 / 发放节奏,全部后台可配)。
type SubscriptionPlan struct {
BaseModel
Name string `gorm:"size:64" json:"name"`
PriceFen int64 `gorm:"column:price_fen" json:"price_fen"` // 售价(分)
DurationDays int `gorm:"column:duration_days" json:"duration_days"` // 一个订阅周期多少天
RefillCreditsMicro int64 `gorm:"column:refill_credits_micro" json:"refill_credits_micro"` // 每次发放的积分 ×10⁻⁶
RefillIntervalDays int `gorm:"column:refill_interval_days" json:"refill_interval_days"` // 每几天发一次
Active bool `json:"active"`
Sort int `json:"sort"`
}
func (SubscriptionPlan) TableName() string { return "sundynix_sub_plan" }
// Subscription 一次已购订阅。到期即 expired,不自动续。
type Subscription struct {
BaseModel
TenantID string `gorm:"size:64;index" json:"tenant_id"`
PlanID string `gorm:"size:24;index" json:"plan_id"`
OrderID string `gorm:"size:24" json:"order_id"` // 来源支付订单(人工发放为空)
Status string `gorm:"size:16;index" json:"status"`
StartedAt time.Time `json:"started_at"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
RefillSeq int `gorm:"column:refill_seq" json:"refill_seq"` // 已发放次数;兼作幂等序号
LastRefillAt *time.Time `json:"last_refill_at"`
}
func (Subscription) TableName() string { return "sundynix_subscription" }
func (Subscription) isTenantScoped() {} // 用户面只看得到自己租户的订阅;系统级扫描须 WithoutTenant
const (
SubActive = "active"
SubExpired = "expired"
)
// refillRef 是一次发放的幂等键,落到 credit_ledger.ref。
// credit_ledger 上 (kind='grant', ref) 的唯一索引是最终闸门:定时器重跑、多实例并发、
// 手动补发,撞到同一序号都只会成功一次。
func refillRef(subID string, seq int) string { return fmt.Sprintf("sub:%s:%d", subID, seq) }
// isDupKey 判断是否唯一索引冲突(= 这一笔已经发过了,幂等成功而非失败)。
func isDupKey(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
return strings.Contains(s, "duplicate key") || strings.Contains(s, "unique constraint") ||
strings.Contains(s, "unique violation") || strings.Contains(s, "constraint failed")
}
// ---- 套餐配置 ----
func (p *Postgres) ListSubPlans(ctx context.Context, onlyActive bool) []SubscriptionPlan {
if p.db == nil {
return nil
}
q := p.db.WithContext(WithoutTenant(ctx)).Order("sort asc, price_fen asc")
if onlyActive {
q = q.Where("active = ?", true)
}
var out []SubscriptionPlan
q.Find(&out)
return out
}
func (p *Postgres) GetSubPlan(ctx context.Context, id string) *SubscriptionPlan {
if p.db == nil || id == "" {
return nil
}
var pl SubscriptionPlan
if err := p.db.WithContext(WithoutTenant(ctx)).First(&pl, "id = ?", id).Error; err != nil {
return nil
}
return &pl
}
// SaveSubPlan 新增或更新套餐(id 空=新增)。
func (p *Postgres) SaveSubPlan(ctx context.Context, pl *SubscriptionPlan) error {
if p.db == nil {
return errStoreDisabled
}
if pl.DurationDays <= 0 {
return errors.New("订阅时长必须大于 0 天")
}
if pl.RefillIntervalDays <= 0 {
return errors.New("发放间隔必须大于 0 天")
}
// 间隔比时长还长 = 一个周期内一次都发不到第二回,多半是配错了,直接拦下。
if pl.RefillIntervalDays > pl.DurationDays {
return errors.New("发放间隔不能大于订阅时长")
}
return p.db.WithContext(WithoutTenant(ctx)).Save(pl).Error
}
// ---- 订阅生命周期 ----
// ActivateSubscription 支付成功后开通/续期,并立即发放第一笔积分。
// 已有生效中的订阅则**顺延**到期时间(而不是新建一条),避免同租户多条 active 互相打架。
// 幂等:同一 orderID 只会开通一次。
func (p *Postgres) ActivateSubscription(ctx context.Context, tenantID, planID, orderID string) (*Subscription, error) {
if p.db == nil {
return nil, errStoreDisabled
}
pl := p.GetSubPlan(ctx, planID)
if pl == nil {
return nil, errors.New("订阅套餐不存在")
}
ctx = WithoutTenant(ctx) // 系统级:为目标租户开通,调用方可能是 admin
now := time.Now()
var sub *Subscription
err := p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 幂等闸:同一订单已开通过就直接返回,不重复延期
if orderID != "" {
var exist Subscription
if err := tx.First(&exist, "order_id = ?", orderID).Error; err == nil {
sub = &exist
return nil
}
}
var cur Subscription
err := tx.Where("tenant_id = ? AND status = ?", tenantID, SubActive).
Order("expires_at desc").First(&cur).Error
switch {
case err == nil: // 续期:在原到期时间上顺延
cur.ExpiresAt = cur.ExpiresAt.AddDate(0, 0, pl.DurationDays)
cur.PlanID, cur.OrderID = pl.ID, orderID
if err := tx.Save(&cur).Error; err != nil {
return err
}
sub = &cur
return nil
case errors.Is(err, gorm.ErrRecordNotFound):
s := &Subscription{
TenantID: tenantID, PlanID: pl.ID, OrderID: orderID, Status: SubActive,
StartedAt: now, ExpiresAt: now.AddDate(0, 0, pl.DurationDays),
}
if err := tx.Create(s).Error; err != nil {
return err
}
sub = s
return nil
default:
return err
}
})
if err != nil {
return nil, err
}
// 首笔发放走与定时器**同一套排期判断**(而不是无条件发一笔):否则同一订单重复
// 开通时(回调重推、查单与回调赛跑)会各发一笔,序号递增绕过幂等索引 —— 白送钱。
// 排期判断天然幂等:seq 已发过则下一笔的到期时间在未来,不会发。
// 放在事务外:发放失败不该导致"已付款却没开通",定时器下一轮会补上。
if _, _, err := p.TickSubscription(ctx, sub, now); err != nil {
return sub, nil // 开通已成功,发放失败交给定时器补
}
return sub, nil
}
// refillOnce 发放一次积分并推进序号。返回 granted=false 表示这一笔已发过(幂等)。
func (p *Postgres) refillOnce(ctx context.Context, sub *Subscription, pl *SubscriptionPlan, now time.Time) (bool, error) {
if pl.RefillCreditsMicro <= 0 {
return false, nil
}
seq := sub.RefillSeq + 1
err := p.GrantCredits(ctx, sub.TenantID, LedgerGrant, pl.RefillCreditsMicro,
refillRef(sub.ID, seq), "订阅发放 "+pl.Name)
if err != nil {
if isDupKey(err) {
return false, nil // 已发过:幂等成功
}
return false, err
}
sub.RefillSeq = seq
sub.LastRefillAt = &now
return true, p.db.WithContext(WithoutTenant(ctx)).Model(&Subscription{}).
Where("id = ?", sub.ID).
Updates(map[string]any{"refill_seq": seq, "last_refill_at": now}).Error
}
// DueSubscriptions 取到期需处理的订阅(系统级,跨租户)。
func (p *Postgres) DueSubscriptions(ctx context.Context, limit int) []Subscription {
if p.db == nil {
return nil
}
if limit <= 0 || limit > 500 {
limit = 200
}
var out []Subscription
p.db.WithContext(WithoutTenant(ctx)).
Where("status = ?", SubActive).Order("expires_at asc").Limit(limit).Find(&out)
return out
}
// TickSubscription 推进一条订阅:先看是否到期,再看是否该发放。
// 返回 (发放笔数, 是否刚过期)。定时器与手动触发共用这一份逻辑,避免两处行为漂移。
func (p *Postgres) TickSubscription(ctx context.Context, sub *Subscription, now time.Time) (int, bool, error) {
pl := p.GetSubPlan(ctx, sub.PlanID)
if pl == nil {
return 0, false, errors.New("订阅套餐已不存在: " + sub.PlanID)
}
granted := 0
// 补发:进程停机/定时器漏跑期间欠下的次数要一次性补齐,而不是只发最近一次。
// 上限用到期时间卡住——过期之后的周期一律不发。
for {
due := sub.StartedAt.AddDate(0, 0, pl.RefillIntervalDays*(sub.RefillSeq))
if due.After(now) || !due.Before(sub.ExpiresAt) {
break
}
ok, err := p.refillOnce(ctx, sub, pl, now)
if err != nil {
return granted, false, err
}
if ok {
granted++
}
if sub.RefillSeq > 1000 { // 防呆:配置异常(间隔 0)时不至于死循环
break
}
}
if now.After(sub.ExpiresAt) {
if err := p.db.WithContext(WithoutTenant(ctx)).Model(&Subscription{}).
Where("id = ? AND status = ?", sub.ID, SubActive).
Update("status", SubExpired).Error; err != nil {
return granted, false, err
}
return granted, true, nil
}
return granted, false, nil
}
// ActiveSubscription 取某租户当前生效的订阅(用户面:账单页展示到期时间)。
func (p *Postgres) ActiveSubscription(ctx context.Context, tenantID string) *Subscription {
if p.db == nil || tenantID == "" {
return nil
}
var s Subscription
if err := p.db.WithContext(WithoutTenant(ctx)).
Where("tenant_id = ? AND status = ?", tenantID, SubActive).
Order("expires_at desc").First(&s).Error; err != nil {
return nil
}
return &s
}
// AdminSubRow 是管理端订阅观测一行:订阅 + 租户名 + 套餐名。
type AdminSubRow struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
PlanName string `json:"plan_name"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
ExpiresAt time.Time `json:"expires_at"`
RefillSeq int `json:"refill_seq"`
}
// AllSubscriptions 全平台订阅(管理端观测,跨租户)。
func (p *Postgres) AllSubscriptions(ctx context.Context, limit int) []AdminSubRow {
if p.db == nil {
return nil
}
if limit <= 0 || limit > 500 {
limit = 200
}
var out []AdminSubRow
p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_subscription as s").
Select("s.id, s.tenant_id, s.status, s.started_at, s.expires_at, s.refill_seq, " +
"coalesce(t.name,'') as tenant_name, coalesce(pl.name,'') as plan_name").
Joins("left join sundynix_tenant t on t.id = s.tenant_id").
Joins("left join sundynix_sub_plan pl on pl.id = s.plan_id").
Where("s.deleted_at is null").
Order("s.expires_at desc").Limit(limit).Scan(&out)
return out
}
@@ -0,0 +1,270 @@
package store
import (
"context"
"testing"
"time"
)
// 订阅是涉及钱的路径:发多了是白送,发少了是欠付费用户的。这组测试钉死三件事——
// 幂等(重跑不重复发)、补发(漏跑要补齐)、到期边界(过期后一分不发)。
func seedPlan(t *testing.T, p *Postgres, durationDays, intervalDays int, credits int64) *SubscriptionPlan {
t.Helper()
pl := &SubscriptionPlan{
Name: "测试套餐", PriceFen: 9900, DurationDays: durationDays,
RefillCreditsMicro: credits, RefillIntervalDays: intervalDays, Active: true,
}
if err := p.SaveSubPlan(context.Background(), pl); err != nil {
t.Fatalf("建套餐失败: %v", err)
}
return pl
}
func balance(t *testing.T, p *Postgres, tenantID string) int64 {
t.Helper()
return p.TenantBalance(WithoutTenant(context.Background()), tenantID)
}
func TestSubscription_ActivateGrantsFirstRefill(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
pl := seedPlan(t, p, 30, 7, 100_000_000)
sub, err := p.ActivateSubscription(context.Background(), "t1", pl.ID, "order-1")
if err != nil {
t.Fatalf("开通失败: %v", err)
}
if sub.Status != SubActive {
t.Fatalf("应为 active,得 %q", sub.Status)
}
if got := balance(t, p, "t1"); got != 100_000_000 {
t.Fatalf("开通即应发第一笔,余额应 100e6,得 %d", got)
}
assertBalanceInvariant(t, p, "t1")
}
// 同一订单重复开通(回调重推 / 查单与回调赛跑)不能重复延期、不能重复发放。
func TestSubscription_ActivateIsIdempotentPerOrder(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
pl := seedPlan(t, p, 30, 7, 100_000_000)
ctx := context.Background()
s1, err := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
if err != nil {
t.Fatal(err)
}
s2, err := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
if err != nil {
t.Fatal(err)
}
if !s1.ExpiresAt.Equal(s2.ExpiresAt) {
t.Fatalf("同一订单重复开通不该延期:%v → %v", s1.ExpiresAt, s2.ExpiresAt)
}
if got := balance(t, p, "t1"); got != 100_000_000 {
t.Fatalf("重复开通不该重复发放,余额应仍为 100e6,得 %d", got)
}
assertBalanceInvariant(t, p, "t1")
}
// 续订(不同订单)应在原到期时间上顺延,而不是新建第二条 active。
func TestSubscription_RenewExtendsInsteadOfDuplicating(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
pl := seedPlan(t, p, 30, 7, 100_000_000)
ctx := context.Background()
s1, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
s2, err := p.ActivateSubscription(ctx, "t1", pl.ID, "order-2")
if err != nil {
t.Fatal(err)
}
if s1.ID != s2.ID {
t.Fatalf("续订应复用同一条订阅,得两条:%s / %s", s1.ID, s2.ID)
}
want := s1.ExpiresAt.AddDate(0, 0, 30)
if !s2.ExpiresAt.Equal(want) {
t.Fatalf("续订应顺延 30 天:want %v got %v", want, s2.ExpiresAt)
}
var n int64
p.db.WithContext(WithoutTenant(ctx)).Model(&Subscription{}).
Where("tenant_id = ? AND status = ?", "t1", SubActive).Count(&n)
if n != 1 {
t.Fatalf("同租户不应出现多条 active 订阅,得 %d 条", n)
}
}
// 定时器漏跑(进程停机数周)后要把欠下的次数一次补齐,而不是只补最近一次。
func TestSubscription_TickBackfillsMissedRefills(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
pl := seedPlan(t, p, 30, 7, 100_000_000) // 30 天订阅,每 7 天发一次
ctx := context.Background()
sub, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1") // 已发第 1 笔
// 快进 22 天:第 7/14/21 天各应发一次,共补 3 笔
now := sub.StartedAt.AddDate(0, 0, 22)
granted, expired, err := p.TickSubscription(ctx, sub, now)
if err != nil {
t.Fatal(err)
}
if expired {
t.Fatal("22 天时不该过期(周期 30 天)")
}
if granted != 3 {
t.Fatalf("应补发 3 笔(第 7/14/21 天),得 %d", granted)
}
if got := balance(t, p, "t1"); got != 400_000_000 {
t.Fatalf("首笔 + 补 3 笔 = 400e6,得 %d", got)
}
assertBalanceInvariant(t, p, "t1")
}
// 同一时刻重复 tick(多实例并发 / 定时器重叠)不能重复发放。
func TestSubscription_TickIsIdempotent(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
pl := seedPlan(t, p, 30, 7, 100_000_000)
ctx := context.Background()
sub, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
now := sub.StartedAt.AddDate(0, 0, 8)
if _, _, err := p.TickSubscription(ctx, sub, now); err != nil {
t.Fatal(err)
}
before := balance(t, p, "t1")
// 再 tick 两次,余额不能变
for i := 0; i < 2; i++ {
if _, _, err := p.TickSubscription(ctx, sub, now); err != nil {
t.Fatal(err)
}
}
if after := balance(t, p, "t1"); after != before {
t.Fatalf("重复 tick 不该重复发放:%d → %d", before, after)
}
assertBalanceInvariant(t, p, "t1")
}
// 过期后一分不发,且状态置 expired(到期即失效,无自动续费)。
func TestSubscription_ExpiresAndStopsGranting(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
pl := seedPlan(t, p, 14, 7, 100_000_000) // 14 天,7 天一发 → 最多发第 1、第 7 天两笔
ctx := context.Background()
sub, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
now := sub.StartedAt.AddDate(0, 0, 100) // 远超到期
granted, expired, err := p.TickSubscription(ctx, sub, now)
if err != nil {
t.Fatal(err)
}
if !expired {
t.Fatal("早该过期了")
}
// 到期时间点(第 14 天)之后的周期不发:第 7 天那笔算,第 14 天正好等于到期不算
if granted != 1 {
t.Fatalf("过期前只应补第 7 天那一笔,得 %d 笔", granted)
}
if got := balance(t, p, "t1"); got != 200_000_000 {
t.Fatalf("首笔 + 第 7 天 = 200e6,得 %d", got)
}
var s Subscription
p.db.WithContext(WithoutTenant(ctx)).First(&s, "id = ?", sub.ID)
if s.Status != SubExpired {
t.Fatalf("状态应为 expired,得 %q", s.Status)
}
assertBalanceInvariant(t, p, "t1")
}
// 配置校验:间隔比时长长 = 一个周期只发得到首笔,多半是配错了,直接拦。
func TestSubPlan_RejectsBadConfig(t *testing.T) {
p := newTestStore(t)
ctx := context.Background()
for _, tc := range []struct {
name string
duration, ivl int
wantErrSubstring string
}{
{"时长为 0", 0, 7, "订阅时长"},
{"间隔为 0", 30, 0, "发放间隔"},
{"间隔大于时长", 7, 30, "不能大于"},
} {
t.Run(tc.name, func(t *testing.T) {
err := p.SaveSubPlan(ctx, &SubscriptionPlan{
Name: "x", DurationDays: tc.duration, RefillIntervalDays: tc.ivl, RefillCreditsMicro: 1,
})
if err == nil {
t.Fatal("应被拒绝")
}
})
}
}
var _ = time.Now
// 订阅单经支付回调入账后必须真的开通订阅。这是"钱收了但订阅没生效"的高危点,
// 而且开通逻辑刻意放在 MarkOrderPaid 里(回调与掉单补偿两条路共用),这里一并钉死。
func TestSubscription_ActivatedByOrderPayment(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
pl := seedPlan(t, p, 30, 7, 100_000_000)
ctx := context.Background()
o := &PaymentOrder{
TenantID: "t1", UserID: "u1", Kind: OrderKindSub, PlanID: pl.ID,
AmountFen: pl.PriceFen, CreditsMicro: 0, // 订阅单自身不带积分
Channel: ChannelWechat, Status: OrderPending,
}
if err := p.CreateOrder(ctx, o); err != nil {
t.Fatal(err)
}
changed, err := p.MarkOrderPaid(ctx, o.ID, "txn-1")
if err != nil || !changed {
t.Fatalf("入账应成功: changed=%v err=%v", changed, err)
}
sub := p.ActiveSubscription(ctx, "t1")
if sub == nil {
t.Fatal("付款后应已开通订阅")
}
if sub.OrderID != o.ID {
t.Fatalf("订阅应关联来源订单 %s,得 %s", o.ID, sub.OrderID)
}
if got := balance(t, p, "t1"); got != 100_000_000 {
t.Fatalf("开通即发首笔,余额应 100e6,得 %d", got)
}
assertBalanceInvariant(t, p, "t1")
// 回调重复推送:不能重复开通、不能重复发放
if _, err := p.MarkOrderPaid(ctx, o.ID, "txn-1"); err != nil {
t.Fatal(err)
}
if got := balance(t, p, "t1"); got != 100_000_000 {
t.Fatalf("重复回调不该重复发放,得 %d", got)
}
}
// 余额列为 NULL 时入账必须仍然生效。
// 真实事故:credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL,
// 而入账语句是「余额 + N」——SQL 里 NULL + N = NULL,于是这些租户**充值永远不到账**,
// 分录照写、余额不动、且不报错。本地库 42 个租户里有 11 个处于此状态。
func TestGrantCredits_SurvivesNullBalance(t *testing.T) {
p := newTestStore(t)
ctx := context.Background()
seedTenant(t, p, "t-null")
// 造出"历史租户":把余额置回 NULL
if err := p.db.WithContext(WithoutTenant(ctx)).Exec(
"UPDATE sundynix_tenant SET credit_balance_micro = NULL WHERE id = ?", "t-null").Error; err != nil {
t.Fatal(err)
}
if err := p.GrantCredits(ctx, "t-null", LedgerGrant, 50_000_000, "ref-null", "充值"); err != nil {
t.Fatalf("入账失败: %v", err)
}
if got := balance(t, p, "t-null"); got != 50_000_000 {
t.Fatalf("NULL 余额的租户充值后应为 50e6,得 %d —— coalesce 丢了?", got)
}
assertBalanceInvariant(t, p, "t-null")
}
@@ -0,0 +1,105 @@
package store
import (
"context"
"testing"
)
// 管理端任务下钻必须跨租户读。Task/Eval 都在租户插件作用域内,若用请求 ctx 直接查,
// 管理员看别的租户的任务不会报错,而是**静默返回空**——UI 上表现为"这任务没产出",
// 比报错难查得多。这组测试把"跨租户可读"钉死。
func seedTask(t *testing.T, p *Postgres, tenantID, taskID string) {
t.Helper()
task := &Task{
BaseModel: BaseModel{ID: taskID},
TenantID: tenantID, Owner: "u-" + tenantID, TaskID: taskID,
Status: "done", Output: "最终输出内容",
Trace: `[{"node":"n1","type":"llm","msg":"跑了一步"}]`,
Graph: `{"topic":"测试主题"}`,
}
// 显式 WithoutTenant 建数据:插件会按 ctx 覆写 tenant_id,不绕开就种不进指定租户。
if err := p.db.WithContext(WithoutTenant(context.Background())).Create(task).Error; err != nil {
t.Fatalf("建任务失败: %v", err)
}
}
// 前提验证:先证明租户过滤在测试环境里真的生效(用租户作用域的读去查别人的任务应查不到)。
// 否则下面"跨租户能读到"的断言可能只是因为插件压根没装,属于假过。
func TestTaskDetail_TenantScopeIsActuallyOn(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
seedTask(t, p, "t1", "task_a")
var got Task
err := p.db.WithContext(WithTenant(context.Background(), "t2")).
Where("task_id = ?", "task_a").First(&got).Error
if err == nil {
t.Fatal("租户过滤没生效:t2 的作用域竟能读到 t1 的任务,本测试文件的其余断言都不可信")
}
}
func TestTaskDetail_ReadsAcrossTenants(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
seedTask(t, p, "t1", "task_a")
// 以「另一个租户」的 ctx 调用——模拟管理员本人属于 t2、却要看 t1 的任务。
d := p.TaskDetail(WithTenant(context.Background(), "t2"), "task_a")
if d == nil {
t.Fatal("跨租户下钻取不到任务(TaskDetail 少了 WithoutTenant?)")
}
if d.Output != "最终输出内容" {
t.Fatalf("输出应完整读出,得 %q", d.Output)
}
if d.Trace == "" {
t.Fatal("轨迹不该为空")
}
if d.Topic != "测试主题" {
t.Fatalf("topic 应从 graph 里取出,得 %q", d.Topic)
}
}
func TestTaskDetail_MissingReturnsNil(t *testing.T) {
p := newTestStore(t)
if d := p.TaskDetail(context.Background(), "task_不存在"); d != nil {
t.Fatalf("不存在的任务应返回 nil,得 %+v", d)
}
}
func TestTaskDetail_EvalAttachedWhenPresent(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
seedTask(t, p, "t1", "task_a")
ctx := WithoutTenant(context.Background())
if err := p.db.WithContext(ctx).Create(&Eval{
BaseModel: BaseModel{ID: "ev1"}, TenantID: "t1", TaskID: "task_a",
Overall: 0.82, Level: "ok", Reason: "还行", Sources: 3,
}).Error; err != nil {
t.Fatalf("建评测失败: %v", err)
}
d := p.TaskDetail(WithTenant(context.Background(), "t2"), "task_a")
if d == nil || d.Eval == nil {
t.Fatal("有评测时应带出评测明细")
}
if d.Eval.Level != "ok" || d.Eval.Sources != 3 {
t.Fatalf("评测字段没读对: %+v", d.Eval)
}
}
// 没有评测的任务,eval 应为 nil 而不是零值——否则前端会把 0 分当成"评了 0 分"。
func TestTaskDetail_NoEvalIsNil(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
seedTask(t, p, "t1", "task_a")
d := p.TaskDetail(context.Background(), "task_a")
if d == nil {
t.Fatal("应能取到任务")
}
if d.Eval != nil {
t.Fatalf("无评测时 eval 应为 nil,得 %+v", d.Eval)
}
}
@@ -73,3 +73,60 @@ func (p *Postgres) TaskStatusCounts(ctx context.Context) map[string]int64 {
} }
return out return out
} }
// AdminTaskDetail 是管理端任务下钻:基本信息 + 持久化的输出/轨迹 + 评测。
// 轨迹与输出取自 sundynix_task 的收尾落库列(Redis 流只有 10min TTL,历史任务只能靠它们)。
type AdminTaskDetail struct {
AdminTaskRow
Graph string `json:"graph"` // 提交时的 DSL 原文
Output string `json:"output"` // 最终模型输出
Trace string `json:"trace"` // 执行轨迹事件 JSON 数组(原文透传,前端解析)
// gorm:"-":这是查完再单独填的组合字段,不是关联;不标记的话 Scan 会当成关系报错。
Eval *AdminEval `gorm:"-" json:"eval"` // 无评测时为 null
}
// AdminEval 是下钻里的评测明细(比列表页的 level/overall 多出评语与命中项)。
type AdminEval struct {
Overall float64 `json:"overall"`
Rule float64 `json:"rule"`
LLM float64 `json:"llm"`
Faithful float64 `json:"faithful"`
Level string `json:"level"`
Flags string `json:"flags"`
Reason string `json:"reason"`
Sources int `json:"sources"`
Corrected bool `json:"corrected"`
}
// TaskDetail 按 task_id 取单条任务的完整下钻数据(管理端,跨租户)。
// 必须 WithoutTenantTask/Eval 都在租户插件作用域内,用请求 ctx 查别的租户的任务
// 不会报错,而是静默返回空——看起来像"这任务没产出",比报错更难排查。
func (p *Postgres) TaskDetail(ctx context.Context, taskID string) *AdminTaskDetail {
if p.db == nil || taskID == "" {
return nil
}
ctx = WithoutTenant(ctx)
var d AdminTaskDetail
err := p.db.WithContext(ctx).Table("sundynix_task as t").
Select("t.task_id, t.tenant_id, t.owner, t.status, t.detail, t.created_at as at, "+
"t.output, t.trace, coalesce(cast(t.graph as text),'') as graph, "+
"coalesce(tn.name,'') as tenant_name, coalesce(u.email,'') as owner_email, "+
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, "+
"coalesce(t.graph->>'topic','') as topic").
Joins("left join sundynix_tenant tn on tn.id = t.tenant_id").
Joins("left join sundynix_user u on u.id = t.owner").
Joins("left join sundynix_eval e on e.task_id = t.task_id").
Where("t.task_id = ? and t.deleted_at is null", taskID).
Scan(&d).Error
if err != nil || d.TaskID == "" {
return nil
}
var e Eval
if p.db.WithContext(ctx).Where("task_id = ?", taskID).First(&e).Error == nil {
d.Eval = &AdminEval{
Overall: e.Overall, Rule: e.Rule, LLM: e.LLM, Faithful: e.Faithful,
Level: e.Level, Flags: e.Flags, Reason: e.Reason, Sources: e.Sources, Corrected: e.Corrected,
}
}
return &d
}
@@ -7,6 +7,7 @@ import (
"github.com/glebarez/sqlite" // 纯 Go sqlite(无 CGO):DB 背书的单测在 CI ubuntu 无 Postgres 服务时也能跑 "github.com/glebarez/sqlite" // 纯 Go sqlite(无 CGO):DB 背书的单测在 CI ubuntu 无 Postgres 服务时也能跑
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
"gorm.io/gorm/schema"
) )
// newTestStore 起一个内存 sqlite,迁移同款模型 + 建那道支付幂等兜底的部分唯一索引 + // newTestStore 起一个内存 sqlite,迁移同款模型 + 建那道支付幂等兜底的部分唯一索引 +
@@ -15,7 +16,12 @@ import (
func newTestStore(t *testing.T) *Postgres { func newTestStore(t *testing.T) *Postgres {
t.Helper() t.Helper()
// 静音 gorm 日志:计费路径故意查 pricing/setting 取不到时回退默认,属预期空查询,别刷屏。 // 静音 gorm 日志:计费路径故意查 pricing/setting 取不到时回退默认,属预期空查询,别刷屏。
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
// 必须与 OpenPostgres 用同一套命名策略:多数模型有显式 TableName() 碰巧对得上,
// 但 Task 这类没有的会退化成 "tasks",于是写裸 SQL 的查询在测试里查无此表。
NamingStrategy: schema.NamingStrategy{TablePrefix: "sundynix_", SingularTable: true},
})
if err != nil { if err != nil {
t.Fatalf("打开内存 sqlite 失败: %v", err) t.Fatalf("打开内存 sqlite 失败: %v", err)
} }
@@ -28,6 +34,7 @@ func newTestStore(t *testing.T) *Postgres {
if err := db.AutoMigrate( if err := db.AutoMigrate(
&User{}, &Tenant{}, &TenantMember{}, &CreditLedger{}, &PaymentOrder{}, &User{}, &Tenant{}, &TenantMember{}, &CreditLedger{}, &PaymentOrder{},
&RedeemCode{}, &CreditPack{}, &UsageEvent{}, &UsageRollup{}, &Setting{}, &Pricing{}, &LLMModel{}, &RedeemCode{}, &CreditPack{}, &UsageEvent{}, &UsageRollup{}, &Setting{}, &Pricing{}, &LLMModel{},
&AuditLog{}, &Task{}, &Eval{}, &SubscriptionPlan{}, &Subscription{},
&KB{}, // 租户作用域模型,验证隔离插件 &KB{}, // 租户作用域模型,验证隔离插件
); err != nil { ); err != nil {
t.Fatalf("AutoMigrate 失败: %v", err) t.Fatalf("AutoMigrate 失败: %v", err)
+6 -1
View File
@@ -16,7 +16,12 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mcp-go ./cmd/serve
# alpine 运行(内网 runner 连不上 gcr.io;二进制 CGO_ENABLED=0 全静态,ca-certs 供 TLS)。 # alpine 运行(内网 runner 连不上 gcr.io;二进制 CGO_ENABLED=0 全静态,ca-certs 供 TLS)。
FROM alpine:3.20 FROM alpine:3.20
RUN sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories \ RUN sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories \
&& apk add --no-cache ca-certificates tzdata && adduser -D -u 10001 app && apk add --no-cache ca-certificates tzdata && adduser -D -u 10001 app \
# /data 存 bleve 全文索引:必须由 app 拥有(进程非 root),且 compose 要挂持久卷。
# 没有它的话 BLEVE_PATH 默认相对 CWD(=/)uid 10001 在 / 下建不了目录 → 静默退回
# 内存索引,全文路每次重启清零,而混合检索只会少一路召回、不报错。
&& mkdir -p /data && chown app:app /data
USER app USER app
WORKDIR /data
COPY --from=build /out/mcp-go /mcp-go COPY --from=build /out/mcp-go /mcp-go
ENTRYPOINT ["/mcp-go"] ENTRYPOINT ["/mcp-go"]
+16 -3
View File
@@ -347,11 +347,24 @@ func (g *Gateway) kbSearch(ctx context.Context, call *contract.ToolCall) *contra
if v, ok := call.Args["topK"].(float64); ok && v > 0 { if v, ok := call.Args["topK"].(float64); ok && v > 0 {
topK = int(v) topK = int(v)
} }
if !g.rag.Ready() { // 这里**不能**用 g.rag.Ready() 当总闸。Ready() 要求 embedding 已配置 + Milvus 已连,
return &contract.ToolResult{OK: true, Content: "[]"} // 但全文(bleve)与图谱(Neo4j)两路根本不依赖它们;一刀切返回 "[]" 会让"embedding 没配好"
} // 表现为"整个知识库搜不到东西",且没有任何错误信息。改为让 searchPaths 逐路判定,
// 各路自己上报 disabled/error(见 rag.RouteDiag)。
// mode 空=生产混合检索(含 rerank);显式 vector/fulltext/graph/hybrid=评测用单路/纯融合(不 rerank)。 // mode 空=生产混合检索(含 rerank);显式 vector/fulltext/graph/hybrid=评测用单路/纯融合(不 rerank)。
mode, _ := call.Args["mode"].(string) mode, _ := call.Args["mode"].(string)
// diag=true(仅检索试验台会传):返回 {hits, routes} 对象,带每一路的
// ok/empty/disabled/error 诊断。不传时保持裸数组,生产调用方不受影响。
if d, _ := call.Args["diag"].(bool); d {
hits, diags := g.rag.SearchByModeDiag(ctx, kb, q, topK, mode)
if hits == nil {
hits = []rag.Hit{}
}
data, _ := json.Marshal(map[string]any{"hits": hits, "routes": diags})
return &contract.ToolResult{OK: true, Content: string(data)}
}
var hits []rag.Hit var hits []rag.Hit
var err error var err error
if mode == "" { if mode == "" {
@@ -0,0 +1,94 @@
package mcp
import (
"context"
"encoding/json"
"testing"
"github.com/sundynix/sundynix-shared/contract"
"github.com/sundynix/sundynix-mcp-go/internal/rag"
)
// kb_search 的两种返回形状必须泾渭分明:
// - 不带 diag(生产调用:dispatcher、Agent 工具链)→ 裸 JSON 数组,契约不能变;
// - 带 diag(只有检索试验台会传)→ {hits, routes} 对象,带每一路的诊断。
//
// 把它钉住是因为这层是"改可观测性顺手改坏生产"的高风险位置:diag 分支若写成无条件
// 生效,所有调用方拿到的就从数组变成了对象,工具链会静默解析失败。
// newFulltextOnlyGateway 造一个只有全文路可用的 Gateway:无 embedding、无 Milvus、无 Neo4j。
// 这正是 mcp-go 先于 gateway 启动(控制面配置尚未下发)时的真实状态。
func newFulltextOnlyGateway(t *testing.T) *Gateway {
t.Helper()
t.Setenv("BLEVE_PATH", t.TempDir()+"/bleve")
e := rag.Open(context.Background(), rag.Config{}) // 不给 Milvus/Neo4j/embedding
return &Gateway{rag: e}
}
func TestKbSearch_ProductionShapeIsBareArray(t *testing.T) {
g := newFulltextOnlyGateway(t)
res := g.kbSearch(context.Background(), &contract.ToolCall{
Tool: "kb_search",
Args: map[string]any{"kb": "k1", "q": "星间链路"},
})
if !res.OK {
t.Fatalf("不应失败: %s", res.Error)
}
var arr []map[string]any
if err := json.Unmarshal([]byte(res.Content), &arr); err != nil {
t.Fatalf("生产调用必须返回裸数组(工具链据此解析),得 %q: %v", res.Content, err)
}
}
func TestKbSearch_DiagShapeCarriesRoutes(t *testing.T) {
g := newFulltextOnlyGateway(t)
res := g.kbSearch(context.Background(), &contract.ToolCall{
Tool: "kb_search",
Args: map[string]any{"kb": "k1", "q": "星间链路", "diag": true, "mode": "hybrid"},
})
if !res.OK {
t.Fatalf("不应失败: %s", res.Error)
}
var out struct {
Hits []map[string]any `json:"hits"`
Routes []rag.RouteDiag `json:"routes"`
}
if err := json.Unmarshal([]byte(res.Content), &out); err != nil {
t.Fatalf("diag 调用应返回 {hits,routes} 对象,得 %q: %v", res.Content, err)
}
if len(out.Routes) != 3 {
t.Fatalf("应有 vector/fulltext/graph 三路诊断,得 %d 条: %+v", len(out.Routes), out.Routes)
}
for _, r := range out.Routes {
if r.Status == "" {
t.Fatalf("每一路都必须有 status,否则界面仍无法解释这个空: %+v", r)
}
// 没配置的路必须给出原因——这正是当初排查半天的痛点。
if r.Status == "disabled" && r.Note == "" {
t.Fatalf("%s 路 disabled 却没写原因", r.Name)
}
}
}
// 未配置 embedding/Milvus 时不能整体短路返回空:以前用 rag.Ready() 当总闸,
// 连不依赖它们的全文/图谱路一起毙掉,表现为"知识库什么都搜不到"且不报错。
func TestKbSearch_NotShortCircuitedByReadiness(t *testing.T) {
g := newFulltextOnlyGateway(t)
if g.rag.Ready() {
t.Fatal("前提不成立:该引擎本应 not ready")
}
res := g.kbSearch(context.Background(), &contract.ToolCall{
Tool: "kb_search",
Args: map[string]any{"kb": "k1", "q": "星间链路", "diag": true},
})
var out struct {
Routes []rag.RouteDiag `json:"routes"`
}
if err := json.Unmarshal([]byte(res.Content), &out); err != nil {
t.Fatalf("not ready 时也应正常走到各路诊断,得 %q", res.Content)
}
if len(out.Routes) == 0 {
t.Fatal("not ready 时被总闸短路了(返回空数组而非逐路诊断)")
}
}
+9 -5
View File
@@ -19,6 +19,9 @@ import (
// 落盘不可用时退回内存索引(功能在、重启会丢),好过全文路全哑。 // 落盘不可用时退回内存索引(功能在、重启会丢),好过全文路全哑。
type bleveStore struct { type bleveStore struct {
idx bleve.Index idx bleve.Index
// persistent=false 表示退了内存兜底:功能在但重启即清零。这是**静默降级**——
// 混合检索只会少一路召回、不报错,所以必须上报出去(health → 控制台),否则没人发现。
persistent bool
} }
// bleveMappingtext 字段用 cjk 分词器(bigram,能切中文,否则默认标准分词器把整段中文当一个 // bleveMappingtext 字段用 cjk 分词器(bigram,能切中文,否则默认标准分词器把整段中文当一个
@@ -62,7 +65,7 @@ func openBleve() *bleveStore {
return memBleve() return memBleve()
} }
log.Printf("[rag] bleve 全文索引落盘就绪: %s", path) log.Printf("[rag] bleve 全文索引落盘就绪: %s", path)
return &bleveStore{idx: idx} return &bleveStore{idx: idx, persistent: true}
} }
// memBleve 内存兜底:落盘不可用时退回内存索引(功能在、重启会丢),好过全文路全哑。 // memBleve 内存兜底:落盘不可用时退回内存索引(功能在、重启会丢),好过全文路全哑。
@@ -123,9 +126,10 @@ func (b *bleveStore) deleteDoc(kb, doc string) error {
} }
// search 全文检索(可按 kb 过滤),返回 BM25 排序的命中。 // search 全文检索(可按 kb 过滤),返回 BM25 排序的命中。
func (b *bleveStore) search(kb, q string, topK int) []Hit { // 错误如实返回:以前吞掉错误只回 nil,全文路挂了看起来就只是"没召回"。
func (b *bleveStore) search(kb, q string, topK int) ([]Hit, error) {
if !b.ready() || q == "" { if !b.ready() || q == "" {
return nil return nil, nil
} }
mq := bleve.NewMatchQuery(q) mq := bleve.NewMatchQuery(q)
mq.SetField("text") mq.SetField("text")
@@ -140,7 +144,7 @@ func (b *bleveStore) search(kb, q string, topK int) []Hit {
req.Fields = []string{"text"} req.Fields = []string{"text"}
res, err := b.idx.Search(req) res, err := b.idx.Search(req)
if err != nil { if err != nil {
return nil return nil, err
} }
var hits []Hit var hits []Hit
for _, h := range res.Hits { for _, h := range res.Hits {
@@ -149,7 +153,7 @@ func (b *bleveStore) search(kb, q string, topK int) []Hit {
hits = append(hits, Hit{Text: text, Score: float32(h.Score)}) hits = append(hits, Hit{Text: text, Score: float32(h.Score)})
} }
} }
return hits return hits, nil
} }
func fnvHash(s string) uint64 { func fnvHash(s string) uint64 {
+5 -2
View File
@@ -18,7 +18,10 @@ func TestBleve_ChineseSearch(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
hits := b.search(kb, "星云一号的总设计师是谁", 5) hits, err := b.search(kb, "星云一号的总设计师是谁", 5)
if err != nil {
t.Fatalf("全文检索报错: %v", err)
}
if len(hits) == 0 { if len(hits) == 0 {
t.Fatal("中文全文检索应命中(CJK 分词),got 0 —— 分词器回归了?") t.Fatal("中文全文检索应命中(CJK 分词),got 0 —— 分词器回归了?")
} }
@@ -56,7 +59,7 @@ func TestBleve_PersistsAcrossReopen(t *testing.T) {
b2 := openBleve() // 模拟重启:同路径重开 b2 := openBleve() // 模拟重启:同路径重开
defer b2.close() defer b2.close()
if hits := b2.search("kp", "龙渊号探测器", 5); len(hits) == 0 { if hits, err := b2.search("kp", "龙渊号探测器", 5); err != nil || len(hits) == 0 {
t.Fatal("重开后应仍能检索到(落盘持久),got 0 —— 退回内存了?") t.Fatal("重开后应仍能检索到(落盘持久),got 0 —— 退回内存了?")
} }
} }
@@ -0,0 +1,108 @@
package rag
import (
"context"
"testing"
)
// 这组测试钉死「一路挂了不能拖垮全部」以及「三种空必须分得清」。
//
// 背景:embedding 未配置时,kb_search 与 Search() 都用 Ready() 当总闸直接返回空——
// 而全文(bleve)、图谱(Neo4j)两路根本不依赖 embedding/Milvus。结果是"模型配置没下发"
// 表现为"整个知识库什么都搜不到",且无任何错误信息,排查时毫无线索。
// newFulltextOnlyEngine 造一个只有全文路可用的引擎:无 embedding、无 Milvus、无 Neo4j
// 正是 mcp-go 先于 gateway 启动(控制面配置尚未下发)时的真实状态。
func newFulltextOnlyEngine(t *testing.T) *Engine {
t.Helper()
t.Setenv("BLEVE_PATH", t.TempDir()+"/bleve")
e := &Engine{bleve: openBleve(), graph: &graphStore{}}
t.Cleanup(func() { e.bleve.close() })
if !e.bleve.ready() {
t.Skip("bleve 不可用")
}
if err := e.bleve.index("k1", "d1", []string{
"星云一号卫星于2023年由长征七号发射,项目总设计师是李明华。",
"北斗增强终端解决了星间链路的可靠性难题。",
}); err != nil {
t.Fatal(err)
}
return e
}
// 没有 embedding/Milvus 时,全文路仍须照常召回——不能被总闸一刀切。
func TestSearch_FulltextSurvivesWithoutEmbedding(t *testing.T) {
e := newFulltextOnlyEngine(t)
if e.Ready() {
t.Fatal("前提不成立:该引擎本应是 not ready(无 embedding/Milvus")
}
hits, err := e.Search(context.Background(), "k1", "星间链路可靠性", 5)
if err != nil {
t.Fatalf("检索报错: %v", err)
}
if len(hits) == 0 {
t.Fatal("embedding 未配置时全文路仍应有召回;返回空说明总闸回归了")
}
}
// 诊断必须区分三种"空":没配置 / 报错 / 确实没匹配。
func TestSearchByModeDiag_DistinguishesEmptyKinds(t *testing.T) {
e := newFulltextOnlyEngine(t)
ctx := context.Background()
_, diags := e.SearchByModeDiag(ctx, "k1", "星间链路可靠性", 5, "hybrid")
byName := map[string]RouteDiag{}
for _, d := range diags {
byName[d.Name] = d
}
if len(byName) != 3 {
t.Fatalf("应有 vector/fulltext/graph 三路诊断,得 %v", diags)
}
// 向量路:没配置 → disabled(不是 empty,更不是静默消失)
if got := byName["vector"].Status; got != "disabled" {
t.Fatalf("无 embedding 时向量路应为 disabled,得 %q", got)
}
if byName["vector"].Note == "" {
t.Fatal("disabled 必须给出原因,否则界面上仍是一个无法解释的空")
}
// 图谱路:Neo4j 未连 → disabled
if got := byName["graph"].Status; got != "disabled" {
t.Fatalf("无 Neo4j 时图谱路应为 disabled,得 %q", got)
}
// 全文路:有命中 → ok
if got := byName["fulltext"].Status; got != "ok" {
t.Fatalf("全文路应为 ok,得 %qhits=%d err=%q", got, byName["fulltext"].Hits, byName["fulltext"].Error)
}
// 换一个库里没有的词:全文路应是 empty(确实没匹配),而不是 disabled/error
_, diags2 := e.SearchByModeDiag(ctx, "k1", "完全不相关的查询内容xyz", 5, "fulltext")
for _, d := range diags2 {
if d.Name == "fulltext" && d.Status != "empty" {
t.Fatalf("无匹配时全文路应为 empty,得 %q", d.Status)
}
}
}
// 内存兜底(索引不持久)必须在诊断里说出来:命中数正常但数据随时会没,
// 只看 hits 数量完全看不出来。
func TestDiag_ReportsNonPersistentIndex(t *testing.T) {
e := &Engine{bleve: memBleve(), graph: &graphStore{}}
defer e.bleve.close()
if err := e.bleve.index("k1", "d1", []string{"星间链路可靠性难题"}); err != nil {
t.Fatal(err)
}
_, diags := e.SearchByModeDiag(context.Background(), "k1", "星间链路", 5, "fulltext")
for _, d := range diags {
if d.Name != "fulltext" {
continue
}
if d.Status != "ok" {
t.Fatalf("内存索引也应能召回,得 %q", d.Status)
}
if d.Note == "" {
t.Fatal("内存兜底必须在 note 里点明(重启即清零),否则运维看不出这是降级态")
}
}
}
+5 -4
View File
@@ -104,9 +104,10 @@ func (g *graphStore) deleteByFile(ctx context.Context, kb, fileID string) error
} }
// search 图谱召回:找查询里提到的实体,返回其相连三元组(文本化)。 // search 图谱召回:找查询里提到的实体,返回其相连三元组(文本化)。
func (g *graphStore) search(ctx context.Context, kb, query string, limit int) []Hit { // 错误如实返回:Neo4j 不可用/查询失败以前一律回 nil,与"图谱里没有相关实体"无法区分。
func (g *graphStore) search(ctx context.Context, kb, query string, limit int) ([]Hit, error) {
if !g.ready() || query == "" { if !g.ready() || query == "" {
return nil return nil, nil
} }
// 匹配两路:① 查询整体含实体名($q CONTAINS);② 查询的字符 n-gram 与实体名互为子串 // 匹配两路:① 查询整体含实体名($q CONTAINS);② 查询的字符 n-gram 与实体名互为子串
// —— 解决"查询说'星云一号'、实体名抽成'星云一号卫星'"这类后缀错配(纯 $q CONTAINS 会漏)。 // —— 解决"查询说'星云一号'、实体名抽成'星云一号卫星'"这类后缀错配(纯 $q CONTAINS 会漏)。
@@ -118,7 +119,7 @@ func (g *graphStore) search(ctx context.Context, kb, query string, limit int) []
map[string]any{"kb": kb, "q": query, "ngrams": queryNgrams(query), "k": limit}, map[string]any{"kb": kb, "q": query, "ngrams": queryNgrams(query), "k": limit},
neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j")) neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j"))
if err != nil { if err != nil {
return nil return nil, err
} }
var hits []Hit var hits []Hit
for _, rec := range res.Records { for _, rec := range res.Records {
@@ -127,7 +128,7 @@ func (g *graphStore) search(ctx context.Context, kb, query string, limit int) []
o, _ := rec.Get("o") o, _ := rec.Get("o")
hits = append(hits, Hit{Text: fmt.Sprintf("%v —%v→ %v", s, p, o), Score: 1}) hits = append(hits, Hit{Text: fmt.Sprintf("%v —%v→ %v", s, p, o), Score: 1})
} }
return hits return hits, nil
} }
// queryNgrams 生成查询的字符 n-gram(长度 2..8,去重并截断)——用于和图谱实体名做双向子串匹配, // queryNgrams 生成查询的字符 n-gram(长度 2..8,去重并截断)——用于和图谱实体名做双向子串匹配,
+3 -2
View File
@@ -221,8 +221,9 @@ func (m *milvusStore) search(ctx context.Context, kb string, qvec []float32, top
results, err = do() results, err = do()
} }
if err != nil { if err != nil {
// 集合尚未就绪/无法重建 → 降级空结果(不阻断混合检索其它路) // 如实上抛:调用方负责"不阻断其它路",但必须知道这一路是**失败**而非"没召回"
return nil, nil // 以前这里回 nil,nil,向量路挂掉与检索无果彻底无法区分。
return nil, err
} }
var hits []Hit var hits []Hit
for _, r := range results { for _, r := range results {
+123 -16
View File
@@ -10,6 +10,7 @@ import (
"os" "os"
"strconv" "strconv"
"sync" "sync"
"time"
"github.com/sundynix/sundynix-shared/contract" "github.com/sundynix/sundynix-shared/contract"
) )
@@ -155,6 +156,9 @@ func (e *Engine) Status() map[string]bool {
"milvus": e.mv != nil, "milvus": e.mv != nil,
"neo4j": e.graph.ready(), "neo4j": e.graph.ready(),
"embedding": e.embed().ready(), "embedding": e.embed().ready(),
// 全文路是否落盘持久。false = 退了内存兜底,重启清零 —— 不上报的话这种降级
// 只表现为"召回变差",看不出故障(曾因此静默坏了很久)。
"fulltext_disk": e.bleve != nil && e.bleve.persistent,
} }
} }
@@ -230,17 +234,17 @@ func itoa(n int) string {
return string(b) return string(b)
} }
// Search 混合检索:Milvus(向量) + Bleve(全文) → RRF 融合 → 可选 rerank → topK。降级时返回空。 // Search 混合检索:Milvus(向量) + Bleve(全文) + Neo4j(图谱) → RRF 融合 → 可选 rerank → topK。
// 注意这里**不再**用 Ready() 当总闸:Ready() 只代表"向量路可用",而全文/图谱两路不依赖
// embedding 与 Milvus。以前一刀切返回空,导致 embedding 配置缺失时整个知识库像是"什么都搜不到",
// 且无任何错误信息。现在各路独立判定,任一路可用就仍有召回(见 searchPaths 的 RouteDiag)。
func (e *Engine) Search(ctx context.Context, kb, query string, topK int) ([]Hit, error) { func (e *Engine) Search(ctx context.Context, kb, query string, topK int) ([]Hit, error) {
if !e.Ready() {
return nil, nil
}
if topK <= 0 { if topK <= 0 {
topK = 5 topK = 5
} }
fanout := topK * 3 fanout := topK * 3
vecHits, ftHits, graphHits := e.searchPaths(ctx, kb, query, fanout) vecHits, ftHits, graphHits, _ := e.searchPaths(ctx, kb, query, fanout)
// RRF 融合(三路,按文本去重) // RRF 融合(三路,按文本去重)
cand := rrf([][]Hit{vecHits, ftHits, graphHits}, fanout) cand := rrf([][]Hit{vecHits, ftHits, graphHits}, fanout)
log.Printf("[rag] hybrid: 向量=%d 全文=%d 图谱=%d → 融合=%d", len(vecHits), len(ftHits), len(graphHits), len(cand)) log.Printf("[rag] hybrid: 向量=%d 全文=%d 图谱=%d → 融合=%d", len(vecHits), len(ftHits), len(graphHits), len(cand))
@@ -283,26 +287,129 @@ func (e *Engine) DeleteDoc(ctx context.Context, kb, fileID string) error {
return nil return nil
} }
// searchPaths 跑三路召回,返回各路命中(供混合融合与离线评测按单路对比)。 // RouteDiag 是一路召回的诊断。存在的理由:三路里任何一路挂掉都**不会报错**,
func (e *Engine) searchPaths(ctx context.Context, kb, query string, fanout int) (vec, ft, graph []Hit) { // 只表现为召回变差——"这一路没配置"、"这一路报错了"、"这一路确实没匹配"
if vecs, err := e.embed().Embed(ctx, []string{query}); err == nil && len(vecs) > 0 { // 在结果上完全一样(都是空数组),运维无从分辨。检索试验台据此告诉人是哪一环坏了。
vec, _ = e.mv.search(ctx, kb, vecs[0], fanout) type RouteDiag struct {
Name string `json:"name"` // vector | fulltext | graph
Status string `json:"status"` // ok | empty | disabled | error
Hits int `json:"hits"` //
MS int64 `json:"ms"` // 该路耗时
Error string `json:"error,omitempty"` // status=error 时的原文
Note string `json:"note,omitempty"` // 给人看的解释
}
func diagOf(name string, hits []Hit, err error, disabled bool, note string, started time.Time) RouteDiag {
d := RouteDiag{Name: name, Hits: len(hits), MS: time.Since(started).Milliseconds(), Note: note}
switch {
case disabled:
d.Status = "disabled"
case err != nil:
d.Status, d.Error = "error", err.Error()
case len(hits) == 0:
d.Status = "empty"
default:
d.Status = "ok"
}
return d
}
// searchPaths 跑三路召回,返回各路命中 + 各路诊断(供混合融合、离线评测与检索试验台)。
// 任何一路失败都不阻断其它路,但失败会被如实记录并打日志——绝不静默当成"没召回"。
func (e *Engine) searchPaths(ctx context.Context, kb, query string, fanout int) (vec, ft, graph []Hit, diags []RouteDiag) {
// ── 向量路:embedding 与 Milvus 任一环节失败都要区分出来 ──
t := time.Now()
var vErr error
var vNote string
vDisabled := !e.embed().ready() || e.mv == nil
if vDisabled {
vNote = "embedding 未配置或 Milvus 未连接"
} else {
vecs, err := e.embed().Embed(ctx, []string{query})
switch {
case err != nil:
vErr, vNote = fmt.Errorf("embedding: %w", err), "查询向量化失败,向量路本次无贡献"
case len(vecs) == 0:
vErr, vNote = errors.New("embedding 返回空向量"), "向量化返回空结果"
default:
vec, vErr = e.mv.search(ctx, kb, vecs[0], fanout)
if vErr != nil {
vNote = "Milvus 检索失败"
} else if len(vec) == 0 {
vNote = "该知识库在 Milvus 中没有向量(未入库或集合被重建过)"
}
}
}
diags = append(diags, diagOf("vector", vec, vErr, vDisabled, vNote, t))
// ── 全文路 ──
t = time.Now()
ftDisabled := !e.bleve.ready()
var ftErr error
ftNote := ""
if ftDisabled {
ftNote = "全文索引未就绪"
} else {
ft, ftErr = e.bleve.search(kb, query, fanout)
if !e.bleve.persistent {
ftNote = "索引为内存兜底(重启已清零,历史文档需重新入库)"
}
}
diags = append(diags, diagOf("fulltext", ft, ftErr, ftDisabled, ftNote, t))
// ── 图谱路 ──
t = time.Now()
gDisabled := !e.graph.ready()
var gErr error
gNote := ""
if gDisabled {
gNote = "Neo4j 未连接或未配置"
} else {
graph, gErr = e.graph.search(ctx, kb, query, fanout)
}
diags = append(diags, diagOf("graph", graph, gErr, gDisabled, gNote, t))
for _, d := range diags {
if d.Status == "error" {
log.Printf("[rag] ⚠️ %s 路检索失败 kb=%s: %s", d.Name, kb, d.Error)
}
} }
ft = e.bleve.search(kb, query, fanout)
graph = e.graph.search(ctx, kb, query, fanout)
return return
} }
// SearchByModeDiag 与 SearchByMode 同源,额外返回各路诊断(检索试验台用)。
// 试验台要回答的是"为什么这一路是空的",光有命中数回答不了。
func (e *Engine) SearchByModeDiag(ctx context.Context, kb, query string, topK int, mode string) ([]Hit, []RouteDiag) {
if topK <= 0 {
topK = 5
}
fanout := topK * 3
vec, ft, graph, diags := e.searchPaths(ctx, kb, query, fanout)
var hits []Hit
switch mode {
case "vector":
hits = vec
case "fulltext":
hits = ft
case "graph":
hits = graph
default:
hits = rrf([][]Hit{vec, ft, graph}, fanout)
}
if len(hits) > topK {
hits = hits[:topK]
}
return hits, diags
}
// SearchByMode 按指定模式返回 topK(评测用,纯检索不 rerank,便于公平对比)。 // SearchByMode 按指定模式返回 topK(评测用,纯检索不 rerank,便于公平对比)。
// mode: vector|fulltext|graph|hybrid(RRF)。 // mode: vector|fulltext|graph|hybrid(RRF)。
func (e *Engine) SearchByMode(ctx context.Context, kb, query string, topK int, mode string) []Hit { func (e *Engine) SearchByMode(ctx context.Context, kb, query string, topK int, mode string) []Hit {
if !e.Ready() || topK <= 0 { if topK <= 0 {
if topK <= 0 { topK = 5
topK = 5
}
} }
fanout := topK * 3 fanout := topK * 3
vec, ft, graph := e.searchPaths(ctx, kb, query, fanout) vec, ft, graph, _ := e.searchPaths(ctx, kb, query, fanout)
var hits []Hit var hits []Hit
switch mode { switch mode {
case "vector": case "vector":
+37 -2
View File
@@ -274,18 +274,53 @@ export async function billingPacks(): Promise<{ packs: Pack[]; channels: string[
// createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付) // createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付)
// + expires_at(二维码有效期,由服务端 orderTTL 决定,前端只负责倒计时展示)。 // + expires_at(二维码有效期,由服务端 orderTTL 决定,前端只负责倒计时展示)。
export async function createWechatOrder( export async function createWechatOrder(
packId: string, target: { packId: string } | { planId: string },
): Promise<{ order_id: string; code_url: string; amount_fen: number; expires_at: string }> { ): Promise<{ order_id: string; code_url: string; amount_fen: number; expires_at: string }> {
// 积分包与订阅走同一条支付链路(下单/回调/查单/掉单补偿全复用),只是订单内容不同。
const body = "packId" in target ? { pack_id: target.packId } : { plan_id: target.planId };
const res = guard401( const res = guard401(
await fetch(`${GATEWAY}/api/v1/billing/orders`, { await fetch(`${GATEWAY}/api/v1/billing/orders`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...bearer() }, headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify({ pack_id: packId }), body: JSON.stringify(body),
}), }),
); );
return jsonOrThrow(res, "下单失败"); return jsonOrThrow(res, "下单失败");
} }
// ---- 订阅(手动购买制:买一个周期,期内每 N 天发一次积分,到期即失效)----
export interface SubPlan {
id: string;
name: string;
price_fen: number;
duration_days: number;
refill_credits_micro: number;
refill_interval_days: number;
}
export interface MySub {
id: string;
plan_id: string;
status: string;
started_at: string;
expires_at: string;
refill_seq: number;
}
export async function subPlans(): Promise<SubPlan[]> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/sub-plans`, { headers: bearer() }));
if (!res.ok) return [];
return ((await res.json()) as { plans?: SubPlan[] }).plans ?? [];
}
// 我的订阅(无则 null)。到期即失效、不自动续费,所以到期时间要显眼地告诉用户。
export async function mySubscription(): Promise<{ subscription: MySub | null; plan: SubPlan | null }> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/subscription`, { headers: bearer() }));
if (!res.ok) return { subscription: null, plan: null };
const d = (await res.json()) as { subscription?: MySub | null; plan?: SubPlan | null };
return { subscription: d.subscription ?? null, plan: d.plan ?? null };
}
// orderStatus 轮询订单态(pending 时服务端顺路主动查单,本地也能确认到账)。 // orderStatus 轮询订单态(pending 时服务端顺路主动查单,本地也能确认到账)。
// warn 必须一并返回:服务端在「已付但金额与订单不符」时不入账、挂起人工核对, // warn 必须一并返回:服务端在「已付但金额与订单不符」时不入账、挂起人工核对,
// 订单会一直停在 pending —— 丢掉 warn 的话用户付了钱、界面却只会一直转圈等下去。 // 订单会一直停在 pending —— 丢掉 warn 的话用户付了钱、界面却只会一直转圈等下去。
+97 -11
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { Coins, ReceiptText, Ticket, QrCode } from "lucide-react"; import { Coins, ReceiptText, Ticket, QrCode } from "lucide-react";
import { useTenant } from "../shell/AppShell"; import { useTenant } from "../shell/AppShell";
import { myUsage, redeemCode, billingOrders, billingPacks, createWechatOrder, orderStatus, fmtCredits, type MyUsage, type TopupOrder, type Pack } from "../api"; import { myUsage, redeemCode, billingOrders, billingPacks, createWechatOrder, orderStatus, fmtCredits, subPlans, mySubscription, type MyUsage, type TopupOrder, type Pack, type SubPlan, type MySub } from "../api";
import { Badge, Button, Dialog, Input, Panel, Table, Tr, Td, cn, useToast } from "../ui"; import { Badge, Button, Dialog, Input, Panel, Table, Tr, Td, cn, useToast } from "../ui";
// 用量与账单:余额 + 兑换码充值(P5.1) + 消耗趋势 + 最近消耗/充值。 // 用量与账单:余额 + 兑换码充值(P5.1) + 消耗趋势 + 最近消耗/充值。
@@ -17,7 +17,10 @@ export function Usage() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [packs, setPacks] = useState<Pack[]>([]); const [packs, setPacks] = useState<Pack[]>([]);
const [wechatOn, setWechatOn] = useState(false); // 服务端配了商户号才亮 const [wechatOn, setWechatOn] = useState(false); // 服务端配了商户号才亮
const [paying, setPaying] = useState<Pack | null>(null); // 正在扫码支付的 const [paying, setPaying] = useState<PayItem | null>(null); // 正在扫码支付的商品(积分包或订阅)
const [plans, setPlans] = useState<SubPlan[]>([]);
const [sub, setSub] = useState<MySub | null>(null);
const [subPlan, setSubPlan] = useState<SubPlan | null>(null);
const load = useCallback(() => { const load = useCallback(() => {
myUsage(days).then(setU).catch(() => {}); myUsage(days).then(setU).catch(() => {});
@@ -28,6 +31,13 @@ export function Usage() {
setWechatOn(r.channels.includes("wechat")); setWechatOn(r.channels.includes("wechat"));
}) })
.catch(() => {}); .catch(() => {});
subPlans().then(setPlans).catch(() => {});
mySubscription()
.then((r) => {
setSub(r.subscription);
setSubPlan(r.plan);
})
.catch(() => {});
}, [days]); }, [days]);
useEffect(load, [load, ctx?.tenant?.id]); useEffect(load, [load, ctx?.tenant?.id]);
@@ -76,6 +86,55 @@ export function Usage() {
{canTopup ? ( {canTopup ? (
<div className="mt-4 border-t border-line pt-4"> <div className="mt-4 border-t border-line pt-4">
{/* 微信扫码:服务端配了商户号且有在售包才出现 */} {/* 微信扫码:服务端配了商户号且有在售包才出现 */}
{/* 订阅:到期即失效、不自动续费,所以"还剩几天"必须显眼——用户不会收到扣款提醒 */}
{sub && subPlan && (
<div className="mb-4 rounded-lg border border-brand/30 bg-brand/5 px-4 py-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="text-sm font-medium text-slate-200">
· {subPlan.name}
</div>
<SubExpiry expiresAt={sub.expires_at} />
</div>
<div className="mt-1 text-xs text-slate-500">
{subPlan.refill_interval_days} {" "}
<span className="tabular-nums text-brand-400">{fmtCredits(subPlan.refill_credits_micro)}</span> ·
<span className="tabular-nums">{sub.refill_seq}</span> ·
</div>
</div>
)}
{wechatOn && plans.length > 0 && canTopup && (
<div className="mb-4">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400">
<QrCode className="h-3.5 w-3.5" /> {sub ? "续订(在当前到期时间上顺延)" : "订阅"}
</div>
<div className="flex flex-wrap gap-2">
{plans.map((pl) => {
const times = pl.refill_interval_days > 0 ? Math.floor(pl.duration_days / pl.refill_interval_days) : 0;
return (
<button key={pl.id}
onClick={() => setPaying({
id: pl.id, name: pl.name, price_fen: pl.price_fen, kind: "sub",
note: `微信扫一扫支付,开通后每 ${pl.refill_interval_days} 天发放 ${fmtCredits(pl.refill_credits_micro)} 积分`,
})}
className="group rounded-lg border border-line bg-ink-850 px-4 py-2.5 text-left transition-colors hover:border-brand/50">
<div className="text-sm font-medium text-slate-200">{pl.name}</div>
<div className="mt-0.5 text-xs text-slate-500">
{pl.duration_days} · {pl.refill_interval_days} {" "}
<span className="tabular-nums text-brand-400">{fmtCredits(pl.refill_credits_micro)}</span> ·
<span className="ml-1 tabular-nums">¥{(pl.price_fen / 100).toFixed(2)}</span>
</div>
<div className="mt-0.5 text-[10px] text-slate-600">
{times} {fmtCredits(pl.refill_credits_micro * times)}
</div>
</button>
);
})}
</div>
</div>
)}
{wechatOn && packs.length > 0 && ( {wechatOn && packs.length > 0 && (
<div className="mb-4"> <div className="mb-4">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400"> <div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400">
@@ -83,7 +142,11 @@ export function Usage() {
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{packs.map((p) => ( {packs.map((p) => (
<button key={p.id} onClick={() => setPaying(p)} <button key={p.id}
onClick={() => setPaying({
id: p.id, name: p.name, price_fen: p.price_fen, kind: "pack",
note: `微信扫一扫支付,到账 ${fmtCredits(p.credits_micro)} 积分`,
})}
className="group rounded-lg border border-line bg-ink-850 px-4 py-2.5 text-left transition-colors hover:border-brand/50"> className="group rounded-lg border border-line bg-ink-850 px-4 py-2.5 text-left transition-colors hover:border-brand/50">
<div className="text-sm font-medium text-slate-200">{p.name}</div> <div className="text-sm font-medium text-slate-200">{p.name}</div>
<div className="mt-0.5 text-xs text-slate-500"> <div className="mt-0.5 text-xs text-slate-500">
@@ -188,10 +251,11 @@ export function Usage() {
{paying && ( {paying && (
<PayDialog <PayDialog
pack={paying} item={paying}
onClose={(paid) => { onClose={(paid) => {
const wasSub = paying.kind === "sub";
setPaying(null); setPaying(null);
if (paid) toast.push("success", "支付成功,积分已入账"); if (paid) toast.push("success", wasSub ? "订阅已开通,首笔积分已发放" : "支付成功,积分已入账");
// 无论如何都刷一次:用户可能在关弹窗前一刻付款、状态刚落地还没被轮询看到。 // 无论如何都刷一次:用户可能在关弹窗前一刻付款、状态刚落地还没被轮询看到。
load(); load();
refresh(); refresh();
@@ -217,7 +281,17 @@ const POLL_MAX_MS = 15000;
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) => void }) { // 支付弹窗对「买什么」保持中立:积分包与订阅只是标题与到账说明不同,
// 下单/轮询/超时/warn 处理全共用——复制一份给订阅的话,两边迟早漂移。
type PayItem = {
id: string;
name: string;
price_fen: number;
kind: "pack" | "sub";
note: string; // 支付成功前显示的"买到什么"说明
};
function PayDialog({ item, onClose }: { item: PayItem; onClose: (paid: boolean) => void }) {
const [qr, setQr] = useState(""); const [qr, setQr] = useState("");
const [err, setErr] = useState(""); const [err, setErr] = useState("");
const [warn, setWarn] = useState(""); const [warn, setWarn] = useState("");
@@ -275,7 +349,7 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
(async () => { (async () => {
try { try {
const o = await createWechatOrder(pack.id); const o = await createWechatOrder(item.kind === "sub" ? { planId: item.id } : { packId: item.id });
if (!alive) return; if (!alive) return;
orderRef.current = o.order_id; orderRef.current = o.order_id;
expiresRef.current = new Date(o.expires_at).getTime(); expiresRef.current = new Date(o.expires_at).getTime();
@@ -294,7 +368,7 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
document.removeEventListener("visibilitychange", onVisible); document.removeEventListener("visibilitychange", onVisible);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [pack.id]); }, [item.id]);
// 倒计时:到点本地先置过期,省一次「扫了个死码才知道」。服务端 TTL 仍是权威。 // 倒计时:到点本地先置过期,省一次「扫了个死码才知道」。服务端 TTL 仍是权威。
useEffect(() => { useEffect(() => {
@@ -308,7 +382,7 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
}, [state]); }, [state]);
return ( return (
<Dialog open title={`微信扫码 · ${pack.name}`} onClose={() => onClose(state === "paid")}> <Dialog open title={`微信扫码 · ${item.name}`} onClose={() => onClose(state === "paid")}>
<div className="flex flex-col items-center gap-3 py-2"> <div className="flex flex-col items-center gap-3 py-2">
{err ? ( {err ? (
<p className="text-xs text-danger">{err}</p> <p className="text-xs text-danger">{err}</p>
@@ -327,13 +401,13 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
)} )}
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-lg font-semibold tabular-nums text-slate-100">¥{(pack.price_fen / 100).toFixed(2)}</div> <div className="text-lg font-semibold tabular-nums text-slate-100">¥{(item.price_fen / 100).toFixed(2)}</div>
<div className="mt-1 text-xs text-slate-500"> <div className="mt-1 text-xs text-slate-500">
{state === "paid" {state === "paid"
? "✅ 已支付,入账中…" ? "✅ 已支付,入账中…"
: state === "expired" : state === "expired"
? "订单已过期,请关闭后重新下单" ? "订单已过期,请关闭后重新下单"
: `微信扫一扫支付,到账 ${fmtCredits(pack.credits_micro)} 积分`} : item.note}
</div> </div>
{state === "waiting" && ( {state === "waiting" && (
<div className="mt-1 text-[11px] tabular-nums text-slate-500"> {mmss(left)} </div> <div className="mt-1 text-[11px] tabular-nums text-slate-500"> {mmss(left)} </div>
@@ -361,3 +435,15 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
</Dialog> </Dialog>
); );
} }
// SubExpiry 到期倒计时。到期即失效且没有自动续费,临近到期必须变色提醒——
// 用户不会收到任何扣款或续费通知,只能靠这里看见。
function SubExpiry({ expiresAt }: { expiresAt: string }) {
const left = Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86400000);
const tone = left <= 3 ? "text-rose-400" : left <= 7 ? "text-amber-400" : "text-slate-400";
return (
<span className={`text-xs tabular-nums ${tone}`}>
{new Date(expiresAt).toLocaleDateString("zh-CN")} {left}
</span>
);
}