From c1420b5665429b63e10d063d002b8ece96cc6ea6 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 13:40:31 +0800 Subject: [PATCH 01/15] =?UTF-8?q?fix(rag):=20=E5=85=A8=E6=96=87=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E5=9C=A8=E5=AE=B9=E5=99=A8=E9=87=8C=E6=A0=B9=E6=9C=AC?= =?UTF-8?q?=E6=B2=A1=E6=8C=81=E4=B9=85=E5=8C=96=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E8=A1=A5=20/data=20=E5=8D=B7=20+=20BLEVE=5FPATH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排查「fulltext 路 0 命中」时发现的真问题,比原以为的分词器问题严重得多。 现状:mcp-go 容器**没有任何 volume**,也没设 BLEVE_PATH,于是索引落到相对 CWD 的 .data/bleve。而运行阶段没有 WORKDIR(CWD=/)、进程又是非 root(uid 10001), 两种结局都糟: - 在 / 下建不了目录 → openBleve 静默退回内存索引,重启即清零; - 就算建得了,也只是写进容器可写层,每次发版重建容器一样清零。 更糟的是它不报错:混合检索少一路只表现为召回变差,看不出故障。 改动: - Dockerfile:建 /data 并 chown 给 app,WORKDIR 设为 /data; - 两份 compose:BLEVE_PATH=/data/bleve + 挂持久卷(132 那份此前连 顶层 volumes 段都没有); - .env.example:写清默认相对路径相对的是**启动目录**——本地从仓库根起 和从 sundynix-mcp-go/ 起会写出两份互不相干的索引,排查时极易被坑。 附带澄清:中文分词本身没问题。直接开落盘索引验证过,mapping 里 text 字段 确实是 cjk,实测「星间链」命中 4 条、「海关监管」命中 104 条,BM25 打分正常。 a4852c3 那次修复是有效的,之前怀疑分词器的判断作废。 Co-Authored-By: Claude Opus 4.8 --- .env.example | 5 +++++ deploy/132-app/docker-compose.yml | 10 ++++++++++ docker-compose.prod.yml | 6 ++++++ sundynix-mcp-go/Dockerfile | 7 ++++++- 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index bd01177..bea2cfb 100644 --- a/.env.example +++ b/.env.example @@ -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。 diff --git a/deploy/132-app/docker-compose.yml b/deploy/132-app/docker-compose.yml index 9ea2444..185e36b 100644 --- a/deploy/132-app/docker-compose.yml +++ b/deploy/132-app/docker-compose.yml @@ -72,6 +72,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 +88,8 @@ services: # Docker 暴露给该服务(可逃逸);不需要代码执行工具可注释掉这两行。mcp-py 无对外端口。 volumes: - /var/run/docker.sock:/var/run/docker.sock + +# 全文索引持久化:随容器重建保留(向量在 Milvus、图谱在 Neo4j,都在 128; +# 只有 bleve 是跟着 mcp-go 走的本地索引,唯独它需要这个卷)。 +volumes: + mcp-go-data: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1edd8bf..a5659d1 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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: diff --git a/sundynix-mcp-go/Dockerfile b/sundynix-mcp-go/Dockerfile index 95b285a..f630075 100644 --- a/sundynix-mcp-go/Dockerfile +++ b/sundynix-mcp-go/Dockerfile @@ -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"] -- 2.52.0 From bfd0d74c34c4703a5372b9725b6c57c388b45dbd Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 13:44:48 +0800 Subject: [PATCH 02/15] =?UTF-8?q?feat(status):=20=E5=85=A8=E6=96=87?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E9=99=8D=E7=BA=A7=E4=B8=8A=E6=8A=A5=E5=88=B0?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=8F=B0=20=E2=80=94=E2=80=94=20=E8=AE=A9?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E9=99=8D=E7=BA=A7=E7=9C=8B=E5=BE=97=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一个提交修的是"卷没挂"这个实例,这个提交修的是"它坏了几周没人发现"这个 类别问题:全文路退内存兜底后,混合检索只表现为召回变差,不报错、无告警。 - mcp-go: bleveStore 记录 persistent;health 工具多报一个 fulltext_disk。 仍是 map[string]bool,gateway 现有反序列化不受影响(加非 bool 字段会让 整个 health 解析失败,连带 Milvus/Neo4j 健康灯一起黑)。 - gateway: 基建灯新增「全文索引」一项,退内存时给出后果与排查方向, 而不只是一盏灰灯。 - admin: 基建列表此前只渲染 name/up,detail 字段一直没露出——而降级原因 恰恰只存在于 detail 里。补上渲染(离线态用琥珀色),并给「全文索引」 补 INFRA_META(它不是独立服务,没有端口,落在容器卷上)。 Co-Authored-By: Claude Opus 4.8 --- sundynix-admin/src/pages/StatusPage.tsx | 10 +++++++++- .../internal/handler/status_handler.go | 18 ++++++++++++++++++ sundynix-mcp-go/internal/rag/bleve.go | 5 ++++- sundynix-mcp-go/internal/rag/rag.go | 3 +++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/sundynix-admin/src/pages/StatusPage.tsx b/sundynix-admin/src/pages/StatusPage.tsx index 34ff63e..ab9af84 100644 --- a/sundynix-admin/src/pages/StatusPage.tsx +++ b/sundynix-admin/src/pages/StatusPage.tsx @@ -17,6 +17,8 @@ const INFRA_META: Record 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 { @@ -313,9 +315,15 @@ export function StatusPage() { const meta = INFRA_META[item.name]; return (
-
+
{item.name} {meta?.role || "后端组件"} + {/* detail 是降级原因/后果的唯一载体(如"内存兜底·重启即清零"),必须露出来 */} + {item.detail && ( + + · {item.detail} + + )}
diff --git a/sundynix-gateway/internal/handler/status_handler.go b/sundynix-gateway/internal/handler/status_handler.go index 3e75ba6..8cc4a97 100644 --- a/sundynix-gateway/internal/handler/status_handler.go +++ b/sundynix-gateway/internal/handler/status_handler.go @@ -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 与挂载卷权限)" + } +} diff --git a/sundynix-mcp-go/internal/rag/bleve.go b/sundynix-mcp-go/internal/rag/bleve.go index b9a660f..a95c24c 100644 --- a/sundynix-mcp-go/internal/rag/bleve.go +++ b/sundynix-mcp-go/internal/rag/bleve.go @@ -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 内存兜底:落盘不可用时退回内存索引(功能在、重启会丢),好过全文路全哑。 diff --git a/sundynix-mcp-go/internal/rag/rag.go b/sundynix-mcp-go/internal/rag/rag.go index 1da1f40..c81b7f4 100644 --- a/sundynix-mcp-go/internal/rag/rag.go +++ b/sundynix-mcp-go/internal/rag/rag.go @@ -155,6 +155,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, } } -- 2.52.0 From 84463394d4817d0659c377bf04ca792b4899d418 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 13:51:59 +0800 Subject: [PATCH 03/15] =?UTF-8?q?fix(admin):=20=E5=AE=A1=E8=AE=A1=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E4=B8=8B=E6=B2=89=20SQL=20+=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=95=B0=E4=B8=8D=E5=86=8D=E5=86=99=E6=AD=BB=20+=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=88=A0=E9=99=A4=E5=8A=A0=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清单里那批小毛病,逐条复核后修(「审计详情列缺失」那条已不成立,早补上了)。 1. 审计筛选只在当前页生效 —— 影响最大的一条。分页是服务端的,筛选却在 前端对已取回的 50 条做,于是搜一个用户 ID 显示"无结果"时,后面几页 可能还有几百条。审计的用途就是查证,"搜不到"会被读成"没发生过"。 改为 action/path/q 三个条件全部落到 SQL,前端只管发条件(防抖 300ms)。 2. 服务状态页把 mcp-go/mcp-py 的工具数写死成 23/4 —— 增删工具后一直骗人, 且服务离线时照样显示,看不出工具其实一个都没注册上。改取实际上报值。 3. 模型删除一点即删,无任何确认。补二次确认,并对"正在使用中"的模型 单独说明后果(删掉会立刻打断线上对话/向量能力)。 审计筛选补了 4 组回归测试,两条是踩出来的坑: - q 的 OR 组必须带括号:gorm 以 AND 拼接各 Where,裸 OR 会让 action 条件被绕过(测试里用"同 IP 不同方法"两行钉死这个语义); - LIKE 必须显式写 ESCAPE '\':Postgres 默认拿反斜杠当转义符,SQLite 不写就没有转义符——原来的写法在单测里静默失效,搜 "100%" 命中 0 条。 顺带把 ILIKE 换成 LOWER()+LIKE,这段才能被内存库覆盖。 Co-Authored-By: Claude Opus 4.8 --- sundynix-admin/src/api.ts | 16 ++- .../src/components/ModelManager.tsx | 10 +- sundynix-admin/src/pages/AuditPage.tsx | 34 +++--- sundynix-admin/src/pages/StatusPage.tsx | 6 +- sundynix-gateway/internal/handler/admin.go | 9 +- sundynix-gateway/internal/store/audit.go | 44 ++++++- .../internal/store/audit_filter_test.go | 107 ++++++++++++++++++ .../internal/store/testdb_test.go | 1 + 8 files changed, 203 insertions(+), 24 deletions(-) create mode 100644 sundynix-gateway/internal/store/audit_filter_test.go diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 2e4cb36..9fa22f4 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -647,8 +647,20 @@ export interface GuardrailEventItem { at: string; } -export async function listAudit(limit = 50, offset = 0): Promise { - 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 { + 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 ?? []; } diff --git a/sundynix-admin/src/components/ModelManager.tsx b/sundynix-admin/src/components/ModelManager.tsx index dfd9b6c..a0401fe 100644 --- a/sundynix-admin/src/components/ModelManager.tsx +++ b/sundynix-admin/src/components/ModelManager.tsx @@ -108,7 +108,15 @@ export function ModelManager({ )}
diff --git a/sundynix-gateway/internal/handler/admin.go b/sundynix-gateway/internal/handler/admin.go index 2900fc2..c219ecf 100644 --- a/sundynix-gateway/internal/handler/admin.go +++ b/sundynix-gateway/internal/handler/admin.go @@ -30,7 +30,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 diff --git a/sundynix-gateway/internal/store/audit.go b/sundynix-gateway/internal/store/audit.go index c85766d..ee011a3 100644 --- a/sundynix-gateway/internal/store/audit.go +++ b/sundynix-gateway/internal/store/audit.go @@ -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 } diff --git a/sundynix-gateway/internal/store/audit_filter_test.go b/sundynix-gateway/internal/store/audit_filter_test.go new file mode 100644 index 0000000..2e7d279 --- /dev/null +++ b/sundynix-gateway/internal/store/audit_filter_test.go @@ -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) +} diff --git a/sundynix-gateway/internal/store/testdb_test.go b/sundynix-gateway/internal/store/testdb_test.go index 45fc5c8..6e56e40 100644 --- a/sundynix-gateway/internal/store/testdb_test.go +++ b/sundynix-gateway/internal/store/testdb_test.go @@ -28,6 +28,7 @@ func newTestStore(t *testing.T) *Postgres { if err := db.AutoMigrate( &User{}, &Tenant{}, &TenantMember{}, &CreditLedger{}, &PaymentOrder{}, &RedeemCode{}, &CreditPack{}, &UsageEvent{}, &UsageRollup{}, &Setting{}, &Pricing{}, &LLMModel{}, + &AuditLog{}, &KB{}, // 租户作用域模型,验证隔离插件 ); err != nil { t.Fatalf("AutoMigrate 失败: %v", err) -- 2.52.0 From e4ec74893f5d1cef15d34efe57a12c3a74642068 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 14:07:02 +0800 Subject: [PATCH 04/15] =?UTF-8?q?feat(admin):=20=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=E6=94=AF=E6=8C=81=E4=B8=8B=E9=92=BB=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E8=BD=A8=E8=BF=B9/=E8=BE=93=E5=87=BA/?= =?UTF-8?q?=E8=AF=84=E6=B5=8B/DSL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全平台任务页此前只能看列表,点进去什么都没有。现在点一行开抽屉,四个页签: 执行轨迹、最终输出、评测明细、提交时的 DSL。不含审批操作——审批是客户端 用户的行为(桌面端 ApprovalBar),管理端只做观测。 不复用用户面的 /tasks/:id/replay:Task/Eval 都在租户插件作用域内,用请求 ctx 查别的租户的任务不会报错,而是静默返回空输出/空轨迹,UI 上表现为"这任务没 产出",比报错难查得多。新增 admin 端点走 WithoutTenant。 数据取自 sundynix_task 收尾落库的 output/trace 列,不依赖 Redis 流(10min TTL)。 所以这是复盘视图,运行中的任务轨迹为空——UI 里明确写出来,免得被当成轨迹丢了。 修的两处与测试环境失真有关(写测试时暴露的): - 测试库没配 NamingStrategy,与 OpenPostgres 不一致:多数模型有显式 TableName() 碰巧对得上,但 Task 这类没有的会退化成 "tasks",导致写裸 SQL 的查询在测试里查无此表。现已对齐 sundynix_ 前缀 + 单数表名。 - graph 的 ::text 换成标准 cast(... as text):前者是 Postgres 专有, 换掉后这条查询才能被内存库覆盖。 新增 5 组测试,其中一组专门先证明"租户过滤在测试环境里确实开着"——否则 "跨租户能读到"的断言可能只是因为插件没装,属于假过。 Co-Authored-By: Claude Opus 4.8 --- sundynix-admin/src/api.ts | 28 +++ .../src/components/TaskDetailDrawer.tsx | 207 ++++++++++++++++++ sundynix-admin/src/pages/TasksPage.tsx | 16 +- .../internal/handler/billing_pay.go | 20 ++ sundynix-gateway/internal/router/router.go | 1 + .../internal/store/task_detail_test.go | 105 +++++++++ sundynix-gateway/internal/store/task_query.go | 57 +++++ .../internal/store/testdb_test.go | 10 +- 8 files changed, 440 insertions(+), 4 deletions(-) create mode 100644 sundynix-admin/src/components/TaskDetailDrawer.tsx create mode 100644 sundynix-gateway/internal/store/task_detail_test.go diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 9fa22f4..dc70ba2 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -361,6 +361,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 = "", diff --git a/sundynix-admin/src/components/TaskDetailDrawer.tsx b/sundynix-admin/src/components/TaskDetailDrawer.tsx new file mode 100644 index 0000000..30603c4 --- /dev/null +++ b/sundynix-admin/src/components/TaskDetailDrawer.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState } from "react"; +import { adminTaskDetail, type AdminTask, type AdminTaskDetail } from "../api"; + +// 任务下钻:点列表一行看这一次运行到底发生了什么 —— 轨迹、最终输出、评测明细。 +// +// 数据全部取自 sundynix_task 收尾时落库的列,不依赖 Redis 流(那个只有 10min TTL, +// 历史任务早没了)。所以这里看到的是"复盘"而非"实时",正在跑的任务轨迹会是空的—— +// 这一点在 UI 上要讲清楚,否则会被当成"轨迹丢了"。 +// +// 不含审批操作:审批是客户端用户的行为(桌面端 ApprovalBar),管理端只做观测。 + +type ExecEvent = { node?: string; type?: string; msg?: string; at?: string; [k: string]: unknown }; + +export function TaskDetailDrawer({ task, onClose }: { task: AdminTask; onClose: () => void }) { + const [d, setD] = useState(null); + const [exec, setExec] = useState([]); + 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]); + + const flags: string[] = (() => { + try { + return d?.eval?.flags ? (JSON.parse(d.eval.flags) as string[]) : []; + } catch { + return []; + } + })(); + + return ( +
+
e.stopPropagation()} + > + {/* 头部 */} +
+
+
+

{task.topic || "(无主题)"}

+

{task.task_id}

+
+ +
+
+ + + + + {task.detail && } +
+
+ + {/* 页签 */} +
+ {( + [ + ["trace", `执行轨迹${exec.length ? ` (${exec.length})` : ""}`], + ["output", "最终输出"], + ["eval", "评测"], + ["graph", "DSL"], + ] as const + ).map(([k, label]) => ( + + ))} +
+ + {/* 内容 */} +
+ {err &&

{err}

} + {!d && !err &&

加载中…

} + + {d && tab === "trace" && ( + exec.length === 0 ? ( + + ) : ( +
    + {exec.map((e, i) => ( +
  1. +
    + #{i + 1} + {e.node && {e.node}} + {e.type && {e.type}} + {e.at && {e.at}} +
    + {e.msg &&

    {e.msg}

    } + {!e.msg && !e.node && ( +
    {JSON.stringify(e)}
    + )} +
  2. + ))} +
+ ) + )} + + {d && tab === "output" && ( + d.output ? ( +
{d.output}
+ ) : ( + + ) + )} + + {d && tab === "eval" && ( + d.eval ? ( +
+
+ + + + +
+
+ + + +
+ {flags.length > 0 && ( +
+ {flags.map((f) => ( + + {f} + + ))} +
+ )} + {d.eval.reason && ( +

+ {d.eval.reason} +

+ )} +
+ ) : ( + + ) + )} + + {d && tab === "graph" && ( + d.graph ? ( +
+                {(() => {
+                  try {
+                    return JSON.stringify(JSON.parse(d.graph), null, 2);
+                  } catch {
+                    return d.graph; // 解析不了就原样显示,别把排查线索吞掉
+                  }
+                })()}
+              
+ ) : ( + + ) + )} +
+
+
+ ); +} + +function Row({ k, v, span }: { k: string; v: string; span?: boolean }) { + return ( +
+
{k}
+
{v}
+
+ ); +} + +function Score({ label, v, hint }: { label: string; v: number; hint?: string }) { + return ( +
+
{label}
+
{(v * 100).toFixed(0)}
+
+ ); +} + +function Empty({ text }: { text: string }) { + return

{text}

; +} diff --git a/sundynix-admin/src/pages/TasksPage.tsx b/sundynix-admin/src/pages/TasksPage.tsx index af69c02..7d2cabf 100644 --- a/sundynix-admin/src/pages/TasksPage.tsx +++ b/sundynix-admin/src/pages/TasksPage.tsx @@ -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([]); + const [drill, setDrill] = useState(null); // 正在下钻的任务 const [copiedId, setCopiedId] = useState(""); const [err, setErr] = useState(""); const [loading, setLoading] = useState(false); @@ -124,13 +126,21 @@ export function TasksPage() { {tasks.map((t) => ( - + setDrill(t)} + className="cursor-pointer border-b border-gray-50 align-top last:border-0 hover:bg-violet-50/40" + title="点击查看执行轨迹 / 输出 / 评测" + > {new Date(t.at).toLocaleString("zh-CN")}
{t.topic || {t.task_id.slice(0, 8)}…}
+ + {drill && setDrill(null)} />} ); } diff --git a/sundynix-gateway/internal/handler/billing_pay.go b/sundynix-gateway/internal/handler/billing_pay.go index c1a14fc..c57ed8c 100644 --- a/sundynix-gateway/internal/handler/billing_pay.go +++ b/sundynix-gateway/internal/handler/billing_pay.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "net/http" "strconv" "strings" @@ -332,6 +333,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 diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 74d0841..0fba8b2 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -162,6 +162,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. 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) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据) diff --git a/sundynix-gateway/internal/store/task_detail_test.go b/sundynix-gateway/internal/store/task_detail_test.go new file mode 100644 index 0000000..66a6cef --- /dev/null +++ b/sundynix-gateway/internal/store/task_detail_test.go @@ -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) + } +} diff --git a/sundynix-gateway/internal/store/task_query.go b/sundynix-gateway/internal/store/task_query.go index 1cf77f4..3bf63d5 100644 --- a/sundynix-gateway/internal/store/task_query.go +++ b/sundynix-gateway/internal/store/task_query.go @@ -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 +} diff --git a/sundynix-gateway/internal/store/testdb_test.go b/sundynix-gateway/internal/store/testdb_test.go index 6e56e40..1666eca 100644 --- a/sundynix-gateway/internal/store/testdb_test.go +++ b/sundynix-gateway/internal/store/testdb_test.go @@ -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,7 +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{}, + &AuditLog{}, &Task{}, &Eval{}, &KB{}, // 租户作用域模型,验证隔离插件 ); err != nil { t.Fatalf("AutoMigrate 失败: %v", err) -- 2.52.0 From 00ad6c8ac00321d8151b9e9d001b872449cd1f72 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 14:20:58 +0800 Subject: [PATCH 05/15] =?UTF-8?q?fix(admin):=20=E4=BB=BB=E5=8A=A1=E4=B8=8B?= =?UTF-8?q?=E9=92=BB=E6=8C=89=E7=9C=9F=E5=AE=9E=E8=BD=A8=E8=BF=B9=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=B8=B2=E6=9F=93=20+=20=E4=BF=AE=E8=AF=84=E6=B5=8B?= =?UTF-8?q?=E9=A1=B5=E7=99=BD=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker 起来后拿真库验证,上一版有两处只有真数据才暴露的问题: 1. 轨迹字段名猜错了。实际是 seq/ts/node/kind/phase/label/detail,我写的是 type/msg/at。更糟的是兜底判断用了 !e.msg && !e.node —— node 恰好存在, 兜底不触发,结果会渲染出一列几乎空白的行。现按真实字段渲染:label 作 标题、detail 作正文、kind 上色(system/model/tool/agent/plan/section/ render/approval 八种)、phase=error 整行标红、ts 转本地时间。 2. 评测页签点开直接白屏。库里 flags 存的是字符串 "null"(不是 "[]"), JSON.parse 出来是 null,flags.length 抛 TypeError 把整个控制台打崩。 `as string[]` 的类型断言骗过了 tsc,单测也覆盖不到——只有点开真数据的 页面才会炸。改为 Array.isArray 校验。 验证方式:本地起 gateway + 真 Postgres,浏览器里逐个页签点过(轨迹 15 条 真实事件、最终输出、评测 100/100/100、DSL 格式化),并在真库上确认跨租户 读取、jsonb ->> 取 topic、cast(jsonb as text) 取 graph 均正常。 Co-Authored-By: Claude Opus 4.8 --- .../src/components/TaskDetailDrawer.tsx | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/sundynix-admin/src/components/TaskDetailDrawer.tsx b/sundynix-admin/src/components/TaskDetailDrawer.tsx index 30603c4..9411f17 100644 --- a/sundynix-admin/src/components/TaskDetailDrawer.tsx +++ b/sundynix-admin/src/components/TaskDetailDrawer.tsx @@ -9,7 +9,38 @@ import { adminTaskDetail, type AdminTask, type AdminTaskDetail } from "../api"; // // 不含审批操作:审批是客户端用户的行为(桌面端 ApprovalBar),管理端只做观测。 -type ExecEvent = { node?: string; type?: string; msg?: string; at?: string; [k: string]: unknown }; +// 字段名以库里真实轨迹为准(不是猜的):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 = { + 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 = { + 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(null); @@ -31,9 +62,12 @@ export function TaskDetailDrawer({ task, onClose }: { task: AdminTask; onClose: }; }, [task.task_id]); + // flags 是库里存的 JSON 字符串,实测可能是 "null"(不是 "[]")——JSON.parse 出来是 null, + // 不校验就会在 flags.length 上抛 TypeError 把整个控制台白屏掉。必须确认真是数组。 const flags: string[] = (() => { try { - return d?.eval?.flags ? (JSON.parse(d.eval.flags) as string[]) : []; + 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 []; } @@ -104,15 +138,29 @@ export function TaskDetailDrawer({ task, onClose }: { task: AdminTask; onClose: ) : (
    {exec.map((e, i) => ( -
  1. +
  2. - #{i + 1} - {e.node && {e.node}} - {e.type && {e.type}} - {e.at && {e.at}} + #{e.seq ?? i + 1} + {e.label && {e.label}} + {e.kind && ( + + {e.kind} + + )} + {e.phase && e.phase !== "info" && ( + {e.phase} + )} + {hhmmss(e.ts)}
    - {e.msg &&

    {e.msg}

    } - {!e.msg && !e.node && ( + {e.node &&
    {e.node}
    } + {e.detail &&

    {e.detail}

    } + {/* 兜底:字段形状变了也别把线索吞掉(上一版就是猜错字段名,白渲染了一列空行) */} + {!e.label && !e.detail && (
    {JSON.stringify(e)}
    )}
  3. -- 2.52.0 From b26fe2140854d84480872cd21fd11b98a58c7ce6 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 15:10:03 +0800 Subject: [PATCH 06/15] =?UTF-8?q?fix(rag):=20=E6=A3=80=E7=B4=A2=E4=B8=89?= =?UTF-8?q?=E8=B7=AF=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98=E5=90=9E=E9=94=99?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E9=80=90=E8=B7=AF=E8=AF=8A=E6=96=AD=20?= =?UTF-8?q?+=20=E4=B8=80=E8=B7=AF=E6=8C=82=E4=B8=8D=E6=8B=96=E5=9E=AE?= =?UTF-8?q?=E5=85=A8=E9=83=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排查"向量路为什么是空的"花了半小时,因为空就是空,没有任何线索。这次把 整条检索链上的静默降级一次清掉。 真 bug(不只是可观测性): - kb_search 与 Search() 都拿 rag.Ready() 当总闸,而 Ready() 只代表"向量路 可用"(embedding + Milvus)。全文(bleve)与图谱(Neo4j)根本不依赖它们,却 被一并毙掉 → "模型配置没下发"表现为"整个知识库什么都搜不到",还不报错。 改为逐路判定,任一路可用就仍有召回。 不再吞错: - milvus.search 原先把 error 转成 nil,nil —— 检索失败与无召回彻底无法区分; - bleve.search / graph.search 出错直接回 nil,连日志都没有; - searchPaths 丢掉 embedding 的 error。 三处改为如实返回,错误统一打日志。 逐路诊断(RouteDiag):每路上报 ok/empty/disabled/error + 耗时 + 原因,经 kb_search 的 diag 参数(仅试验台传,生产调用返回值不变)→ gateway → 检索 试验台。界面上现在能直接看出"这一路没配置/报错了/确实没匹配",不必翻日志。 内存兜底索引也会在 note 里点明"重启即清零"。 测试:3 组,覆盖"无 embedding 时全文仍可召回"、三种空的区分、内存索引提示。 把总闸加回去验证过第一条确实会红——测试能抓到这个回归,不是摆设。 Co-Authored-By: Claude Opus 4.8 --- sundynix-admin/src/api.ts | 23 ++- .../src/components/RetrievalBench.tsx | 47 +++++- sundynix-gateway/internal/handler/kb.go | 20 ++- sundynix-mcp-go/internal/mcp/gateway.go | 19 ++- sundynix-mcp-go/internal/rag/bleve.go | 9 +- sundynix-mcp-go/internal/rag/cjk_test.go | 7 +- sundynix-mcp-go/internal/rag/degrade_test.go | 108 ++++++++++++++ sundynix-mcp-go/internal/rag/graph.go | 9 +- sundynix-mcp-go/internal/rag/milvus.go | 5 +- sundynix-mcp-go/internal/rag/rag.go | 136 +++++++++++++++--- 10 files changed, 341 insertions(+), 42 deletions(-) create mode 100644 sundynix-mcp-go/internal/rag/degrade_test.go diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index dc70ba2..1bed299 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -513,9 +513,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 { +// 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", @@ -523,9 +538,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[] }> { diff --git a/sundynix-admin/src/components/RetrievalBench.tsx b/sundynix-admin/src/components/RetrievalBench.tsx index 2bf3649..6379842 100644 --- a/sundynix-admin/src/components/RetrievalBench.tsx +++ b/sundynix-admin/src/components/RetrievalBench.tsx @@ -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>; +type Results = Partial>; + +// 每一路的诊断徽章。三种"空"必须分得清 —— 没配置 / 报错 / 确实没匹配, +// 以前它们在界面上长得一模一样(都是 0 条),排查时只能靠猜。 +const STATUS_TONE: Record = { + 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 = { 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 && (
    + {/* 各路健康状况:先回答"哪一路能用",再看"召回了什么" */} + {(() => { + const routes = res[""]?.routes ?? res["hybrid"]?.routes ?? []; + if (!routes.length) return null; + return ( +
    + 各路状态 + {routes.map((r) => { + const tone = STATUS_TONE[r.status] ?? STATUS_TONE.empty; + return ( + + {ROUTE_CN[r.name] ?? r.name} · {tone.label} + {r.hits}命中/{r.ms}ms + + ); + })} +
    + ); + })()} + {/* 非正常路的原因写全,别让人去翻日志 */} + {(res[""]?.routes ?? res["hybrid"]?.routes ?? []) + .filter((r) => r.status === "disabled" || r.status === "error") + .map((r) => ( +

    + {ROUTE_CN[r.name] ?? r.name}路{r.status === "error" ? "报错" : "未启用"}:{r.error || r.note} +

    + ))} {/* 生产链路:用户实际拿到的结果 */} 0 { args["topK"] = body.TopK } diff --git a/sundynix-mcp-go/internal/mcp/gateway.go b/sundynix-mcp-go/internal/mcp/gateway.go index f6854a5..b193507 100644 --- a/sundynix-mcp-go/internal/mcp/gateway.go +++ b/sundynix-mcp-go/internal/mcp/gateway.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 == "" { diff --git a/sundynix-mcp-go/internal/rag/bleve.go b/sundynix-mcp-go/internal/rag/bleve.go index a95c24c..ab8952c 100644 --- a/sundynix-mcp-go/internal/rag/bleve.go +++ b/sundynix-mcp-go/internal/rag/bleve.go @@ -126,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") @@ -143,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 { @@ -152,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 { diff --git a/sundynix-mcp-go/internal/rag/cjk_test.go b/sundynix-mcp-go/internal/rag/cjk_test.go index 783e295..8b046e1 100644 --- a/sundynix-mcp-go/internal/rag/cjk_test.go +++ b/sundynix-mcp-go/internal/rag/cjk_test.go @@ -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 —— 退回内存了?") } } diff --git a/sundynix-mcp-go/internal/rag/degrade_test.go b/sundynix-mcp-go/internal/rag/degrade_test.go new file mode 100644 index 0000000..3a152fa --- /dev/null +++ b/sundynix-mcp-go/internal/rag/degrade_test.go @@ -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 里点明(重启即清零),否则运维看不出这是降级态") + } + } +} diff --git a/sundynix-mcp-go/internal/rag/graph.go b/sundynix-mcp-go/internal/rag/graph.go index 28718ed..4a9cc2a 100644 --- a/sundynix-mcp-go/internal/rag/graph.go +++ b/sundynix-mcp-go/internal/rag/graph.go @@ -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,去重并截断)——用于和图谱实体名做双向子串匹配, diff --git a/sundynix-mcp-go/internal/rag/milvus.go b/sundynix-mcp-go/internal/rag/milvus.go index 70ba642..8b67c18 100644 --- a/sundynix-mcp-go/internal/rag/milvus.go +++ b/sundynix-mcp-go/internal/rag/milvus.go @@ -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 { diff --git a/sundynix-mcp-go/internal/rag/rag.go b/sundynix-mcp-go/internal/rag/rag.go index c81b7f4..37cb44c 100644 --- a/sundynix-mcp-go/internal/rag/rag.go +++ b/sundynix-mcp-go/internal/rag/rag.go @@ -10,6 +10,7 @@ import ( "os" "strconv" "sync" + "time" "github.com/sundynix/sundynix-shared/contract" ) @@ -233,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)) @@ -286,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": -- 2.52.0 From 7c8c7232456c66a15508f73aeb2e6fce334ccb89 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 15:24:49 +0800 Subject: [PATCH 07/15] =?UTF-8?q?fix(admin):=20=E8=AF=8A=E6=96=AD=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E6=94=BE=E9=94=99=E4=BA=86=E5=87=BD=E6=95=B0=20+=20?= =?UTF-8?q?=E8=A1=A5=20kb=5Fsearch=20=E5=BD=A2=E7=8A=B6=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一个提交里,诊断解析被塞进了用户面的 KbSearch,而真正传 diag 的 AdminKbSearch 还在用老代码 —— 于是试验台永远拿不到 routes。写法上是 字符串替换只改了文件里第一处匹配,而这两个 handler 的收尾代码一模一样。 用户面已还原(它不传 diag,拿到的本就是裸数组,多一层解析是错的)。 补 3 组 mcp 层测试钉死 kb_search 的两种返回形状: - 不带 diag(生产调用:dispatcher / Agent 工具链)→ 裸 JSON 数组,契约不变; - 带 diag → {hits, routes} 对象; - not ready 时不被总闸短路,仍逐路诊断。 这层是"改可观测性顺手改坏生产"的高风险位置:diag 分支若无条件生效,所有 调用方的解析都会静默失败。 真环境验证(本地 gateway + mcp-go + 真 PG/Milvus/bleve): vector empty 该知识库在 Milvus 中没有向量(未入库或集合被重建过) fulltext ok 15 命中 graph disabled Neo4j 未连接或未配置 以前这三行都只是"0 条"。 Co-Authored-By: Claude Opus 4.8 --- sundynix-gateway/internal/handler/kb.go | 33 +++---- .../internal/mcp/kb_search_diag_test.go | 94 +++++++++++++++++++ 2 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 sundynix-mcp-go/internal/mcp/kb_search_diag_test.go diff --git a/sundynix-gateway/internal/handler/kb.go b/sundynix-gateway/internal/handler/kb.go index ae22abb..2755ae5 100644 --- a/sundynix-gateway/internal/handler/kb.go +++ b/sundynix-gateway/internal/handler/kb.go @@ -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" ) @@ -548,18 +548,9 @@ func (h *Handler) KbSearch(c *gin.Context) { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": res.Error}) return } - 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 { - // 兼容老 mcp-go(未部署新版时仍回裸数组),别因为这个把试验台整个打挂。 - 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}) + var hits []map[string]any + _ = json.Unmarshal([]byte(res.Content), &hits) + c.JSON(http.StatusOK, gin.H{"hits": hits}) } // AdminKbSearch: POST /api/v1/admin/kb/search —— 管理端「检索试验台」:按**完整作用域键** @@ -602,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)。 diff --git a/sundynix-mcp-go/internal/mcp/kb_search_diag_test.go b/sundynix-mcp-go/internal/mcp/kb_search_diag_test.go new file mode 100644 index 0000000..24a17e6 --- /dev/null +++ b/sundynix-mcp-go/internal/mcp/kb_search_diag_test.go @@ -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 时被总闸短路了(返回空数组而非逐路诊断)") + } +} -- 2.52.0 From 518cddbd1cbe7dbbef1aaabdfa55dad101c87f10 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 15:29:04 +0800 Subject: [PATCH 08/15] =?UTF-8?q?feat(admin):=20=E6=A3=80=E7=B4=A2?= =?UTF-8?q?=E8=AF=95=E9=AA=8C=E5=8F=B0=E5=88=86=E8=B7=AF=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E8=AF=B4=E6=98=8E"=E4=B8=BA=E4=BB=80?= =?UTF-8?q?=E4=B9=88=E7=A9=BA"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前四张卡片一律写"这一路没召回",而人的视线落在卡片上、不在顶部状态行。 现在按该路诊断分别显示:未启用给出缺什么配置(琥珀)、报错给出错误原文(红)、 真没匹配才说没召回。 真环境验证(截图为证):向量="该知识库在 Milvus 中没有向量(未入库或集合被 重建过)"、图谱="未启用:Neo4j 未连接或未配置"、全文="索引里确实没有匹配内容"。 Co-Authored-By: Claude Opus 4.8 --- .../src/components/RetrievalBench.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/sundynix-admin/src/components/RetrievalBench.tsx b/sundynix-admin/src/components/RetrievalBench.tsx index 6379842..e0593e2 100644 --- a/sundynix-admin/src/components/RetrievalBench.tsx +++ b/sundynix-admin/src/components/RetrievalBench.tsx @@ -162,9 +162,11 @@ export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) { 分路诊断(分数体系不同,勿跨路比大小)
    - {ROUTES.map((r) => ( - - ))} + {ROUTES.map((r) => { + // 单路那次请求里,取它自己那一路的诊断(RRF 融合没有单独一路,故为空) + const diag = res[r.mode]?.routes?.find((d) => d.name === r.mode); + return ; + })}
    @@ -177,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 ?? []; @@ -198,7 +202,14 @@ function RouteCard({ {data?.err ? (

    {data.err}

    ) : hits.length === 0 ? ( -

    这一路没召回

    + // 空的原因比"空"本身重要:没配置 / 报错 / 确实没匹配,处理方式完全不同 +

    + {diag?.status === "disabled" + ? `未启用:${diag.note || "该路未配置"}` + : diag?.status === "error" + ? `报错:${diag.error}` + : diag?.note || "这一路没召回(索引里确实没有匹配内容)"} +

    ) : (
      {hits.map((h, i) => ( -- 2.52.0 From 466959d3c39d29a3f539aa3508c58a64933370d4 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 15:49:13 +0800 Subject: [PATCH 09/15] =?UTF-8?q?fix:=20=E9=9D=99=E9=BB=98=E5=90=9E?= =?UTF-8?q?=E9=94=99=E6=A8=AA=E6=89=AB=20=E2=80=94=E2=80=94=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=90=BD=E5=BA=93/=E9=85=8D=E7=BD=AE=E5=B9=BF?= =?UTF-8?q?=E6=92=AD/=E5=AE=A1=E8=AE=A1=E5=A4=B1=E8=B4=A5=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=97=A0=E5=A3=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把检索链路那次的做法推到同类位置。判据是"丢了这个 error 的代价",只改代价 高的,不动刻意的 fire-and-forget。 改(丢了就是数据永久丢失或行为不可解释): - task_handler: 任务**输出**与**执行轨迹**的收尾落库。这是唯一的持久副本 (Redis 流 10min TTL),失败则永远无法复盘,界面上只显示"这次运行没有 轨迹"。轨迹的 json.Marshal 失败分支同样是静默跳过,一并补上。 - admin.broadcastActive: 模型配置热更新广播。失败 = dispatcher/mcp-go 仍用 旧配置,症状是"控制台改了模型却不生效",而操作者这边一切正常——正是今天 排查半天的那类问题。 - middleware/audit: 审计留痕写入。不阻断主流程是对的(业务已完成),但静默 失败意味着敏感操作没有记录,且没人知道记录缺了,这是合规缺口。 不改(确认过是合理的): - dispatcher 的 PublishToken/PublishExec:往前端推流,丢一帧是 UI 瑕疵, 且按 token 打日志会刷屏; - 计量回写 PublishUsage:本来就检查 error 并打日志,钱的路径是干净的。 另修一处误导文案:任务下钻的轨迹空态原本把原因说死为"早于该功能上线", 现在"落库失败"也是已知原因,文案改为并列并指向日志里的 [task] 告警。 Co-Authored-By: Claude Opus 4.8 --- .../src/components/TaskDetailDrawer.tsx | 2 +- sundynix-gateway/internal/handler/admin.go | 8 ++++++-- .../internal/handler/task_handler.go | 16 ++++++++++++---- sundynix-gateway/internal/middleware/audit.go | 7 ++++++- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/sundynix-admin/src/components/TaskDetailDrawer.tsx b/sundynix-admin/src/components/TaskDetailDrawer.tsx index 9411f17..057182f 100644 --- a/sundynix-admin/src/components/TaskDetailDrawer.tsx +++ b/sundynix-admin/src/components/TaskDetailDrawer.tsx @@ -132,7 +132,7 @@ export function TaskDetailDrawer({ task, onClose }: { task: AdminTask; onClose: text={ task.status === "running" || task.status === "submitted" ? "任务还在跑,轨迹要等收尾才落库(这里是复盘视图,不是实时流)" - : "这次运行没有留下轨迹(可能早于轨迹落库功能上线)" + : "这次运行没有留下轨迹(早于轨迹落库功能上线,或收尾时落库失败——后者会在 gateway 日志里有 [task] 告警)" } /> ) : ( diff --git a/sundynix-gateway/internal/handler/admin.go b/sundynix-gateway/internal/handler/admin.go index c219ecf..52f3b8a 100644 --- a/sundynix-gateway/internal/handler/admin.go +++ b/sundynix-gateway/internal/handler/admin.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "log" "net/http" "strconv" "strings" @@ -382,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")) @@ -564,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) + } } } } diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go index b532b69..013eca3 100644 --- a/sundynix-gateway/internal/handler/task_handler.go +++ b/sundynix-gateway/internal/handler/task_handler.go @@ -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() diff --git a/sundynix-gateway/internal/middleware/audit.go b/sundynix-gateway/internal/middleware/audit.go index 8eab759..67f826f 100644 --- a/sundynix-gateway/internal/middleware/audit.go +++ b/sundynix-gateway/internal/middleware/audit.go @@ -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) + } } } -- 2.52.0 From 80d4aaf4bac526877c034c031ec9fce0ae4463cc Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 16:11:34 +0800 Subject: [PATCH 10/15] =?UTF-8?q?feat(billing):=20=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E5=88=B6=E5=90=8E=E7=AB=AF=20=E2=80=94=E2=80=94=20=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E8=B4=AD=E4=B9=B0=20+=20=E5=91=A8=E6=9C=9F=E5=8F=91?= =?UTF-8?q?=E6=94=BE=E7=A7=AF=E5=88=86=20+=20=E5=88=B0=E6=9C=9F=E5=A4=B1?= =?UTF-8?q?=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规格(按需求):用户扫码买一个订阅周期,有效期内每 N 天发一次积分,到期即失效, 不自动续费。N 与每次发放额度都在套餐里配,后台可改。 为什么不做自动续费:微信 Native 扫码支付没有代扣能力,真自动续费要走「委托代扣」 ——另一套产品与资质。与其假装有,不如把"到期即失效"这个语义做扎实。 两个决定,都写进了代码注释: - **发放语义是累加而非重置**。每次刷新写一条 grant 分录、余额累加。重置型 (月度配额清零)会让「余额 = SUM(ledger)」这条对账不变量变复杂,且有误清 用户自费积分的风险。 - **订阅开通放在 store.MarkOrderPaid 内**,而不是各调用方。回调与掉单补偿两条 路都经过它,放这一处才没人能漏掉;按 orderID 幂等,重复调用无害。 复用而非另造:订阅单与积分包单走同一条支付链路(下单/回调/查单/掉单补偿), 只是 kind=sub 且 credits_micro=0——积分不在付款时给,由订阅按周期发。 定时器每 10 分钟扫一轮,语义与幂等都在 store.TickSubscription 里,与手动触发 共用,不会两处漂移。停机期间欠下的发放会一次性补齐。 8 组测试。其中一条当场抓到真 bug:开通时原本无条件发一笔,同一订单重复开通 (回调重推/查单赛跑)会因序号自增绕过幂等索引,白送积分。改为开通也走与定时器 同一套排期判断——排期天然幂等。教训:幂等键要锚在业务时间轴上,不能靠自增序号。 Co-Authored-By: Claude Opus 4.8 --- .../internal/handler/billing_pay.go | 44 ++- .../internal/handler/subscription.go | 83 +++++ .../internal/handler/subscription_tick.go | 58 ++++ sundynix-gateway/internal/router/router.go | 146 +++++---- sundynix-gateway/internal/store/payment.go | 50 ++- sundynix-gateway/internal/store/pgsql.go | 2 +- .../internal/store/subscription.go | 299 ++++++++++++++++++ .../internal/store/subscription_test.go | 248 +++++++++++++++ .../internal/store/testdb_test.go | 2 +- 9 files changed, 836 insertions(+), 96 deletions(-) create mode 100644 sundynix-gateway/internal/handler/subscription.go create mode 100644 sundynix-gateway/internal/handler/subscription_tick.go create mode 100644 sundynix-gateway/internal/store/subscription.go create mode 100644 sundynix-gateway/internal/store/subscription_test.go diff --git a/sundynix-gateway/internal/handler/billing_pay.go b/sundynix-gateway/internal/handler/billing_pay.go index c57ed8c..0a8ecd2 100644 --- a/sundynix-gateway/internal/handler/billing_pay.go +++ b/sundynix-gateway/internal/handler/billing_pay.go @@ -39,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) @@ -61,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) diff --git a/sundynix-gateway/internal/handler/subscription.go b/sundynix-gateway/internal/handler/subscription.go new file mode 100644 index 0000000..636dc50 --- /dev/null +++ b/sundynix-gateway/internal/handler/subscription.go @@ -0,0 +1,83 @@ +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)}) +} + +// ---- 管理端 ---- + +// 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)}) +} diff --git a/sundynix-gateway/internal/handler/subscription_tick.go b/sundynix-gateway/internal/handler/subscription_tick.go new file mode 100644 index 0000000..cbe1edf --- /dev/null +++ b/sundynix-gateway/internal/handler/subscription_tick.go @@ -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)) + } +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 0fba8b2..ef24fd1 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -13,12 +13,12 @@ import ( "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 +28,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 +50,75 @@ 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.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 +136,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,32 +148,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("/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) // 护栏命中安全事件流(倒序,翻页) + 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) // 护栏命中安全事件流(倒序,翻页) } } diff --git a/sundynix-gateway/internal/store/payment.go b/sundynix-gateway/internal/store/payment.go index f373f93..e3054e3 100644 --- a/sundynix-gateway/internal/store/payment.go +++ b/sundynix-gateway/internal/store/payment.go @@ -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"` @@ -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("credit_balance_micro + ?", 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 负分录 + 回退余额)。 diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index cf4dac6..9687d4e 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -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{} } diff --git a/sundynix-gateway/internal/store/subscription.go b/sundynix-gateway/internal/store/subscription.go new file mode 100644 index 0000000..a231c15 --- /dev/null +++ b/sundynix-gateway/internal/store/subscription.go @@ -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 +} diff --git a/sundynix-gateway/internal/store/subscription_test.go b/sundynix-gateway/internal/store/subscription_test.go new file mode 100644 index 0000000..4a2fa59 --- /dev/null +++ b/sundynix-gateway/internal/store/subscription_test.go @@ -0,0 +1,248 @@ +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) + } +} diff --git a/sundynix-gateway/internal/store/testdb_test.go b/sundynix-gateway/internal/store/testdb_test.go index 1666eca..62c63c5 100644 --- a/sundynix-gateway/internal/store/testdb_test.go +++ b/sundynix-gateway/internal/store/testdb_test.go @@ -34,7 +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{}, + &AuditLog{}, &Task{}, &Eval{}, &SubscriptionPlan{}, &Subscription{}, &KB{}, // 租户作用域模型,验证隔离插件 ); err != nil { t.Fatalf("AutoMigrate 失败: %v", err) -- 2.52.0 From f8f73597237b1078c5e87b330e8e2857006363e8 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 16:28:32 +0800 Subject: [PATCH 11/15] =?UTF-8?q?feat(admin):=20=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=20+=20=E4=BF=AE=E3=80=8C=E4=BD=99?= =?UTF-8?q?=E9=A2=9D=E5=88=97=E4=B8=BA=20NULL=20=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E5=85=85=E5=80=BC=E6=B0=B8=E4=B8=8D=E5=88=B0=E8=B4=A6=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理端「支付 → 订阅」:套餐配置 + 全平台订阅观测。配置时直接算出「一个周期 发几次、合计多少积分」,时长不能被间隔整除时橙字提示到期前会有空档 —— 让人在 配的时候就看见后果,而不是上线后才发现只发了一次。 顺带修了个真 bug,是拿真库验订阅时撞出来的(本地 42 个租户里 11 个中招): credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL。而入账语句是 「余额 + N」—— SQL 里 NULL + N 仍是 NULL,于是这些租户**充值永远不到账**: 分录照写、余额不动、不报错。这条路径是充值/兑换码/退款/扣费/订阅发放共用的, 不是订阅引入的问题。 三处修: - 5 处余额增减一律改 coalesce(credit_balance_micro, 0),新写入自愈; - 启动迁移回填存量 NULL(按账本求和,让「余额 = SUM(ledger)」重新成立); - 模型只加 default:0,**刻意不加 not null** —— 存量库有 NULL 行,AutoMigrate 尝试 SET NOT NULL 会直接失败,而且它在回填之前跑,等于把部署搞挂。 回归测试先证明能失败(去掉 coalesce → 余额 0)再确认修复。第一版测试因为我给 模型加了 not null 而无法造出 NULL,恰好暴露了上面那个部署风险。 真库验证:回填后 42 个租户 0 个 NULL;那个"有分录但余额 NULL"的租户余额 2981 = 账本合计 2981.34。管理端页面显示真实订阅(已发放 3 次 = 首笔 + 补发 2 笔)。 Co-Authored-By: Claude Opus 4.8 --- sundynix-admin/src/api.ts | 53 ++++ sundynix-admin/src/pages/SubscriptionPage.tsx | 241 ++++++++++++++++++ sundynix-admin/src/routes.tsx | 9 + sundynix-gateway/internal/store/credit.go | 4 +- sundynix-gateway/internal/store/models.go | 24 +- sundynix-gateway/internal/store/payment.go | 6 +- sundynix-gateway/internal/store/pgsql.go | 10 + .../internal/store/subscription_test.go | 22 ++ 8 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 sundynix-admin/src/pages/SubscriptionPage.tsx diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 1bed299..c7466d9 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -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 { + 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 { + 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 { + 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 }> { diff --git a/sundynix-admin/src/pages/SubscriptionPage.tsx b/sundynix-admin/src/pages/SubscriptionPage.tsx new file mode 100644 index 0000000..36cc391 --- /dev/null +++ b/sundynix-admin/src/pages/SubscriptionPage.tsx @@ -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 ( +
      +
      +

      支付 · 订阅

      +

      + 订阅套餐定价与发放节奏 · 全平台订阅状态。手动购买制,到期即失效,不自动续费 +

      +
      + + +
      + ); +} + +function PlansBlock() { + const [rows, setRows] = useState([]); + 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 ( +
      +
      +

      订阅套餐

      + 积分按周期发放且累加,不清零用户已有积分 +
      + +
      + + + + + + +
      + + {/* 配置后果预览:省得上线后才发现节奏配错 */} + {previewTimes > 0 && ( +

      + 一个周期发 {previewTimes} 次,合计{" "} + {previewTotal.toLocaleString("zh-CN")} 积分 + {Number(duration) % Number(interval) !== 0 && ( + ({Number(duration)} 不能被 {Number(interval)} 整除,最后一次发放后到期前会有空档) + )} +

      + )} + {err &&

      {err}

      } + +
      + + + + + + + + + + + + + {rows.map((p) => ( + + + + + + + + + ))} + {rows.length === 0 && ( + + + + )} + +
      名称售价时长发放节奏周期总积分状态
      {p.name}¥{yuan(p.price_fen)}{p.duration_days} 天 + 每 {p.refill_interval_days} 天 · {credits(p.refill_credits_micro)} + + {credits(p.refill_credits_micro * times(p))} + ({times(p)} 次) + + +
      还没有订阅套餐
      +
      +
      + ); +} + +function SubsBlock() { + const [rows, setRows] = useState([]); + 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 ( +
      +
      +

      全平台订阅

      + 到期即失效,不自动续费;剩余天数少的排在前面 +
      + {err &&

      {err}

      } + + + + + + + + + + + + {rows.map((s) => { + const left = daysLeft(s.expires_at); + return ( + + + + + + + + ); + })} + {rows.length === 0 && ( + + + + )} + +
      租户套餐状态已发放到期
      {s.tenant_name || s.tenant_id}{s.plan_name || "—"} + + {s.status === "active" ? "生效中" : "已过期"} + + {s.refill_seq} 次 + {new Date(s.expires_at).toLocaleDateString("zh-CN")} + {s.status === "active" && ( + // 快到期的要显眼:到期即失效,没有自动续费兜底 + + 剩 {left} 天 + + )} +
      还没有人订阅
      +
      + ); +} diff --git a/sundynix-admin/src/routes.tsx b/sundynix-admin/src/routes.tsx index 8bcb26a..1b36aaf 100644 --- a/sundynix-admin/src/routes.tsx +++ b/sundynix-admin/src/routes.tsx @@ -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: , }, + { + path: "payment/subscription", + label: "订阅", + group: "运维", + parent: "支付", + ready: true, + element: , + }, { path: "payment/orders", label: "订单与对账", diff --git a/sundynix-gateway/internal/store/credit.go b/sundynix-gateway/internal/store/credit.go index 7432264..d11223a 100644 --- a/sundynix-gateway/internal/store/credit.go +++ b/sundynix-gateway/internal/store/credit.go @@ -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 }) } diff --git a/sundynix-gateway/internal/store/models.go b/sundynix-gateway/internal/store/models.go index 15d4817..39aaf10 100644 --- a/sundynix-gateway/internal/store/models.go +++ b/sundynix-gateway/internal/store/models.go @@ -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-) - 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-) + 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" } diff --git a/sundynix-gateway/internal/store/payment.go b/sundynix-gateway/internal/store/payment.go index e3054e3..8465672 100644 --- a/sundynix-gateway/internal/store/payment.go +++ b/sundynix-gateway/internal/store/payment.go @@ -188,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 @@ -253,7 +253,7 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string 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 } } @@ -315,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 diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index 9687d4e..2c31230 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -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} diff --git a/sundynix-gateway/internal/store/subscription_test.go b/sundynix-gateway/internal/store/subscription_test.go index 4a2fa59..5ada32e 100644 --- a/sundynix-gateway/internal/store/subscription_test.go +++ b/sundynix-gateway/internal/store/subscription_test.go @@ -246,3 +246,25 @@ func TestSubscription_ActivatedByOrderPayment(t *testing.T) { 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") +} -- 2.52.0 From caa2602131d4955c3286f94c074681680383612c Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 16:33:59 +0800 Subject: [PATCH 12/15] =?UTF-8?q?feat(web):=20=E7=94=A8=E6=88=B7=E9=9D=A2?= =?UTF-8?q?=E8=AE=A2=E9=98=85=20=E2=80=94=E2=80=94=20=E8=B4=AD=E4=B9=B0/?= =?UTF-8?q?=E7=BB=AD=E8=AE=A2=E5=85=A5=E5=8F=A3=20+=20=E5=88=B0=E6=9C=9F?= =?UTF-8?q?=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 账单页新增:当前订阅状态(套餐/发放节奏/已发放次数/到期倒计时)+ 可购套餐。 已有订阅时购买区标题自动变成「续订(在当前到期时间上顺延)」,与后端的顺延 语义一致,免得用户以为会新开一条。 到期倒计时 ≤7 天转琥珀、≤3 天转红:到期即失效且**没有任何扣款或续费通知**, 用户只能从这里看见,不显眼等于没有。 支付弹窗改为对「买什么」中立(PayItem:积分包 | 订阅),下单/轮询/超时/warn 处理全共用。给订阅复制一份弹窗的话,两边迟早漂移——之前修过的轮询问题就得修 两遍。 真环境验证:账单页显示真实订阅(已发放 3 次 = 首笔 + 补发 2 笔)、余额 3.0K (NULL 回填后的 2981)、套餐卡片算出「周期内共 3 次,合计 3.0K 积分」。 Co-Authored-By: Claude Opus 4.8 --- sundynix-web/src/api.ts | 39 ++++++++++- sundynix-web/src/pages/Usage.tsx | 108 +++++++++++++++++++++++++++---- 2 files changed, 134 insertions(+), 13 deletions(-) diff --git a/sundynix-web/src/api.ts b/sundynix-web/src/api.ts index 7b50adb..32d6039 100644 --- a/sundynix-web/src/api.ts +++ b/sundynix-web/src/api.ts @@ -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 { + 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 的话用户付了钱、界面却只会一直转圈等下去。 diff --git a/sundynix-web/src/pages/Usage.tsx b/sundynix-web/src/pages/Usage.tsx index a3db9d8..61ac463 100644 --- a/sundynix-web/src/pages/Usage.tsx +++ b/sundynix-web/src/pages/Usage.tsx @@ -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([]); const [wechatOn, setWechatOn] = useState(false); // 服务端配了商户号才亮 - const [paying, setPaying] = useState(null); // 正在扫码支付的包 + const [paying, setPaying] = useState(null); // 正在扫码支付的商品(积分包或订阅) + const [plans, setPlans] = useState([]); + const [sub, setSub] = useState(null); + const [subPlan, setSubPlan] = useState(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 ? (
      {/* 微信扫码:服务端配了商户号且有在售包才出现 */} + {/* 订阅:到期即失效、不自动续费,所以"还剩几天"必须显眼——用户不会收到扣款提醒 */} + {sub && subPlan && ( +
      +
      +
      + 订阅中 · {subPlan.name} +
      + +
      +
      + 每 {subPlan.refill_interval_days} 天发放{" "} + {fmtCredits(subPlan.refill_credits_micro)} 积分 · + 已发放 {sub.refill_seq} 次 · + 到期后不再发放,需重新购买 +
      +
      + )} + + {wechatOn && plans.length > 0 && canTopup && ( +
      +
      + {sub ? "续订(在当前到期时间上顺延)" : "订阅"} +
      +
      + {plans.map((pl) => { + const times = pl.refill_interval_days > 0 ? Math.floor(pl.duration_days / pl.refill_interval_days) : 0; + return ( + + ); + })} +
      +
      + )} + {wechatOn && packs.length > 0 && (
      @@ -83,7 +142,11 @@ export function Usage() {
      {packs.map((p) => ( -
      ); } + +// 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 ( + +
      +
      订阅中 · {plan.name}
      +
      + 每 {plan.refill_interval_days} 天发放 {credits(plan.refill_credits_micro)} 积分 · 已发放 {sub.refill_seq} 次 · + 到期后不再发放 +
      +
      +
      + + {new Date(sub.expires_at).toLocaleDateString("zh-CN")} 到期(剩 {left} 天) + + +
      +
      + ); +} -- 2.52.0 From b1fea23a0cec005c5a1c07a5a6fc2f8f318dbd92 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 16:59:58 +0800 Subject: [PATCH 14/15] =?UTF-8?q?feat(site):=20=E5=AE=98=E7=BD=91=E5=AE=9A?= =?UTF-8?q?=E4=BB=B7=E9=A1=B5=20=E2=80=94=E2=80=94=20=E5=A5=97=E9=A4=90?= =?UTF-8?q?=E7=94=B1=E5=90=8E=E5=8F=B0=E9=85=8D=E7=BD=AE=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=EF=BC=8C=E5=85=AC=E5=BC=80=E5=8F=AF=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 官网加「定价」菜单与页面。数据来自 GET /api/v1/pricing(**不挂鉴权**): 运营改价、上下架、调发放节奏都在管理端完成,官网跟着变,不用发版; 未登录就能看到价格,这是转化的前提,也是官网存在的意义。 接口只吐在售项与展示字段,成本、权重、租户信息一律不出去。 结账仍在 Web 面完成(那里有登录态、租户上下文与支付轮询),官网只负责 「看价 → 去买」。同一套支付流程不在两处各实现一遍——这也是本次订阅开发 一直遵循的那条线:积分包与订阅共用一个支付弹窗,桌面端不重复购买入口。 Co-Authored-By: Claude Opus 4.8 --- sundynix-admin/src/App.tsx | 2 + .../site/components/layout/site-header.tsx | 1 + sundynix-admin/src/site/pages/pricing.tsx | 151 ++++++++++++++++++ .../internal/handler/subscription.go | 12 ++ sundynix-gateway/internal/router/router.go | 1 + 5 files changed, 167 insertions(+) create mode 100644 sundynix-admin/src/site/pages/pricing.tsx diff --git a/sundynix-admin/src/App.tsx b/sundynix-admin/src/App.tsx index f47b0e6..bb2cb4c 100644 --- a/sundynix-admin/src/App.tsx +++ b/sundynix-admin/src/App.tsx @@ -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) */} }> } /> + } /> } /> } /> diff --git a/sundynix-admin/src/site/components/layout/site-header.tsx b/sundynix-admin/src/site/components/layout/site-header.tsx index dcaa2c5..38efef7 100644 --- a/sundynix-admin/src/site/components/layout/site-header.tsx +++ b/sundynix-admin/src/site/components/layout/site-header.tsx @@ -10,6 +10,7 @@ const NAV_ITEMS = [ { label: '产品', to: '/', end: true }, { label: '功能', to: '/#features' }, { label: '架构', to: '/#architecture' }, + { label: '定价', to: '/pricing' }, ] export function SiteHeader() { diff --git a/sundynix-admin/src/site/pages/pricing.tsx b/sundynix-admin/src/site/pages/pricing.tsx new file mode 100644 index 0000000..29cd909 --- /dev/null +++ b/sundynix-admin/src/site/pages/pricing.tsx @@ -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([]) + const [packs, setPacks] = useState([]) + 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 ( +
      +
      +

      PRICING

      +

      按用量计费,先付后用

      +

      + 所有消耗以「积分」结算。订阅按周期自动发放积分,也可以单次购买积分包。 + 没有隐藏费用,用不完的积分不清零。 +

      +
      + + {state === 'loading' &&

      正在获取价格…

      } + {state === 'error' && ( +

      + 价格暂时取不到,请稍后再试,或直接 + + 前往账单页 + + 。 +

      + )} + + {state === 'ready' && ( + <> + {plans.length > 0 && ( +
      +

      订阅

      +

      + 购买一个周期,期内按节奏发放积分。到期即结束,不会自动续费、不会自动扣款。 +

      +
      + {plans.map((p) => { + const times = p.refill_interval_days > 0 ? Math.floor(p.duration_days / p.refill_interval_days) : 0 + return ( +
      +

      {p.name}

      +
      + ¥{yuan(p.price_fen)} + / {p.duration_days} 天 +
      +
        +
      • + 每 {p.refill_interval_days} 天发放{' '} + {credits(p.refill_credits_micro)} 积分 +
      • +
      • + 周期内共 {times} 次,合计{' '} + {credits(p.refill_credits_micro * times)} 积分 +
      • +
      • 积分累加,用不完不清零
      • +
      + + 购买 + +
      + ) + })} +
      +
      + )} + + {packs.length > 0 && ( +
      +

      积分包

      +

      一次性购买,用完再买,适合用量不规律的场景。

      +
      + {packs.map((p) => ( +
      +

      {p.name}

      +
      ¥{yuan(p.price_fen)}
      +

      {credits(p.credits_micro)} 积分

      + + 购买 → + +
      + ))} +
      +
      + )} + + {plans.length === 0 && packs.length === 0 && ( +

      价格方案即将上线。

      + )} + +

      + 支付由微信支付提供。购买需要先登录;企业采购与私有化部署请通过页脚联系方式与我们联系。 +

      + + )} +
      + ) +} diff --git a/sundynix-gateway/internal/handler/subscription.go b/sundynix-gateway/internal/handler/subscription.go index 636dc50..c28cc45 100644 --- a/sundynix-gateway/internal/handler/subscription.go +++ b/sundynix-gateway/internal/handler/subscription.go @@ -36,6 +36,18 @@ func (h *Handler) MySubscription(c *gin.Context) { 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 —— 全部套餐(含下架)。 diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index ef24fd1..12fdef6 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -50,6 +50,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. api := r.Group("/api/v1") { // —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)—— + 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) -- 2.52.0 From a8a497b4cea0c41e27ca4ce7911978df985f5440 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 17:20:02 +0800 Subject: [PATCH 15/15] =?UTF-8?q?feat(gateway):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E5=9F=9F=E5=90=8D=E6=A0=A1=E9=AA=8C=E6=96=87?= =?UTF-8?q?=E4=BB=B6=EF=BC=88=E4=B8=BA=E7=BD=91=E9=A1=B5=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=93=BA=E8=B7=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 微信公众平台配置「JS接口安全域名 / 网页授权域名」时会下发 MP_verify_xxx.txt, 要求能从域名根目录直接访问。文件放宿主机 /home/workspace/wechat-verify, 只读挂进容器(不进镜像、不进 git —— 它随时可能重发,且属站点凭证类文件)。 未设 WECHAT_VERIFY_DIR 时这段完全不生效。 **第一版写成了独立路由 `/:mpfile`,直接把官网打挂**:它匹配所有单段路径, 于是 /pricing、/download 全变 404(实测确认)。改为并进 NoRoute 的 SPA 兜底里, 在"已排除 /api/ 与内嵌静态文件"之后、回退 index.html 之前处理。 文件名白名单:必须 MP_verify_ 前缀 + .txt 后缀、不含路径分隔符与 .., 否则落 SPA 兜底 —— 避免把挂载目录变成任意文件下载口子。 回归验证:/ /pricing /download /admin 均 200,校验文件取到正确内容, /passwd.txt 与路径穿越都只拿到 index.html。 Co-Authored-By: Claude Opus 4.8 --- deploy/132-app/docker-compose.yml | 5 +++++ sundynix-gateway/internal/router/router.go | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/deploy/132-app/docker-compose.yml b/deploy/132-app/docker-compose.yml index 185e36b..e9a9754 100644 --- a/deploy/132-app/docker-compose.yml +++ b/deploy/132-app/docker-compose.yml @@ -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 } diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 12fdef6..f858cf4 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "os" + "path/filepath" "strings" "github.com/gin-gonic/gin" @@ -195,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/* 等真实文件 -- 2.52.0