Merge pull request 'fix(rag): 全文索引在容器里根本没持久化 —— 补 /data 卷 + BLEVE_PATH' (#8) from feat/site into main
deploy-132 / deploy (push) Successful in 3m45s
deploy-132 / deploy (push) Successful in 3m45s
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -23,6 +23,11 @@ POSTGRES_PASSWORD=
|
||||
# 图数据库密码(Neo4j,知识图谱)。
|
||||
NEO4J_PASSWORD=
|
||||
|
||||
# 全文(bleve)索引落盘目录。留空则用相对路径 .data/bleve —— 相对的是 mcp-go 的**启动目录**,
|
||||
# 从仓库根起和从 sundynix-mcp-go/ 起会写出两份互不相干的索引(本地排查全文召回时最容易被这个坑到)。
|
||||
# 本地开发建议显式写成绝对路径;容器部署已在 compose 里固定为 /data/bleve + 持久卷。
|
||||
BLEVE_PATH=
|
||||
|
||||
# ── 必填:管理员 ─────────────────────────────────────────────
|
||||
# 管理员用户 ID 白名单(逗号分隔)。生产模式下只有这些用户能进 /admin 控制台。
|
||||
# 首次部署:先留空起服务 → 注册第一个账号 → 从返回的 user id 填进来 → 重启 gateway。
|
||||
|
||||
@@ -31,12 +31,17 @@ services:
|
||||
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN:-*}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318
|
||||
# 微信域名校验文件目录(容器内路径);未设则该路由不注册
|
||||
WECHAT_VERIFY_DIR: /etc/sundynix/wechat-verify
|
||||
ports: ["3000:8080"] # frp 外网 → 132:3000 → 容器 8080
|
||||
volumes:
|
||||
# 微信支付证书(商户私钥 + 微信支付公钥):宿主机 132 的目录只读挂进容器。
|
||||
# ⚠️ admin「系统配置 → 支付」里填的路径必须是**容器内路径**(/etc/sundynix/wechat-cert/...),
|
||||
# 不是宿主机路径——容器看不到宿主机的 /home/workspace/...。私钥不进镜像、不进 git。
|
||||
- /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:
|
||||
build: { context: ../.., dockerfile: sundynix-dispatcher/Dockerfile }
|
||||
@@ -72,6 +77,11 @@ services:
|
||||
MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-docs}
|
||||
SUNDYNIX_SECRET_KEY: ${SUNDYNIX_SECRET_KEY:?}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318
|
||||
# 全文(bleve)索引落盘位置。不设则相对 CWD,写进容器可写层 → 每次发版重建容器就清零,
|
||||
# 而混合检索只是静默少一路召回、不报错,很难发现。必须配合下面的持久卷。
|
||||
BLEVE_PATH: /data/bleve
|
||||
volumes:
|
||||
- mcp-go-data:/data
|
||||
|
||||
mcp-py:
|
||||
build: { context: ../../sundynix-mcp-py, dockerfile: Dockerfile }
|
||||
@@ -83,3 +93,8 @@ services:
|
||||
# Docker 暴露给该服务(可逃逸);不需要代码执行工具可注释掉这两行。mcp-py 无对外端口。
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
# 全文索引持久化:随容器重建保留(向量在 Milvus、图谱在 Neo4j,都在 128;
|
||||
# 只有 bleve 是跟着 mcp-go 走的本地索引,唯独它需要这个卷)。
|
||||
volumes:
|
||||
mcp-go-data:
|
||||
|
||||
@@ -71,9 +71,14 @@ services:
|
||||
MINIO_BUCKET: sundynix-docs
|
||||
SUNDYNIX_SECRET_KEY: *secret-key
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318
|
||||
# 全文(bleve)索引落盘位置。不设则相对 CWD,写进容器可写层 → 每次重建容器就清零,
|
||||
# 而混合检索只是静默少一路召回、不报错,很难发现。必须配合下面的持久卷。
|
||||
BLEVE_PATH: /data/bleve
|
||||
depends_on:
|
||||
nats: { condition: service_started }
|
||||
milvus: { condition: service_healthy } # mcp-go 须在 Milvus 后起,否则工具 no responders
|
||||
volumes:
|
||||
- mcp_go_data:/data
|
||||
|
||||
mcp-py:
|
||||
build: { context: sundynix-mcp-py, dockerfile: Dockerfile }
|
||||
@@ -181,3 +186,4 @@ volumes:
|
||||
minio_data:
|
||||
milvus_data:
|
||||
neo4j_data:
|
||||
mcp_go_data:
|
||||
|
||||
@@ -8,6 +8,7 @@ import { me, clearToken, type AuthUser } from "./api";
|
||||
import { SiteLayout } from "./site/components/layout/site-layout";
|
||||
import HomePage from "./site/pages/home";
|
||||
import DownloadPage from "./site/pages/download";
|
||||
import PricingPage from "./site/pages/pricing";
|
||||
import NotFoundPage from "./site/pages/not-found";
|
||||
|
||||
// 后台鉴权门:只在访问 /admin 时跑 me()(官网公开、不打鉴权)。
|
||||
@@ -54,6 +55,7 @@ export default function App() {
|
||||
{/* 官网:根 / + /download,公开;未知路径落官网 404(保留 header/footer) */}
|
||||
<Route element={<SiteLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="pricing" element={<PricingPage />} />
|
||||
<Route path="download" element={<DownloadPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
|
||||
+114
-6
@@ -333,6 +333,59 @@ export async function payOrderStatus(orderId: string): Promise<{ order: PayOrder
|
||||
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 负分录 + 回退余额(幂等)。
|
||||
// status="noop" 表示该单本就无需退(已退/未支付)。真渠道钱的原路退回需 admin 另在商户后台操作。
|
||||
export async function adminRefundOrder(id: string, memo: string): Promise<{ status: string; detail?: string }> {
|
||||
@@ -361,6 +414,34 @@ export interface AdminTask {
|
||||
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)。
|
||||
export async function adminTasks(
|
||||
status = "",
|
||||
@@ -485,9 +566,24 @@ export interface KbHit {
|
||||
/** 检索模式:单路用于逐路定位是哪一路没召回;hybrid=纯 RRF 融合(不 rerank);""=生产链路(混合+rerank)。 */
|
||||
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 检索试验台:按完整作用域键跨租户检索任意知识库。
|
||||
// kb 传 `${space_id}/${name}`(来自 adminDatasources)。
|
||||
export async function adminKbSearch(kb: string, q: string, topK = 5, mode: SearchMode = ""): Promise<KbHit[]> {
|
||||
// kb 传 `${space_id}/${name}`(来自 adminDatasources)。返回命中 + 每一路的诊断。
|
||||
export async function adminKbSearch(
|
||||
kb: string,
|
||||
q: string,
|
||||
topK = 5,
|
||||
mode: SearchMode = "",
|
||||
): Promise<{ hits: KbHit[]; routes: RouteDiag[] }> {
|
||||
const res = guard(
|
||||
await fetch(`${ADMIN}/kb/search`, {
|
||||
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 }),
|
||||
}),
|
||||
);
|
||||
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}`);
|
||||
return d.hits ?? [];
|
||||
return { hits: d.hits ?? [], routes: d.routes ?? [] };
|
||||
}
|
||||
|
||||
export async function adminDatasources(): Promise<{ counts: { users: number; kbs: number; docs: number }; datasources: DatasourceKB[] }> {
|
||||
@@ -647,8 +743,20 @@ export interface GuardrailEventItem {
|
||||
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}`);
|
||||
return ((await res.json()) as { logs?: AuditEntry[] }).logs ?? [];
|
||||
}
|
||||
|
||||
@@ -108,7 +108,15 @@ export function ModelManager({
|
||||
</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"
|
||||
>
|
||||
删除
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 融合」,并排看各自召回。
|
||||
// 用途:线上召回不准时定位是哪一环 —— 向量路空=embedding/切块问题;全文路空=分词/索引问题;
|
||||
@@ -14,7 +14,17 @@ const ROUTES: Array<{ mode: SearchMode; label: string; hint: string }> = [
|
||||
{ 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[] }) {
|
||||
const withDocs = kbs.filter((k) => k.doc_count > 0);
|
||||
@@ -34,7 +44,7 @@ export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) {
|
||||
const settled = await Promise.all(
|
||||
modes.map(async (m) => {
|
||||
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) {
|
||||
return [m, { hits: [], err: (e as Error).message }] as const;
|
||||
}
|
||||
@@ -107,6 +117,37 @@ export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) {
|
||||
|
||||
{ran && (
|
||||
<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
|
||||
label="生产链路"
|
||||
@@ -121,9 +162,11 @@ export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) {
|
||||
分路诊断(分数体系不同,勿跨路比大小)
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{ROUTES.map((r) => (
|
||||
<RouteCard key={r.mode} label={r.label} hint={r.hint} data={res[r.mode]} />
|
||||
))}
|
||||
{ROUTES.map((r) => {
|
||||
// 单路那次请求里,取它自己那一路的诊断(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>
|
||||
@@ -136,11 +179,13 @@ function RouteCard({
|
||||
label,
|
||||
hint,
|
||||
data,
|
||||
diag,
|
||||
highlight,
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
data?: { hits: KbHit[]; err?: string };
|
||||
data?: { hits: KbHit[]; routes?: RouteDiag[]; err?: string };
|
||||
diag?: RouteDiag;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
const hits = data?.hits ?? [];
|
||||
@@ -157,7 +202,14 @@ function RouteCard({
|
||||
{data?.err ? (
|
||||
<p className="mt-2 text-[11px] text-rose-500">{data.err}</p>
|
||||
) : 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">
|
||||
{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>;
|
||||
}
|
||||
@@ -47,7 +47,11 @@ export function AuditPage() {
|
||||
try {
|
||||
const offset = (pageNum - 1) * 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) {
|
||||
setAudit(data.slice(0, LIMIT));
|
||||
setHasMore(true);
|
||||
@@ -70,20 +74,20 @@ export function AuditPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
// 前端过滤(配合后端分页后的过滤,或按需做简单的前端实时匹配)
|
||||
const filteredAudit = audit.filter((a) => {
|
||||
if (methodFilter && a.action !== methodFilter) return false;
|
||||
if (routeFilter && !a.path.startsWith(routeFilter)) return false;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchActor = a.actor.toLowerCase().includes(q);
|
||||
const matchIp = a.ip.toLowerCase().includes(q);
|
||||
const matchDetail = a.detail?.toLowerCase().includes(q) ?? false;
|
||||
const matchPath = a.path.toLowerCase().includes(q);
|
||||
if (!matchActor && !matchIp && !matchDetail && !matchPath) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// 筛选条件变化 → 回第 1 页重新向服务端查询(防抖 300ms,免得每敲一个字打一次库)。
|
||||
// 已在第 1 页时 setPage 不会触发上面的 effect,所以这里直接 load。
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
if (page !== 1) setPage(1);
|
||||
else void load(1);
|
||||
}, 300);
|
||||
return () => window.clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [methodFilter, routeFilter, searchQuery]);
|
||||
|
||||
// 筛选已由服务端完成(全库匹配 + 分页),前端不再二次过滤:
|
||||
// 否则会在"服务端已筛过的一页"上再筛一次,翻页计数与实际结果对不上。
|
||||
const filteredAudit = audit;
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (page > 1) setPage(page - 1);
|
||||
|
||||
@@ -17,6 +17,8 @@ const INFRA_META: Record<string, { role: string; port: string; icon: IconName }>
|
||||
milvus: { role: "向量检索库", port: "19530", icon: "db" },
|
||||
neo4j: { role: "图关系库", port: "7687", icon: "bus" },
|
||||
minio: { role: "对象存储 (OSS)", port: "9000", icon: "db" },
|
||||
// 全文索引不是独立服务,是 mcp-go 进程内的本地 bleve —— 没有端口,落在容器卷上。
|
||||
全文索引: { role: "全文检索 (bleve)", port: "本地卷", icon: "db" },
|
||||
};
|
||||
|
||||
function toolCategory(t: string): string {
|
||||
@@ -289,7 +291,11 @@ export function StatusPage() {
|
||||
{s.name === "mcp-go" || s.name === "mcp-py" ? "已注册工具数" : "网络延迟"}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,9 +319,15 @@ export function StatusPage() {
|
||||
const meta = INFRA_META[item.name];
|
||||
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 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="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 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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { TaskDetailDrawer } from "../components/TaskDetailDrawer";
|
||||
import { adminTasks, listTenants, type AdminTask, type TenantRow } from "../api";
|
||||
|
||||
// 全平台任务/运行观测:跨租户看所有任务(状态分布 + 列表 + 提交人/租户/评测)。
|
||||
@@ -31,6 +32,7 @@ export function TasksPage() {
|
||||
const [filter, setFilter] = useState("");
|
||||
const [tenantFilter, setTenantFilter] = useState("");
|
||||
const [tenants, setTenants] = useState<TenantRow[]>([]);
|
||||
const [drill, setDrill] = useState<AdminTask | null>(null); // 正在下钻的任务
|
||||
const [copiedId, setCopiedId] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -124,13 +126,21 @@ export function TasksPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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">
|
||||
<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>
|
||||
<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"
|
||||
title="复制任务 ID"
|
||||
>
|
||||
@@ -174,6 +184,8 @@ export function TasksPage() {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drill && <TaskDetailDrawer task={drill} onClose={() => setDrill(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SubscriptionPage } from "./pages/SubscriptionPage";
|
||||
import { lazy, type ReactNode } from "react";
|
||||
|
||||
// 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。
|
||||
@@ -98,6 +99,14 @@ export const routes: RouteDef[] = [
|
||||
ready: true,
|
||||
element: <PaymentConfigPage />,
|
||||
},
|
||||
{
|
||||
path: "payment/subscription",
|
||||
label: "订阅",
|
||||
group: "运维",
|
||||
parent: "支付",
|
||||
ready: true,
|
||||
element: <SubscriptionPage />,
|
||||
},
|
||||
{
|
||||
path: "payment/orders",
|
||||
label: "订单与对账",
|
||||
|
||||
@@ -10,6 +10,7 @@ const NAV_ITEMS = [
|
||||
{ label: '产品', to: '/', end: true },
|
||||
{ label: '功能', to: '/#features' },
|
||||
{ label: '架构', to: '/#architecture' },
|
||||
{ label: '定价', to: '/pricing' },
|
||||
]
|
||||
|
||||
export function SiteHeader() {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -150,6 +150,31 @@ export interface MyTenant {
|
||||
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 }> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/tenants`, { headers: bearer() }));
|
||||
if (!res.ok) return { tenants: [], active: "" };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "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";
|
||||
|
||||
// 桌面端「用量」= 用户自己租户的计量观测:积分余额 + 消耗趋势 + 最近消耗。
|
||||
@@ -34,11 +35,20 @@ export function UsageView() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [sub, setSub] = useState<MySubscription | null>(null);
|
||||
const [subPlan, setSubPlan] = useState<MySubPlan | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
setData(await myUsage(days));
|
||||
// 订阅只读展示;失败不影响用量页主体(没订阅也是常态)
|
||||
mySubscription()
|
||||
.then((r) => {
|
||||
setSub(r.subscription);
|
||||
setSubPlan(r.plan);
|
||||
})
|
||||
.catch(() => {});
|
||||
setErr("");
|
||||
} catch (e) {
|
||||
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" />
|
||||
</div>
|
||||
|
||||
{/* 订阅状态:到期即失效、无自动续费、无扣款通知 —— 必须在用户干活的地方看得见。
|
||||
购买/续订不在这里做(入口在 Web 面账单页),避免同一功能两个入口。 */}
|
||||
{sub && subPlan && <SubBar sub={sub} plan={subPlan} />}
|
||||
|
||||
{/* 消耗趋势 */}
|
||||
<Panel title="积分消耗趋势" icon={Activity} className="min-h-[200px]">
|
||||
<TrendBars series={series} />
|
||||
@@ -182,3 +196,31 @@ function TrendBars({ series }: { series: UsageDay[] }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -30,7 +31,14 @@ func (h *Handler) AuditList(c *gin.Context) {
|
||||
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 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -375,7 +383,6 @@ func (h *Handler) AdminSetTenantStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
|
||||
// AdminMembers: GET /api/v1/admin/tenants/:id/members —— 某租户成员列表。
|
||||
func (h *Handler) AdminMembers(c *gin.Context) {
|
||||
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) {
|
||||
for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding} {
|
||||
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 配置广播失败: %v(dispatcher/mcp-go 仍在用旧配置,需重启或重试保存)", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -38,10 +39,11 @@ const orderTTL = 30 * time.Minute
|
||||
func (h *Handler) BillingCreateOrder(c *gin.Context) {
|
||||
var b struct {
|
||||
PackID string `json:"pack_id"`
|
||||
PlanID string `json:"plan_id"` // 传它=买订阅周期;与 pack_id 二选一
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.PackID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pack_id 必填"})
|
||||
if err := c.ShouldBindJSON(&b); err != nil || (strings.TrimSpace(b.PackID) == "" && strings.TrimSpace(b.PlanID) == "") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pack_id 或 plan_id 必填"})
|
||||
return
|
||||
}
|
||||
channel := strings.TrimSpace(b.Channel)
|
||||
@@ -60,21 +62,40 @@ func (h *Handler) BillingCreateOrder(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无计费租户上下文"})
|
||||
return
|
||||
}
|
||||
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,
|
||||
AmountFen: pk.PriceFen, CreditsMicro: pk.CreditsMicro,
|
||||
Channel: channel, Status: store.OrderPending,
|
||||
// 订阅单与积分包单走同一条支付链路:只有订单内容不同,下单/回调/查单/掉单补偿全复用。
|
||||
var o *store.PaymentOrder
|
||||
var desc string
|
||||
if pid := strings.TrimSpace(b.PlanID); pid != "" {
|
||||
pl := h.db.GetSubPlan(ctx, pid)
|
||||
if pl == nil || !pl.Active {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "订阅套餐不存在或已下架"})
|
||||
return
|
||||
}
|
||||
// 订阅单 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 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
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 {
|
||||
// 渠道下单失败的单直接作废,不留一堆永远付不了的 pending。
|
||||
_ = 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)})
|
||||
}
|
||||
|
||||
// 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= —— 全平台空间观测(跨租户)。
|
||||
func (h *Handler) AdminSpaces(c *gin.Context) {
|
||||
limit := 200
|
||||
|
||||
@@ -18,9 +18,9 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/blob"
|
||||
"github.com/sundynix/sundynix-gateway/internal/nats"
|
||||
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||
"github.com/sundynix/sundynix-shared/blob"
|
||||
"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 必填"})
|
||||
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 {
|
||||
args["topK"] = body.TopK
|
||||
}
|
||||
@@ -590,9 +593,19 @@ func (h *Handler) AdminKbSearch(c *gin.Context) {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": res.Error})
|
||||
return
|
||||
}
|
||||
var hits []map[string]any
|
||||
_ = json.Unmarshal([]byte(res.Content), &hits)
|
||||
c.JSON(http.StatusOK, gin.H{"hits": hits})
|
||||
// 带 diag 时 mcp-go 回的是 {hits, routes} 对象;老版本回裸数组,做兼容降级
|
||||
// (少了诊断而已,不该让整个试验台打不开)。
|
||||
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_graph,Neo4j)。
|
||||
|
||||
@@ -56,6 +56,7 @@ func (h *Handler) AdminStatus(c *gin.Context) {
|
||||
wg sync.WaitGroup
|
||||
|
||||
milvus, neo4j bool // mcp-go health
|
||||
ftDisk bool // 全文索引是否落盘持久(false=退内存兜底,重启清零)
|
||||
goUp bool // mcp-go 在线
|
||||
goTools []toolInfo // mcp-go 注册工具
|
||||
goLatency int // mcp-go 探针耗时
|
||||
@@ -81,6 +82,7 @@ func (h *Handler) AdminStatus(c *gin.Context) {
|
||||
var sub map[string]bool
|
||||
if json.Unmarshal([]byte(res.Content), &sub) == nil {
|
||||
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: "neo4j", Up: neo4j},
|
||||
{Name: "minio", Up: minioUp}, // 对象存储(报告/KB 正文/blob,126)
|
||||
// 全文索引:mcp-go 本地 bleve,是唯一不在 128 集中存储上的检索路,
|
||||
// 也是唯一会"静默降级"的一路(退内存后重启清零,检索只是变差不报错)。
|
||||
{Name: "全文索引", Up: goUp && ftDisk, Detail: fulltextDetail(goUp, ftDisk)},
|
||||
},
|
||||
Services: []statusItem{
|
||||
{Name: "gateway", Up: true, Detail: "在线"},
|
||||
@@ -207,3 +212,16 @@ func humanDuration(s int) string {
|
||||
}
|
||||
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-gonic/gin"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/blob"
|
||||
"github.com/sundynix/sundynix-gateway/internal/dsl"
|
||||
"github.com/sundynix/sundynix-gateway/internal/nats"
|
||||
"github.com/sundynix/sundynix-gateway/internal/payment"
|
||||
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||
"github.com/sundynix/sundynix-shared/blob"
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
)
|
||||
|
||||
@@ -139,7 +139,11 @@ func (h *Handler) startTokenRecorder(taskID string) {
|
||||
func() {
|
||||
_ = h.cache.StreamAppend(ctx, store.ChannelToken, taskID, "done", "")
|
||||
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()
|
||||
},
|
||||
@@ -170,8 +174,12 @@ func (h *Handler) startExecRecorder(taskID string) {
|
||||
func() {
|
||||
_ = h.cache.StreamAppend(ctx, store.ChannelExec, taskID, "done", "")
|
||||
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()
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,19 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"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/middleware"
|
||||
"github.com/sundynix/sundynix-gateway/internal/nats"
|
||||
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||
"github.com/sundynix/sundynix-gateway/internal/webui"
|
||||
"github.com/sundynix/sundynix-shared/blob"
|
||||
)
|
||||
|
||||
// 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(middleware.RequestID()) // 生成/透传 X-Request-ID(日志关联)
|
||||
r.Use(middleware.Observe()) // Prometheus 指标 + 结构化访问日志(替代 gin 默认文本日志)
|
||||
r.Use(cors()) // 桌面端/浏览器跨源访问
|
||||
r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)——须在限流前,供按用户限流
|
||||
r.Use(middleware.TenantContext(db)) // 多租户:注入当前 tenant_id(已登录才解析;须在 Auth 之后)
|
||||
r.Use(middleware.SpaceContext(db)) // 共享工作区:注入当前 space_id(须在 TenantContext 之后)
|
||||
r.Use(middleware.RateLimit(cache)) // 已认证按用户限流,否则按 IP(企业网多人共享 IP 不再互相拖累)
|
||||
r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event)
|
||||
r.Use(cors()) // 桌面端/浏览器跨源访问
|
||||
r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)——须在限流前,供按用户限流
|
||||
r.Use(middleware.TenantContext(db)) // 多租户:注入当前 tenant_id(已登录才解析;须在 Auth 之后)
|
||||
r.Use(middleware.SpaceContext(db)) // 共享工作区:注入当前 space_id(须在 TenantContext 之后)
|
||||
r.Use(middleware.RateLimit(cache)) // 已认证按用户限流,否则按 IP(企业网多人共享 IP 不再互相拖累)
|
||||
r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event)
|
||||
|
||||
h := handler.New(db, cache, bus, blobStore)
|
||||
// 微信支付渠道装配:DB 配置优先(admin 控制面热重载)→ env 兜底 → 隐藏。失败不阻断启动。
|
||||
h.InitWechat(context.Background())
|
||||
// 掉单补偿定时器:周期扫 pending 微信单确认到账(用户扫完码关页面也能补入账)。
|
||||
h.StartReconcile(context.Background())
|
||||
h.StartSubscriptionTicker(context.Background()) // 订阅按周期发放积分 + 到期置失效
|
||||
|
||||
// 可观测性根端点:Prometheus 抓取 + k8s 存活/就绪探针(不挂业务中间件鉴权)。
|
||||
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")
|
||||
{
|
||||
// —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)——
|
||||
api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
|
||||
api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
|
||||
api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401)
|
||||
api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
|
||||
api.GET("/tasks/:id/stream", h.StreamTask) // SSE 回流 Token Stream(task_id 寻址)
|
||||
api.GET("/tasks/:id/exec", h.StreamExec) // SSE 回流执行轨迹(task_id 寻址)
|
||||
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(job_id 寻址)
|
||||
api.GET("/reports/:id/export", h.ExportReport) // 按需导出(report_id 寻址)
|
||||
api.GET("/reports/:id/download", h.ExportReport) // 兼容旧入口(默认 docx)
|
||||
api.GET("/pricing", h.PublicPricing) // 公开定价(官网未登录也要能看价,故不挂鉴权)
|
||||
api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
|
||||
api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
|
||||
api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401)
|
||||
api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
|
||||
api.GET("/tasks/:id/stream", h.StreamTask) // SSE 回流 Token Stream(task_id 寻址)
|
||||
api.GET("/tasks/:id/exec", h.StreamExec) // SSE 回流执行轨迹(task_id 寻址)
|
||||
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(job_id 寻址)
|
||||
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;渠道验签是唯一的门)
|
||||
|
||||
// —— 受保护:owner 作用域业务,必须携带有效 JWT ——
|
||||
p := api.Group("", middleware.RequireAuth())
|
||||
{
|
||||
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.POST("/tasks/:id/approve", middleware.Audit(db), h.ApproveTask) // HITL 人工审批决定(批准/拒绝,审计)
|
||||
p.GET("/tenants/current", h.TenantCurrent) // 当前租户上下文 + 角色 + 可花余额(多租户)
|
||||
p.GET("/me/tenants", h.MyTenantsList) // 我所属租户(供切换)
|
||||
p.POST("/me/tenants", h.CreateMyTenant) // 自助建组织(创建者即 owner,建完切入)
|
||||
p.POST("/me/tenant", h.SwitchTenant) // 切换当前活跃租户
|
||||
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.GET("/tenants/current", h.TenantCurrent) // 当前租户上下文 + 角色 + 可花余额(多租户)
|
||||
p.GET("/me/tenants", h.MyTenantsList) // 我所属租户(供切换)
|
||||
p.POST("/me/tenants", h.CreateMyTenant) // 自助建组织(创建者即 owner,建完切入)
|
||||
p.POST("/me/tenant", h.SwitchTenant) // 切换当前活跃租户
|
||||
|
||||
// 租户成员自助管理(薄 Web 面):作用于活跃租户,看 ≥viewer、写 ≥admin + 审计。
|
||||
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.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.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗)
|
||||
p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
|
||||
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
||||
p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
|
||||
p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key=)
|
||||
p.GET("/kb/list", h.KbList) // 当前空间的知识库列表(共享工作区)
|
||||
p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗)
|
||||
p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
|
||||
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
||||
p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
|
||||
p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key=)
|
||||
p.GET("/kb/list", h.KbList) // 当前空间的知识库列表(共享工作区)
|
||||
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_file", middleware.RequireSpaceRole(db, store.RoleMember), h.KbIngestFile) // 文件入库:viewer 拦下
|
||||
p.POST("/kb/search", h.KbSearch) // 检索台(读,全员)
|
||||
p.GET("/kb/vault", h.KbVault) // 文库列表
|
||||
p.GET("/kb/doc", h.KbDoc) // 取单篇文档
|
||||
p.POST("/kb/search", h.KbSearch) // 检索台(读,全员)
|
||||
p.GET("/kb/vault", h.KbVault) // 文库列表
|
||||
p.GET("/kb/doc", h.KbDoc) // 取单篇文档
|
||||
p.DELETE("/kb/doc", middleware.RequireSpaceRole(db, store.RoleMember), h.KbDeleteDoc) // 级联删文档:viewer 拦下
|
||||
|
||||
// Prompt 控制面(平台级配置:建版本 → 激活 → 控制面热下发各服务)
|
||||
p.GET("/prompts", h.PromptList) // 列出全部版本 + 可配键
|
||||
p.POST("/prompts/version", h.PromptCreateVersion) // 建新版本(不自动激活)
|
||||
p.POST("/prompts/activate", middleware.Audit(db), h.PromptActivate) // 激活某版本 → 广播热更新(审计)
|
||||
p.POST("/prompts/deactivate", middleware.Audit(db), h.PromptDeactivate) // 撤销激活 → 回退代码默认(热,审计)
|
||||
p.GET("/kb/links", h.KbLinks) // 某库双链
|
||||
p.POST("/kb/note", middleware.RequireSpaceRole(db, store.RoleMember), h.KbSaveNote) // 新建/编辑笔记:viewer 拦下
|
||||
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
|
||||
p.GET("/agents", h.AgentList) // 当前空间的编排列表(共享工作区,含创建人)
|
||||
p.GET("/prompts", h.PromptList) // 列出全部版本 + 可配键
|
||||
p.POST("/prompts/version", h.PromptCreateVersion) // 建新版本(不自动激活)
|
||||
p.POST("/prompts/activate", middleware.Audit(db), h.PromptActivate) // 激活某版本 → 广播热更新(审计)
|
||||
p.POST("/prompts/deactivate", middleware.Audit(db), h.PromptDeactivate) // 撤销激活 → 回退代码默认(热,审计)
|
||||
p.GET("/kb/links", h.KbLinks) // 某库双链
|
||||
p.POST("/kb/note", middleware.RequireSpaceRole(db, store.RoleMember), h.KbSaveNote) // 新建/编辑笔记:viewer 拦下
|
||||
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
|
||||
p.GET("/agents", h.AgentList) // 当前空间的编排列表(共享工作区,含创建人)
|
||||
p.POST("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentSave) // 保存/更新编排:空间只读 viewer 拦下
|
||||
p.DELETE("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentDelete) // 删除编排:viewer 拦下(删他人另需 admin,见 handler)
|
||||
|
||||
// 共享工作区(Space):切换 / 列表 / 建 / 成员管理(增量3)
|
||||
p.GET("/me/spaces", h.SpacesList) // 活跃租户内我所属的空间(供切换)
|
||||
p.POST("/me/space", h.SwitchSpace) // 切换活跃空间
|
||||
p.GET("/spaces/current", h.SpaceCurrent) // 当前空间上下文 + 我的角色
|
||||
p.POST("/spaces", middleware.RequireTenantRole(db, store.RoleMember), h.SpaceCreate) // 建空间:租户只读 viewer 拦下
|
||||
p.GET("/spaces/:id/members", h.SpaceMembers) // 空间成员列表
|
||||
p.POST("/spaces/:id/members", h.SpaceAddMember) // 拉人进空间(handler 内校验空间 admin)
|
||||
p.PUT("/spaces/:id/members/:uid", h.SpaceSetMemberRole) // 改空间成员角色
|
||||
p.DELETE("/spaces/:id/members/:uid", h.SpaceRemoveMember) // 移除空间成员
|
||||
p.POST("/spaces/:id/archive", h.SpaceArchive) // 归档空间
|
||||
p.GET("/me/spaces", h.SpacesList) // 活跃租户内我所属的空间(供切换)
|
||||
p.POST("/me/space", h.SwitchSpace) // 切换活跃空间
|
||||
p.GET("/spaces/current", h.SpaceCurrent) // 当前空间上下文 + 我的角色
|
||||
p.POST("/spaces", middleware.RequireTenantRole(db, store.RoleMember), h.SpaceCreate) // 建空间:租户只读 viewer 拦下
|
||||
p.GET("/spaces/:id/members", h.SpaceMembers) // 空间成员列表
|
||||
p.POST("/spaces/:id/members", h.SpaceAddMember) // 拉人进空间(handler 内校验空间 admin)
|
||||
p.PUT("/spaces/:id/members/:uid", h.SpaceSetMemberRole) // 改空间成员角色
|
||||
p.DELETE("/spaces/:id/members/:uid", h.SpaceRemoveMember) // 移除空间成员
|
||||
p.POST("/spaces/:id/archive", h.SpaceArchive) // 归档空间
|
||||
p.POST("/reports", middleware.RequireTenantRole(db, store.RoleMember), h.GenerateReport) // 报告生成(同样烧租户积分):viewer 只读拦下
|
||||
p.GET("/billing", h.Billing)
|
||||
// 充值(P5.1 兑换码 + P5.2 微信 Native):动钱的 ≥member + 审计;查询全员可看。
|
||||
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/:id", h.BillingOrderStatus) // 轮询单态(pending 时顺路主动查单确认)
|
||||
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.DELETE("/models/:id", h.DeleteModel)
|
||||
admin.POST("/models/test", h.TestModel)
|
||||
admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱 + 积分权重)
|
||||
admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价 + 积分权重
|
||||
admin.GET("/billing-config", h.BillingConfig) // 全局计费规则(token→积分汇率 + 硬拦截开关)
|
||||
admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱 + 积分权重)
|
||||
admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价 + 积分权重
|
||||
admin.GET("/billing-config", h.BillingConfig) // 全局计费规则(token→积分汇率 + 硬拦截开关)
|
||||
admin.PUT("/billing-config", h.SaveBillingConfig)
|
||||
admin.POST("/credits/grant", h.GrantCredits) // 给租户充值/发放积分
|
||||
// 支付配置面(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.GET("/payment/wechat", h.AdminGetWechatPay)
|
||||
admin.PUT("/payment/wechat", h.AdminSaveWechatPay)
|
||||
admin.GET("/orders", h.AdminOrders) // 全平台充值订单流 + 状态计数
|
||||
admin.GET("/orders/reconcile", h.AdminReconcile) // 日终对账:paid 单 ↔ 账本 grant
|
||||
admin.GET("/sub-plans", h.AdminSubPlans) // 订阅套餐(含下架)
|
||||
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.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额)
|
||||
admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner)
|
||||
admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额)
|
||||
admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner)
|
||||
admin.PUT("/tenants/:id/shared-billing", h.AdminSetSharedBilling) // 共享计费开关
|
||||
admin.PUT("/tenants/:id/plan", h.AdminSetTenantPlan) // 方案等级
|
||||
admin.PUT("/tenants/:id/status", h.AdminSetTenantStatus) // 租户状态
|
||||
|
||||
admin.GET("/tenants/:id/members", h.AdminMembers) // 成员列表
|
||||
admin.POST("/tenants/:id/members", h.AdminAddMember) // 按邮箱加成员
|
||||
admin.PUT("/tenants/:id/members/:uid", h.AdminSetMemberRole) // 改角色
|
||||
admin.GET("/tenants/:id/members", h.AdminMembers) // 成员列表
|
||||
admin.POST("/tenants/:id/members", h.AdminAddMember) // 按邮箱加成员
|
||||
admin.PUT("/tenants/:id/members/:uid", h.AdminSetMemberRole) // 改角色
|
||||
admin.DELETE("/tenants/:id/members/:uid", h.AdminRemoveMember) // 移除成员
|
||||
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
|
||||
admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康
|
||||
admin.GET("/tasks", h.AdminTasks) // 全平台任务/运行观测(状态/租户筛 + HITL 待审批)
|
||||
admin.GET("/spaces", h.AdminSpaces) // 全平台空间观测(跨租户 Space + 成员数)
|
||||
admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额
|
||||
admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据)
|
||||
admin.GET("/datasources", h.AdminDatasources) // 数据源清单:全平台知识库 + 文档数(真数据)
|
||||
admin.POST("/kb/search", h.AdminKbSearch) // 检索试验台:按完整作用域键跨租户检索(支持单路 mode 对比)
|
||||
admin.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性)
|
||||
admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页)
|
||||
admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页)
|
||||
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
|
||||
admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康
|
||||
admin.GET("/tasks", h.AdminTasks) // 全平台任务/运行观测(状态/租户筛 + HITL 待审批)
|
||||
admin.GET("/tasks/:id", h.AdminTaskDetail) // 任务下钻:DSL/输出/轨迹/评测(跨租户,走 WithoutTenant)
|
||||
admin.GET("/spaces", h.AdminSpaces) // 全平台空间观测(跨租户 Space + 成员数)
|
||||
admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额
|
||||
admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据)
|
||||
admin.GET("/datasources", h.AdminDatasources) // 数据源清单:全平台知识库 + 文档数(真数据)
|
||||
admin.POST("/kb/search", h.AdminKbSearch) // 检索试验台:按完整作用域键跨租户检索(支持单路 mode 对比)
|
||||
admin.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性)
|
||||
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": "接口不存在"})
|
||||
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 _, statErr := fs.Stat(adminDist, rel); statErr == nil {
|
||||
adminServer.ServeHTTP(c.Writer, c.Request) // /assets/* 等真实文件
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AppendAudit 追加一条审计留痕(best-effort:审计失败不应影响主流程,调用方忽略返回)。
|
||||
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
|
||||
}
|
||||
|
||||
// ListAudit 倒序列出审计留痕(管理端审计流;limit 限流、offset 翻页)。
|
||||
func (p *Postgres) ListAudit(ctx context.Context, limit, offset int) ([]AuditLog, error) {
|
||||
// AuditFilter 是审计流的服务端筛选条件。筛选必须落到 SQL:审计的用途是查证,
|
||||
// 若只在“当前页”里筛,搜不到就等于给出“没有这条记录”的错误结论。
|
||||
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 {
|
||||
return nil, errStoreDisabled
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func applyUsageCredit(tx *gorm.DB, tenantID, taskID string, creditsMicro int64)
|
||||
return err
|
||||
}
|
||||
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 在事务内累加 租户/天 聚合。
|
||||
@@ -165,6 +165,6 @@ func (p *Postgres) GrantCredits(ctx context.Context, tenantID, kind string, cred
|
||||
return err
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ type User struct {
|
||||
BaseModel
|
||||
Email string `gorm:"uniqueIndex;size:255"`
|
||||
Name string `gorm:"size:64"`
|
||||
PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON
|
||||
ActiveTenantID string `gorm:"size:64" json:"-"` // 当前活跃租户(多租户切换;空=用默认)
|
||||
ActiveSpaceID string `gorm:"size:64" json:"-"` // 当前活跃工作区(Space)(增量3;空/失效=用活跃租户的个人空间)
|
||||
PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON
|
||||
ActiveTenantID string `gorm:"size:64" json:"-"` // 当前活跃租户(多租户切换;空=用默认)
|
||||
ActiveSpaceID string `gorm:"size:64" json:"-"` // 当前活跃工作区(Space)(增量3;空/失效=用活跃租户的个人空间)
|
||||
}
|
||||
|
||||
// Task 是一次提交的 Agent 编排任务(DSL)。
|
||||
@@ -35,8 +35,8 @@ func (Task) isTenantScoped() {}
|
||||
// Eval 是一次任务的自动化评测结果(dispatcher 评完经 NATS 回写,每任务一条,按 task_id upsert)。
|
||||
type Eval struct {
|
||||
BaseModel
|
||||
TenantID string `gorm:"size:64;index"` // 多租户作用域(SaveEval 从对应 task 复制)
|
||||
Owner string `gorm:"size:64;index"` // 提交者 user.id(从对应 task 复制)
|
||||
TenantID string `gorm:"size:64;index"` // 多租户作用域(SaveEval 从对应 task 复制)
|
||||
Owner string `gorm:"size:64;index"` // 提交者 user.id(从对应 task 复制)
|
||||
TaskID string `gorm:"uniqueIndex;size:64"`
|
||||
Overall float64 // 综合分 [0,1]
|
||||
Rule float64 // 规则分
|
||||
@@ -85,12 +85,14 @@ func (GuardrailEvent) TableName() string { return "sundynix_guardrail_event" }
|
||||
// Tenant 是多租户的计费/隔离单位(组织/账户)。个人用户 = 一个单人默认租户;团队/企业 = 多成员。
|
||||
type Tenant struct {
|
||||
BaseModel
|
||||
Name string `gorm:"size:128"`
|
||||
Slug string `gorm:"size:64;uniqueIndex"` // 唯一短标识(默认租户用 default-<uid>)
|
||||
Plan string `gorm:"size:32;default:free"` // free / pro / enterprise
|
||||
Status string `gorm:"size:16;default:active"` // active / suspended
|
||||
CreditBalanceMicro int64 `gorm:"column:credit_balance_micro"` // 物化积分余额 ×10⁻⁶(= credit_ledger 之和;用量扣、充值增)
|
||||
SharedBilling bool `gorm:"column:shared_billing"` // 共享计费:开=成员消耗计本租户池;关=成员计各自个人池(owner 恒计本租户)
|
||||
Name string `gorm:"size:128"`
|
||||
Slug string `gorm:"size:64;uniqueIndex"` // 唯一短标识(默认租户用 default-<uid>)
|
||||
Plan string `gorm:"size:32;default:free"` // free / pro / enterprise
|
||||
Status string `gorm:"size:16;default:active"` // active / suspended
|
||||
// 只给 default,**不加 not null**:存量库里这一列有历史 NULL 行,AutoMigrate 若尝试
|
||||
// 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" }
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -19,6 +20,12 @@ import (
|
||||
// - CreditPack / RedeemCode 是平台级配置与凭证,不属于任何租户。
|
||||
|
||||
// 订单状态机:pending → paid | failed | expired;paid →(人工)refunded。
|
||||
const (
|
||||
// 订单类型:一次性积分包 vs 订阅周期
|
||||
OrderKindPack = "pack"
|
||||
OrderKindSub = "sub"
|
||||
)
|
||||
|
||||
const (
|
||||
OrderPending = "pending"
|
||||
OrderPaid = "paid"
|
||||
@@ -49,9 +56,11 @@ func (CreditPack) TableName() string { return "sundynix_credit_pack" }
|
||||
// 兑换码入账也写一行(channel=redeem、amount_fen=0、即时 paid),全部充值一个查法。
|
||||
type PaymentOrder struct {
|
||||
BaseModel
|
||||
TenantID string `gorm:"size:64;index" json:"tenant_id"` // 计费租户(下单时解析并锁定)
|
||||
UserID string `gorm:"size:64;index" json:"user_id"` // 操作人(审计)
|
||||
PackID string `gorm:"size:24" json:"pack_id"` // redeem 渠道为空
|
||||
TenantID string `gorm:"size:64;index" json:"tenant_id"` // 计费租户(下单时解析并锁定)
|
||||
UserID string `gorm:"size:64;index" json:"user_id"` // 操作人(审计)
|
||||
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"`
|
||||
CreditsMicro int64 `gorm:"column:credits_micro" json:"credits_micro"`
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
order = o
|
||||
@@ -219,6 +228,7 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
|
||||
return false, errStoreDisabled
|
||||
}
|
||||
changed := false
|
||||
var paid *PaymentOrder
|
||||
err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error {
|
||||
now := time.Now()
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&CreditLedger{
|
||||
TenantID: o.TenantID, Kind: LedgerGrant, CreditsMicro: o.CreditsMicro, Ref: o.ID, Memo: "充值 " + o.Channel,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
|
||||
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", o.CreditsMicro)).Error; err != nil {
|
||||
return err
|
||||
paid = &o
|
||||
// 订阅单自身不带积分(积分由订阅按周期发放),跳过零额分录避免账本噪声。
|
||||
if o.CreditsMicro != 0 {
|
||||
if err := tx.Create(&CreditLedger{
|
||||
TenantID: o.TenantID, Kind: LedgerGrant, CreditsMicro: o.CreditsMicro, Ref: o.ID, Memo: "充值 " + o.Channel,
|
||||
}).Error; err != nil {
|
||||
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
|
||||
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 §5:admin 发起 → 订单置 refunded + 记 adjust 负分录 + 回退余额)。
|
||||
@@ -289,7 +315,7 @@ func (p *Postgres) RefundOrder(ctx context.Context, orderID, operatorUserID, mem
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
changed = true
|
||||
|
||||
@@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres {
|
||||
migrateLegacyIntIDs(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)
|
||||
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 {
|
||||
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(统一强制隔离)
|
||||
log.Println("[store] postgres connected & migrated (雪花 id + 软删 规约)")
|
||||
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
|
||||
}
|
||||
|
||||
// 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 取单条任务的完整下钻数据(管理端,跨租户)。
|
||||
// 必须 WithoutTenant:Task/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 服务时也能跑
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// newTestStore 起一个内存 sqlite,迁移同款模型 + 建那道支付幂等兜底的部分唯一索引 +
|
||||
@@ -15,7 +16,12 @@ import (
|
||||
func newTestStore(t *testing.T) *Postgres {
|
||||
t.Helper()
|
||||
// 静音 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 {
|
||||
t.Fatalf("打开内存 sqlite 失败: %v", err)
|
||||
}
|
||||
@@ -28,6 +34,7 @@ func newTestStore(t *testing.T) *Postgres {
|
||||
if err := db.AutoMigrate(
|
||||
&User{}, &Tenant{}, &TenantMember{}, &CreditLedger{}, &PaymentOrder{},
|
||||
&RedeemCode{}, &CreditPack{}, &UsageEvent{}, &UsageRollup{}, &Setting{}, &Pricing{}, &LLMModel{},
|
||||
&AuditLog{}, &Task{}, &Eval{}, &SubscriptionPlan{}, &Subscription{},
|
||||
&KB{}, // 租户作用域模型,验证隔离插件
|
||||
); err != nil {
|
||||
t.Fatalf("AutoMigrate 失败: %v", err)
|
||||
|
||||
@@ -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)。
|
||||
FROM alpine:3.20
|
||||
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
|
||||
WORKDIR /data
|
||||
COPY --from=build /out/mcp-go /mcp-go
|
||||
ENTRYPOINT ["/mcp-go"]
|
||||
|
||||
@@ -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 {
|
||||
topK = int(v)
|
||||
}
|
||||
if !g.rag.Ready() {
|
||||
return &contract.ToolResult{OK: true, Content: "[]"}
|
||||
}
|
||||
// 这里**不能**用 g.rag.Ready() 当总闸。Ready() 要求 embedding 已配置 + Milvus 已连,
|
||||
// 但全文(bleve)与图谱(Neo4j)两路根本不依赖它们;一刀切返回 "[]" 会让"embedding 没配好"
|
||||
// 表现为"整个知识库搜不到东西",且没有任何错误信息。改为让 searchPaths 逐路判定,
|
||||
// 各路自己上报 disabled/error(见 rag.RouteDiag)。
|
||||
// mode 空=生产混合检索(含 rerank);显式 vector/fulltext/graph/hybrid=评测用单路/纯融合(不 rerank)。
|
||||
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 err error
|
||||
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 时被总闸短路了(返回空数组而非逐路诊断)")
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ import (
|
||||
// 落盘不可用时退回内存索引(功能在、重启会丢),好过全文路全哑。
|
||||
type bleveStore struct {
|
||||
idx bleve.Index
|
||||
// persistent=false 表示退了内存兜底:功能在但重启即清零。这是**静默降级**——
|
||||
// 混合检索只会少一路召回、不报错,所以必须上报出去(health → 控制台),否则没人发现。
|
||||
persistent bool
|
||||
}
|
||||
|
||||
// bleveMapping:text 字段用 cjk 分词器(bigram,能切中文,否则默认标准分词器把整段中文当一个
|
||||
@@ -62,7 +65,7 @@ func openBleve() *bleveStore {
|
||||
return memBleve()
|
||||
}
|
||||
log.Printf("[rag] bleve 全文索引落盘就绪: %s", path)
|
||||
return &bleveStore{idx: idx}
|
||||
return &bleveStore{idx: idx, persistent: true}
|
||||
}
|
||||
|
||||
// memBleve 内存兜底:落盘不可用时退回内存索引(功能在、重启会丢),好过全文路全哑。
|
||||
@@ -123,9 +126,10 @@ func (b *bleveStore) deleteDoc(kb, doc string) error {
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
mq := bleve.NewMatchQuery(q)
|
||||
mq.SetField("text")
|
||||
@@ -140,7 +144,7 @@ func (b *bleveStore) search(kb, q string, topK int) []Hit {
|
||||
req.Fields = []string{"text"}
|
||||
res, err := b.idx.Search(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
var hits []Hit
|
||||
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)})
|
||||
}
|
||||
}
|
||||
return hits
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
func fnvHash(s string) uint64 {
|
||||
|
||||
@@ -18,7 +18,10 @@ func TestBleve_ChineseSearch(t *testing.T) {
|
||||
}); err != nil {
|
||||
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 {
|
||||
t.Fatal("中文全文检索应命中(CJK 分词),got 0 —— 分词器回归了?")
|
||||
}
|
||||
@@ -56,7 +59,7 @@ func TestBleve_PersistsAcrossReopen(t *testing.T) {
|
||||
|
||||
b2 := openBleve() // 模拟重启:同路径重开
|
||||
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 —— 退回内存了?")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,得 %q(hits=%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 里点明(重启即清零),否则运维看不出这是降级态")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,9 +104,10 @@ func (g *graphStore) deleteByFile(ctx context.Context, kb, fileID string) error
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
// 匹配两路:① 查询整体含实体名($q CONTAINS);② 查询的字符 n-gram 与实体名互为子串
|
||||
// —— 解决"查询说'星云一号'、实体名抽成'星云一号卫星'"这类后缀错配(纯 $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},
|
||||
neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j"))
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
var hits []Hit
|
||||
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")
|
||||
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,去重并截断)——用于和图谱实体名做双向子串匹配,
|
||||
|
||||
@@ -221,8 +221,9 @@ func (m *milvusStore) search(ctx context.Context, kb string, qvec []float32, top
|
||||
results, err = do()
|
||||
}
|
||||
if err != nil {
|
||||
// 集合尚未就绪/无法重建 → 降级空结果(不阻断混合检索其它路)。
|
||||
return nil, nil
|
||||
// 如实上抛:调用方负责"不阻断其它路",但必须知道这一路是**失败**而非"没召回"。
|
||||
// 以前这里回 nil,nil,向量路挂掉与检索无果彻底无法区分。
|
||||
return nil, err
|
||||
}
|
||||
var hits []Hit
|
||||
for _, r := range results {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
)
|
||||
@@ -155,6 +156,9 @@ func (e *Engine) Status() map[string]bool {
|
||||
"milvus": e.mv != nil,
|
||||
"neo4j": e.graph.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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if !e.Ready() {
|
||||
return nil, nil
|
||||
}
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
fanout := topK * 3
|
||||
|
||||
vecHits, ftHits, graphHits := e.searchPaths(ctx, kb, query, fanout)
|
||||
vecHits, ftHits, graphHits, _ := e.searchPaths(ctx, kb, query, fanout)
|
||||
// RRF 融合(三路,按文本去重)
|
||||
cand := rrf([][]Hit{vecHits, ftHits, graphHits}, fanout)
|
||||
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
|
||||
}
|
||||
|
||||
// searchPaths 跑三路召回,返回各路命中(供混合融合与离线评测按单路对比)。
|
||||
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)
|
||||
// RouteDiag 是一路召回的诊断。存在的理由:三路里任何一路挂掉都**不会报错**,
|
||||
// 只表现为召回变差——"这一路没配置"、"这一路报错了"、"这一路确实没匹配"
|
||||
// 在结果上完全一样(都是空数组),运维无从分辨。检索试验台据此告诉人是哪一环坏了。
|
||||
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
|
||||
}
|
||||
|
||||
// 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,便于公平对比)。
|
||||
// mode: vector|fulltext|graph|hybrid(RRF)。
|
||||
func (e *Engine) SearchByMode(ctx context.Context, kb, query string, topK int, mode string) []Hit {
|
||||
if !e.Ready() || topK <= 0 {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
fanout := topK * 3
|
||||
vec, ft, graph := e.searchPaths(ctx, kb, query, fanout)
|
||||
vec, ft, graph, _ := e.searchPaths(ctx, kb, query, fanout)
|
||||
var hits []Hit
|
||||
switch mode {
|
||||
case "vector":
|
||||
|
||||
+37
-2
@@ -274,18 +274,53 @@ export async function billingPacks(): Promise<{ packs: Pack[]; channels: string[
|
||||
// createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付)
|
||||
// + expires_at(二维码有效期,由服务端 orderTTL 决定,前端只负责倒计时展示)。
|
||||
export async function createWechatOrder(
|
||||
packId: string,
|
||||
target: { packId: string } | { planId: 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(
|
||||
await fetch(`${GATEWAY}/api/v1/billing/orders`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...bearer() },
|
||||
body: JSON.stringify({ pack_id: packId }),
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
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 时服务端顺路主动查单,本地也能确认到账)。
|
||||
// warn 必须一并返回:服务端在「已付但金额与订单不符」时不入账、挂起人工核对,
|
||||
// 订单会一直停在 pending —— 丢掉 warn 的话用户付了钱、界面却只会一直转圈等下去。
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { Coins, ReceiptText, Ticket, QrCode } from "lucide-react";
|
||||
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";
|
||||
|
||||
// 用量与账单:余额 + 兑换码充值(P5.1) + 消耗趋势 + 最近消耗/充值。
|
||||
@@ -17,7 +17,10 @@ export function Usage() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [packs, setPacks] = useState<Pack[]>([]);
|
||||
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(() => {
|
||||
myUsage(days).then(setU).catch(() => {});
|
||||
@@ -28,6 +31,13 @@ export function Usage() {
|
||||
setWechatOn(r.channels.includes("wechat"));
|
||||
})
|
||||
.catch(() => {});
|
||||
subPlans().then(setPlans).catch(() => {});
|
||||
mySubscription()
|
||||
.then((r) => {
|
||||
setSub(r.subscription);
|
||||
setSubPlan(r.plan);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [days]);
|
||||
useEffect(load, [load, ctx?.tenant?.id]);
|
||||
|
||||
@@ -76,6 +86,55 @@ export function Usage() {
|
||||
{canTopup ? (
|
||||
<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 && (
|
||||
<div className="mb-4">
|
||||
<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 className="flex flex-wrap gap-2">
|
||||
{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">
|
||||
<div className="text-sm font-medium text-slate-200">{p.name}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500">
|
||||
@@ -188,10 +251,11 @@ export function Usage() {
|
||||
|
||||
{paying && (
|
||||
<PayDialog
|
||||
pack={paying}
|
||||
item={paying}
|
||||
onClose={(paid) => {
|
||||
const wasSub = paying.kind === "sub";
|
||||
setPaying(null);
|
||||
if (paid) toast.push("success", "支付成功,积分已入账");
|
||||
if (paid) toast.push("success", wasSub ? "订阅已开通,首笔积分已发放" : "支付成功,积分已入账");
|
||||
// 无论如何都刷一次:用户可能在关弹窗前一刻付款、状态刚落地还没被轮询看到。
|
||||
load();
|
||||
refresh();
|
||||
@@ -217,7 +281,17 @@ const POLL_MAX_MS = 15000;
|
||||
|
||||
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 [err, setErr] = useState("");
|
||||
const [warn, setWarn] = useState("");
|
||||
@@ -275,7 +349,7 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const o = await createWechatOrder(pack.id);
|
||||
const o = await createWechatOrder(item.kind === "sub" ? { planId: item.id } : { packId: item.id });
|
||||
if (!alive) return;
|
||||
orderRef.current = o.order_id;
|
||||
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);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pack.id]);
|
||||
}, [item.id]);
|
||||
|
||||
// 倒计时:到点本地先置过期,省一次「扫了个死码才知道」。服务端 TTL 仍是权威。
|
||||
useEffect(() => {
|
||||
@@ -308,7 +382,7 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
|
||||
}, [state]);
|
||||
|
||||
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">
|
||||
{err ? (
|
||||
<p className="text-xs text-danger">{err}</p>
|
||||
@@ -327,13 +401,13 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
{state === "paid"
|
||||
? "✅ 已支付,入账中…"
|
||||
: state === "expired"
|
||||
? "订单已过期,请关闭后重新下单"
|
||||
: `微信扫一扫支付,到账 ${fmtCredits(pack.credits_micro)} 积分`}
|
||||
: item.note}
|
||||
</div>
|
||||
{state === "waiting" && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user