Files
sundynix-agentix/sundynix-gateway/internal/handler/status_handler.go
T
Blizzard 07955ddf07 feat(auth): 微信扫码登录后端 —— 网页授权 + ticket 轮询
服务号「植趣 ZeeQ」已认证,走网页授权(snsapi_base,只拿 openid、用户无感),
不接管消息推送,副作用最小。

流程:PC 建 ticket → 二维码指向 /wx/mp?t= → 用户微信扫码 → 302 到微信授权页 →
回调 /api/v1/wx/mp/callback 用 code 换 openid → 找/建用户 → ticket 置 authorized →
PC 轮询 /wx/mp/poll 拿到 authorized → 签发 JWT。ticket 一次性消费防重放。

- 配置(appid/secret/base_url)后台可改,secret AES 加密入库,与微信支付同一套 secrets;
- ticket 存 Redis(短 TTL),无 Redis 时回退进程内内存(本地单实例可用,生产必须有 Redis);
- User 加 wechat_openid。**部分唯一索引**(WHERE openid <> '')而非普通唯一:
  存量邮箱用户该列是空串,普通唯一索引会让多个空串互撞、AutoMigrate 直接失败
  —— 与之前 NULL 余额同类的坑,这次提前避开。

单测覆盖:授权 URL 拼接(含 #wechat_redirect 锚点必须在末尾)、secret 加密往返、
建号/查号、空 openid 不误命中存量用户。微信 API 调用依赖公网回调,本地测不了,
留待部署后真机扫码。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:44:00 +08:00

228 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-shared/contract"
)
// statusItem 是一项依赖/服务的存活状态(基建灯、服务灯共用)。
type statusItem struct {
Name string `json:"name"`
Up bool `json:"up"`
Detail string `json:"detail,omitempty"`
Latency int `json:"latency_ms,omitempty"` // NATS 探针往返耗时(毫秒),本地检查项为 0
}
// toolInfo 是一个注册工具的元信息(透传各 MCP 服务 list_tools 的上报)。
type toolInfo struct {
Name string `json:"name"`
CN string `json:"cn"`
Desc string `json:"desc"`
}
// toolGroup 是一台 MCP 服务的工具注册情况。
type toolGroup struct {
Server string `json:"server"`
Up bool `json:"up"`
Tools []toolInfo `json:"tools"`
}
// systemStatus 是「服务状态」面板的聚合视图:基建 / 应用服务 / MCP 工具注册。
type systemStatus struct {
CheckedAt string `json:"checked_at"`
Infra []statusItem `json:"infra"`
Services []statusItem `json:"services"`
Tools []toolGroup `json:"tools"`
}
// probeTimeout 是各探针的单次超时(无响应即判为下线)。
const probeTimeout = 2 * time.Second
// AdminStatus: GET /api/v1/admin/status —— 聚合基建、应用服务与 MCP 工具注册的实时状态,
// 供管理端「服务状态」一眼看出哪个服务没起、基建是否就绪、工具是否注册。
func (h *Handler) AdminStatus(c *gin.Context) {
parent := c.Request.Context()
// 各探针互不依赖,并发执行,整体只等最慢的一个。
var (
wg sync.WaitGroup
milvus, neo4j bool // mcp-go health
ftDisk bool // 全文索引是否落盘持久(false=退内存兜底,重启清零)
goUp bool // mcp-go 在线
goTools []toolInfo // mcp-go 注册工具
goLatency int // mcp-go 探针耗时
pyUp bool // mcp-py 在线
pyTools []toolInfo // mcp-py 注册工具
pyLatency int // mcp-py 探针耗时
dispUp bool // dispatcher 在线
dispDetail string // dispatcher 详情(模型/运行时长)
dispLatency int // dispatcher 探针耗时
pgUp, redisUp, minioUp bool // 基建活性探针(实时 ping,非仅启动标志)
)
wg.Add(5)
// 1) mcp-go health → milvus / neo4j 基建灯
go func() {
defer wg.Done()
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
if res, err := h.bus.CallTool(ctx, contract.ToolSubjectGo("health"),
&contract.ToolCall{Tool: "health"}); err == nil && res != nil && res.OK {
var sub map[string]bool
if json.Unmarshal([]byte(res.Content), &sub) == nil {
milvus, neo4j = sub["milvus"], sub["neo4j"]
ftDisk = sub["fulltext_disk"]
}
}
}()
// 2) mcp-go list_tools → 在线判定 + 工具清单
go func() {
defer wg.Done()
goUp, goTools, goLatency = h.probeTools(parent, contract.ToolSubjectGo("list_tools"))
}()
// 3) mcp-py list_tools → 在线判定 + 工具清单
go func() {
defer wg.Done()
pyUp, pyTools, pyLatency = h.probeTools(parent, contract.ToolSubjectPy("list_tools"))
}()
// 4) dispatcher 心跳 → 在线判定 + 模型/运行时长
go func() {
defer wg.Done()
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
start := time.Now()
if data, err := h.bus.Ping(ctx, contract.SubjectHealthDispatcher); err == nil {
dispUp = true
dispLatency = int(time.Since(start).Milliseconds())
var st struct {
Model string `json:"model"`
Ready bool `json:"ready"`
UptimeS int `json:"uptime_s"`
}
if json.Unmarshal(data, &st) == nil {
dispDetail = dispatcherDetail(st.Model, st.Ready, st.UptimeS)
}
}
}()
// 5) 基建活性探针:PG / Redis / MinIO 实时 ping(非仅启动标志,能反映中途掉线)。
go func() {
defer wg.Done()
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
pgUp = h.db.Ping(ctx)
redisUp = h.cache.Ping(ctx)
minioUp = h.blob != nil && h.blob.Ping(ctx)
}()
wg.Wait()
c.JSON(http.StatusOK, systemStatus{
CheckedAt: time.Now().Format(time.RFC3339),
Infra: []statusItem{
{Name: "postgres", Up: pgUp},
{Name: "redis", Up: redisUp},
{Name: "nats", Up: true}, // 网关连不上 NATS 即 fatal,能应答即在线
{Name: "milvus", Up: milvus},
{Name: "neo4j", Up: neo4j},
{Name: "minio", Up: minioUp}, // 对象存储(报告/KB 正文/blob126
// 全文索引:mcp-go 本地 bleve,是唯一不在 128 集中存储上的检索路,
// 也是唯一会"静默降级"的一路(退内存后重启清零,检索只是变差不报错)。
{Name: "全文索引", Up: goUp && ftDisk, Detail: fulltextDetail(goUp, ftDisk)},
},
Services: []statusItem{
{Name: "gateway", Up: true, Detail: "在线"},
{Name: "dispatcher", Up: dispUp, Detail: serviceDetail(dispUp, dispDetail), Latency: dispLatency},
{Name: "mcp-go", Up: goUp, Detail: toolsDetail(goUp, len(goTools)), Latency: goLatency},
{Name: "mcp-py", Up: pyUp, Detail: toolsDetail(pyUp, len(pyTools)), Latency: pyLatency},
},
Tools: []toolGroup{
{Server: "mcp-go", Up: goUp, Tools: goTools},
{Server: "mcp-py", Up: pyUp, Tools: pyTools},
},
})
}
// probeTools 调一台 MCP 服务的 list_tools:能应答即在线,并解析其工具清单(含中文名/作用)+ 往返耗时。
func (h *Handler) probeTools(parent context.Context, subject string) (up bool, tools []toolInfo, latency int) {
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
start := time.Now()
res, err := h.bus.CallTool(ctx, subject, &contract.ToolCall{Tool: "list_tools"})
if err != nil || res == nil || !res.OK {
return false, nil, 0
}
var payload struct {
Tools []toolInfo `json:"tools"`
}
_ = json.Unmarshal([]byte(res.Content), &payload)
return true, payload.Tools, int(time.Since(start).Milliseconds())
}
func dispatcherDetail(model string, ready bool, uptimeS int) string {
d := "运行 " + humanDuration(uptimeS)
if model != "" {
d = "模型 " + model + " · " + d
}
if !ready {
d += "(模型未配置,降级桩)"
}
return d
}
func serviceDetail(up bool, detail string) string {
if !up {
return "无响应(未启动?)"
}
if detail == "" {
return "在线"
}
return detail
}
func toolsDetail(up bool, n int) string {
if !up {
return "无响应(未启动?)"
}
return fmt.Sprintf("%d 个工具", n)
}
// humanDuration 把秒数转人读(< 1h 显示分钟,否则小时+分钟)。
func humanDuration(s int) string {
if s < 60 {
return fmt.Sprintf("%ds", s)
}
m := s / 60
if m < 60 {
return fmt.Sprintf("%dm", m)
}
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 与挂载卷权限)"
}
}