diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 10c32af..fe056d0 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -314,6 +314,58 @@ export async function adminRefundOrder(id: string, memo: string): Promise<{ stat return { status: d.status ?? "", detail: d.detail }; } +// ---- 全平台任务/运行观测(管理端,来自 sundynix_task 跨租户)---- +export interface AdminTask { + task_id: string; + tenant_id: string; + tenant_name: string; + owner: string; + owner_email: string; + status: string; + detail: string; + topic: string; + at: string; // RFC3339 + eval_level: string; + eval_overall: number; +} + +// adminTasks 全平台任务流 + 状态计数。status 空=全部;tenant 空=全租户;含 HITL 待审批(status=waiting)。 +export async function adminTasks( + status = "", + tenant = "", + limit = 50, +): Promise<{ tasks: AdminTask[]; counts: Record }> { + const q = new URLSearchParams(); + if (status) q.set("status", status); + if (tenant) q.set("tenant", tenant); + q.set("limit", String(limit)); + const res = guard(await fetch(`${ADMIN}/tasks?${q.toString()}`, { headers: authHeaders() })); + const d = (await res.json().catch(() => ({}))) as { tasks?: AdminTask[]; counts?: Record; error?: string }; + if (!res.ok) throw new Error(d.error ?? `tasks failed: ${res.status}`); + return { tasks: d.tasks ?? [], counts: d.counts ?? {} }; +} + +// ---- 全平台空间(Space)观测(管理端,跨租户 sundynix_space)---- +export interface AdminSpace { + id: string; + tenant_id: string; + tenant_name: string; + name: string; + kind: string; // personal / project / tenant + creator: string; + creator_email: string; + archived: boolean; + members: number; + created_at: string; +} + +export async function adminSpaces(limit = 200): Promise { + const res = guard(await fetch(`${ADMIN}/spaces?limit=${limit}`, { headers: authHeaders() })); + const d = (await res.json().catch(() => ({}))) as { spaces?: AdminSpace[]; error?: string }; + if (!res.ok) throw new Error(d.error ?? `spaces failed: ${res.status}`); + return d.spaces ?? []; +} + // ---- 自动评测观测(真数据,来自 sundynix_eval)---- export interface EvalDay { day: string; // YYYYMMDD diff --git a/sundynix-admin/src/pages/SpacesPage.tsx b/sundynix-admin/src/pages/SpacesPage.tsx new file mode 100644 index 0000000..cc580b4 --- /dev/null +++ b/sundynix-admin/src/pages/SpacesPage.tsx @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { adminSpaces, type AdminSpace } from "../api"; + +// 全平台空间(Space)观测:跨租户看所有空间(类型/租户/建者/成员数/归档态)。 +// Space 是租户与成员之间的中间容器(Tenant > Space > 成员),数据来自 sundynix_space。 + +const KIND: Record = { + personal: { label: "个人", badge: "bg-gray-100 text-gray-500" }, + project: { label: "项目", badge: "bg-cyan-50 text-cyan-600" }, + tenant: { label: "全员", badge: "bg-violet-50 text-violet-600" }, +}; +const kind = (k: string) => KIND[k] ?? { label: k, badge: "bg-gray-100 text-gray-500" }; + +export function SpacesPage() { + const [spaces, setSpaces] = useState([]); + const [err, setErr] = useState(""); + const [loading, setLoading] = useState(false); + const [hideArchived, setHideArchived] = useState(true); + + const load = useCallback(() => { + setLoading(true); + adminSpaces(300) + .then((s) => { + setSpaces(s); + setErr(""); + }) + .catch((e) => setErr((e as Error).message)) + .finally(() => setLoading(false)); + }, []); + useEffect(load, [load]); + + const shown = useMemo(() => (hideArchived ? spaces.filter((s) => !s.archived) : spaces), [spaces, hideArchived]); + const stats = useMemo(() => { + const byKind: Record = {}; + let archived = 0; + for (const s of spaces) { + byKind[s.kind] = (byKind[s.kind] ?? 0) + 1; + if (s.archived) archived++; + } + return { total: spaces.length, byKind, archived }; + }, [spaces]); + + return ( +
+
+

全平台空间(Space)

+ 租户与成员之间的中间容器:Agent / 知识库按空间共享 +
+ + {/* 计数卡片 */} +
+ + + + +
+ +
+
+ {loading ? "加载中…" : `共 ${shown.length} 个空间`} +
+ + +
+
+ {err &&

{err}

} + +
+ + + + + + + + + + + + + + {shown.map((s) => ( + + + + + + + + + + ))} + {shown.length === 0 && !loading && ( + + + + )} + +
空间类型租户建者成员创建时间状态
{s.name} + {kind(s.kind).label} + {s.tenant_name || {s.tenant_id || "—"}}{s.creator_email || s.creator || "—"}{s.members}{new Date(s.created_at).toLocaleDateString("zh-CN")} + {s.archived ? 已归档 : 活跃} +
暂无空间
+
+
+
+ ); +} + +function Stat({ label, value, tone }: { label: string; value: number; tone: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/sundynix-admin/src/pages/StatusPage.tsx b/sundynix-admin/src/pages/StatusPage.tsx index 4c31e63..7904abb 100644 --- a/sundynix-admin/src/pages/StatusPage.tsx +++ b/sundynix-admin/src/pages/StatusPage.tsx @@ -15,6 +15,7 @@ const INFRA_META: Record nats: { role: "消息总线 · JetStream", port: "4222", icon: "bus" }, milvus: { role: "向量库", port: "19530", icon: "db" }, neo4j: { role: "图数据库", port: "7687", icon: "bus" }, + minio: { role: "对象存储 · 报告/正文/blob", port: "9000", icon: "db" }, }; function toolCategory(t: string): string { @@ -187,7 +188,7 @@ export function StatusPage() {
- +
{data.infra.map((s) => ( diff --git a/sundynix-admin/src/pages/TasksPage.tsx b/sundynix-admin/src/pages/TasksPage.tsx new file mode 100644 index 0000000..744e9bd --- /dev/null +++ b/sundynix-admin/src/pages/TasksPage.tsx @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useState } from "react"; +import { adminTasks, type AdminTask } from "../api"; + +// 全平台任务/运行观测:跨租户看所有任务(状态分布 + 列表 + 提交人/租户/评测)。 +// 含 HITL 待审批(筛 waiting)。数据来自 sundynix_task,后端 GET /admin/tasks。 + +const STATUS: Record = { + submitted: { label: "已提交", badge: "bg-gray-100 text-gray-500", dot: "bg-gray-400" }, + running: { label: "运行中", badge: "bg-cyan-50 text-cyan-600", dot: "bg-cyan-500" }, + done: { label: "完成", badge: "bg-emerald-50 text-emerald-600", dot: "bg-emerald-500" }, + failed: { label: "失败", badge: "bg-rose-50 text-rose-500", dot: "bg-rose-500" }, + timeout: { label: "超时", badge: "bg-amber-50 text-amber-600", dot: "bg-amber-500" }, + waiting: { label: "待审批", badge: "bg-yellow-50 text-yellow-700", dot: "bg-yellow-500" }, + rejected: { label: "已拒绝", badge: "bg-violet-50 text-violet-600", dot: "bg-violet-500" }, +}; +const st = (s: string) => STATUS[s] ?? { label: s, badge: "bg-gray-100 text-gray-500", dot: "bg-gray-400" }; + +// 筛选标签顺序(waiting 提前,运维最关心待审批 + 失败)。 +const FILTERS = ["", "waiting", "running", "failed", "timeout", "done", "rejected"] as const; + +const EVAL_LEVEL: Record = { + excellent: "text-emerald-600", + good: "text-emerald-500", + fair: "text-amber-600", + poor: "text-rose-500", +}; + +export function TasksPage() { + const [tasks, setTasks] = useState([]); + const [counts, setCounts] = useState>({}); + const [filter, setFilter] = useState(""); + const [err, setErr] = useState(""); + const [loading, setLoading] = useState(false); + + const load = useCallback(() => { + setLoading(true); + adminTasks(filter, "", 100) + .then((r) => { + setTasks(r.tasks); + setCounts(r.counts); + setErr(""); + }) + .catch((e) => setErr((e as Error).message)) + .finally(() => setLoading(false)); + }, [filter]); + useEffect(load, [load]); + + const total = Object.values(counts).reduce((a, b) => a + b, 0); + + return ( +
+
+

全平台任务 / 运行观测

+ 跨租户查看所有任务,含 HITL 待审批(筛「待审批」) +
+ + {/* 状态计数卡片 */} +
+ setFilter("")} /> + {FILTERS.filter((f) => f).map((f) => ( + setFilter(f)} /> + ))} +
+ +
+
+ {loading ? "加载中…" : `共 ${tasks.length} 条${filter ? `(${st(filter).label})` : ""}`} + +
+ {err &&

{err}

} + +
+ + + + + + + + + + + + + + {tasks.map((t) => ( + + + + + + + + + + ))} + {tasks.length === 0 && !loading && ( + + + + )} + +
时间任务 / 主题租户提交人状态评测详情
{new Date(t.at).toLocaleString("zh-CN")} +
{t.topic || {t.task_id}}
+ {t.topic && {t.task_id}} +
{t.tenant_name || {t.tenant_id || "—"}}{t.owner_email || t.owner || "—"} + + + {st(t.status).label} + + + {t.eval_level ? ( + + {t.eval_level} · {(t.eval_overall * 100).toFixed(0)} + + ) : ( + + )} + + {t.detail || "—"} +
暂无任务
+
+
+
+ ); +} + +function Stat({ + label, + value, + tone, + active, + onClick, +}: { + label: string; + value: number; + tone?: string; + active: boolean; + onClick: () => void; +}) { + const dot = tone ? st(tone).dot : "bg-gray-400"; + return ( + + ); +} diff --git a/sundynix-admin/src/routes.tsx b/sundynix-admin/src/routes.tsx index cd76ce5..8d9028d 100644 --- a/sundynix-admin/src/routes.tsx +++ b/sundynix-admin/src/routes.tsx @@ -9,11 +9,13 @@ const UsagePage = lazy(() => import("./pages/UsagePage").then((m) => ({ default: const ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage }))); const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage }))); const StatusPage = lazy(() => import("./pages/StatusPage").then((m) => ({ default: m.StatusPage }))); +const TasksPage = lazy(() => import("./pages/TasksPage").then((m) => ({ default: m.TasksPage }))); const EvalsPage = lazy(() => import("./pages/EvalsPage").then((m) => ({ default: m.EvalsPage }))); const TenantsPage = lazy(() => import("./pages/TenantsPage").then((m) => ({ default: m.TenantsPage }))); const GuardrailsPage = lazy(() => import("./pages/GuardrailsPage").then((m) => ({ default: m.GuardrailsPage }))); const PromptsPage = lazy(() => import("./pages/PromptsPage").then((m) => ({ default: m.PromptsPage }))); const AuditPage = lazy(() => import("./pages/AuditPage").then((m) => ({ default: m.AuditPage }))); +const SpacesPage = lazy(() => import("./pages/SpacesPage").then((m) => ({ default: m.SpacesPage }))); export interface RouteDef { path: string; @@ -66,6 +68,13 @@ export const routes: RouteDef[] = [ ready: true, element: , }, + { + path: "/tasks", + label: "任务观测", + group: "运维", + ready: true, + element: , + }, { path: "/evals", label: "自动评测", @@ -87,6 +96,13 @@ export const routes: RouteDef[] = [ ready: true, element: , }, + { + path: "/spaces", + label: "空间", + group: "平台", + ready: true, + element: , + }, { path: "/guardrails", label: "安全护栏", diff --git a/sundynix-gateway/internal/handler/billing_pay.go b/sundynix-gateway/internal/handler/billing_pay.go index 42715c2..fc5b73d 100644 --- a/sundynix-gateway/internal/handler/billing_pay.go +++ b/sundynix-gateway/internal/handler/billing_pay.go @@ -3,6 +3,7 @@ package handler import ( "context" "net/http" + "strconv" "strings" "time" @@ -305,6 +306,31 @@ func (h *Handler) AdminReconcile(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"diffs": rows, "ok": len(rows) == 0}) } +// AdminTasks: GET /api/v1/admin/tasks?status=&tenant=&limit= —— 全平台任务/运行观测。 +// 跨租户看所有任务(状态/租户/提交人/评测),含 HITL 待审批(status=waiting)。返回列表 + 状态计数。 +func (h *Handler) AdminTasks(c *gin.Context) { + ctx := c.Request.Context() + limit := 50 + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + rows := h.db.AllTasks(ctx, c.Query("status"), c.Query("tenant"), limit) + c.JSON(http.StatusOK, gin.H{"tasks": rows, "counts": h.db.TaskStatusCounts(ctx)}) +} + +// AdminSpaces: GET /api/v1/admin/spaces?limit= —— 全平台空间观测(跨租户)。 +func (h *Handler) AdminSpaces(c *gin.Context) { + limit := 200 + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + c.JSON(http.StatusOK, gin.H{"spaces": h.db.AllSpaces(c.Request.Context(), limit)}) +} + // AdminRefundOrder: POST /api/v1/admin/orders/:id/refund —— 人工退款(PAYMENT_DESIGN §5)。 // 只退 paid 单:订单置 refunded + 记 adjust 负分录 + 回退余额(幂等,可能扣成负余额)。 // 真渠道(微信)退款仅冲销本地积分与订单态,钱的原路退回由 admin 在微信商户后台线下操作 diff --git a/sundynix-gateway/internal/handler/status_handler.go b/sundynix-gateway/internal/handler/status_handler.go index 6c437a8..3e75ba6 100644 --- a/sundynix-gateway/internal/handler/status_handler.go +++ b/sundynix-gateway/internal/handler/status_handler.go @@ -65,9 +65,11 @@ func (h *Handler) AdminStatus(c *gin.Context) { dispUp bool // dispatcher 在线 dispDetail string // dispatcher 详情(模型/运行时长) dispLatency int // dispatcher 探针耗时 + + pgUp, redisUp, minioUp bool // 基建活性探针(实时 ping,非仅启动标志) ) - wg.Add(4) + wg.Add(5) // 1) mcp-go health → milvus / neo4j 基建灯 go func() { @@ -115,16 +117,27 @@ func (h *Handler) AdminStatus(c *gin.Context) { } }() + // 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: h.db.Enabled()}, - {Name: "redis", Up: h.cache.Enabled()}, + {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 正文/blob,126) }, Services: []statusItem{ {Name: "gateway", Up: true, Detail: "在线"}, diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 7d55ff5..2e1b7be 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -158,6 +158,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. admin.DELETE("/tenants/:id/members/:uid", h.AdminRemoveMember) // 移除成员 admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册 admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康 + admin.GET("/tasks", h.AdminTasks) // 全平台任务/运行观测(状态/租户筛 + HITL 待审批) + admin.GET("/spaces", h.AdminSpaces) // 全平台空间观测(跨租户 Space + 成员数) admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额 admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据) admin.GET("/datasources", h.AdminDatasources) // 数据源清单:全平台知识库 + 文档数(真数据) diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index 61e2661..cf4dac6 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -126,6 +126,18 @@ func migrateDocLinkToID(db *gorm.DB) { // Enabled 报告是否处于真实持久化模式。 func (p *Postgres) Enabled() bool { return p.db != nil } +// Ping 活性探测:底层连接池发一次 PingContext,验证 PG 此刻真的可达(非仅启动时连过)。 +func (p *Postgres) Ping(ctx context.Context) bool { + if p.db == nil { + return false + } + sqlDB, err := p.db.DB() + if err != nil { + return false + } + return sqlDB.PingContext(ctx) == nil +} + // SaveTask 持久化一次任务提交(best-effort:降级模式下静默跳过)。 func (p *Postgres) SaveTask(ctx context.Context, owner, id, graph string) error { if p.db == nil { diff --git a/sundynix-gateway/internal/store/redis.go b/sundynix-gateway/internal/store/redis.go index 6fc4d2a..51144aa 100644 --- a/sundynix-gateway/internal/store/redis.go +++ b/sundynix-gateway/internal/store/redis.go @@ -31,6 +31,14 @@ func OpenRedis(addr string) *Redis { // Enabled 报告是否处于真实限流模式。 func (r *Redis) Enabled() bool { return r.rdb != nil } +// Ping 活性探测:发一次 PING,验证 Redis 此刻真的可达(非仅启动时连过)。 +func (r *Redis) Ping(ctx context.Context) bool { + if r.rdb == nil { + return false + } + return r.rdb.Ping(ctx).Err() == nil +} + // Allow 滑动窗口计数限流:在 window 内对 key 累加,超过 limit 即拒绝。 // 降级模式(rdb==nil)始终放行。 func (r *Redis) Allow(ctx context.Context, key string, limit int64, window time.Duration) (bool, error) { diff --git a/sundynix-gateway/internal/store/space_query.go b/sundynix-gateway/internal/store/space_query.go new file mode 100644 index 0000000..8babf92 --- /dev/null +++ b/sundynix-gateway/internal/store/space_query.go @@ -0,0 +1,44 @@ +package store + +import ( + "context" + "time" +) + +// 全平台空间观测(管理端「空间管理」页)。系统级口径:跨租户看所有 Space, +// 走 WithoutTenant 旁路租户插件。 + +// AdminSpaceRow 是管理端空间一行:空间 + 租户名 + 建者邮箱 + 成员数。 +type AdminSpaceRow struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + TenantName string `json:"tenant_name"` + Name string `json:"name"` + Kind string `json:"kind"` // personal / project / tenant + Creator string `json:"creator"` + CreatorEmail string `json:"creator_email"` + Archived bool `json:"archived"` + Members int64 `json:"members"` + CreatedAt time.Time `json:"created_at"` +} + +// AllSpaces 全平台空间流(管理端观测;跨租户)。倒序,翻页。 +func (p *Postgres) AllSpaces(ctx context.Context, limit int) []AdminSpaceRow { + if p.db == nil { + return nil + } + if limit <= 0 || limit > 500 { + limit = 200 + } + ctx = WithoutTenant(ctx) // 系统级:看所有租户的空间 + var out []AdminSpaceRow + p.db.WithContext(ctx).Table("sundynix_space as s"). + Select("s.id, s.tenant_id, s.name, s.kind, s.creator, s.archived, s.created_at, " + + "coalesce(tn.name,'') as tenant_name, coalesce(u.email,'') as creator_email, " + + "(select count(*) from sundynix_space_member m where m.space_id = s.id and m.deleted_at is null) as members"). + Joins("left join sundynix_tenant tn on tn.id = s.tenant_id"). + Joins("left join sundynix_user u on u.id = s.creator"). + Where("s.deleted_at is null"). + Order("s.created_at desc").Limit(limit).Scan(&out) + return out +} diff --git a/sundynix-gateway/internal/store/task_query.go b/sundynix-gateway/internal/store/task_query.go new file mode 100644 index 0000000..1cf77f4 --- /dev/null +++ b/sundynix-gateway/internal/store/task_query.go @@ -0,0 +1,75 @@ +package store + +import ( + "context" + "time" +) + +// 全平台任务观测(管理端「运行观测」页)。系统级口径:跨租户看所有任务, +// 故走 WithoutTenant 旁路租户插件 + raw Table 查询(同 RecentRuns 手动控制过滤)。 + +// AdminTaskRow 是管理端任务流一行:任务 + 租户名 + 提交人邮箱 + 评测分级(LEFT JOIN)。 +type AdminTaskRow struct { + TaskID string `json:"task_id"` + TenantID string `json:"tenant_id"` + TenantName string `json:"tenant_name"` + Owner string `json:"owner"` + OwnerEmail string `json:"owner_email"` + Status string `json:"status"` + Detail string `json:"detail"` + Topic string `json:"topic"` + At time.Time `json:"at"` + EvalLevel string `json:"eval_level"` + EvalOverall float64 `json:"eval_overall"` +} + +// AllTasks 全平台任务流(管理端观测;可按状态/租户过滤)。倒序,翻页。 +// status 空=全部;tenantID 空=全租户。 +func (p *Postgres) AllTasks(ctx context.Context, status, tenantID string, limit int) []AdminTaskRow { + if p.db == nil { + return nil + } + if limit <= 0 || limit > 200 { + limit = 50 + } + ctx = WithoutTenant(ctx) // 系统级:看所有租户的任务 + q := 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, " + + "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.deleted_at is null") + if status != "" { + q = q.Where("t.status = ?", status) + } + if tenantID != "" { + q = q.Where("t.tenant_id = ?", tenantID) + } + var out []AdminTaskRow + q.Order("t.created_at desc").Limit(limit).Scan(&out) + return out +} + +// TaskStatusCounts 全平台任务按状态计数(观测页的筛选标签 + 分布)。 +func (p *Postgres) TaskStatusCounts(ctx context.Context) map[string]int64 { + out := map[string]int64{} + if p.db == nil { + return out + } + ctx = WithoutTenant(ctx) + var rows []struct { + Status string + N int64 + } + p.db.WithContext(ctx).Table("sundynix_task"). + Select("status, count(*) as n"). + Where("deleted_at is null"). + Group("status").Scan(&rows) + for _, r := range rows { + out[r.Status] = r.N + } + return out +} diff --git a/sundynix-shared/blob/minio.go b/sundynix-shared/blob/minio.go index aca3f0b..f37e173 100644 --- a/sundynix-shared/blob/minio.go +++ b/sundynix-shared/blob/minio.go @@ -46,9 +46,19 @@ func Open(endpoint, accessKey, secretKey, bucket string) *Store { return &Store{cli: cli, bucket: bucket} } -// Ready 报告对象存储是否可用。 +// Ready 报告对象存储是否可用(启动时连接状态)。 func (s *Store) Ready() bool { return s != nil && s.cli != nil } +// Ping 活性探测:向 MinIO 发一次 BucketExists,验证连接此刻真的可用(非仅启动时连过)。 +// 供服务状态页做实时健康灯。 +func (s *Store) Ping(ctx context.Context) bool { + if s == nil || s.cli == nil { + return false + } + _, err := s.cli.BucketExists(ctx, s.bucket) + return err == nil +} + // Put 写入一段文本到对象键 key。 func (s *Store) Put(ctx context.Context, key, content string) error { r := bytes.NewReader([]byte(content))