feat(monitor): admin channel monitor MVP with SSRF protection and batch aggregation
新增 admin「渠道监控」模块(参考 BingZi-233/check-cx),独立于现有 Channel 体系。
admin 配置 + 后台定时调用上游 LLM chat completions 健康检查 + 所有登录用户只读可见。
后端:
- ent: channel_monitor + channel_monitor_history(AES-256-GCM 加密 api_key)
- service 按职责拆分:service/aggregator/validate/checker/runner/ssrf
- provider strategy map 替代 switch(openai/anthropic/gemini)
- repository batch 聚合(ListLatestForMonitorIDs + ComputeAvailabilityForMonitors)消除 N+1
- runner: ticker(5s) + pond worker pool(5) + inFlight 防并发 + TrySubmit 防雪崩
+ 凌晨 3 点 cron 清理 30 天历史
- SSRF 防护:强制 https + 私网/loopback/云元数据 IP 拒绝(127/8、10/8、172.16/12、
192.168/16、169.254/16、100.64/10、::1、fc00::/7、fe80::/10)+ DialContext
在 socket 层防 DNS rebinding
- API key sanitize:擦除 url.Error 与上游响应 body 中的 sk-/sk-ant-/AIza/JWT 模式
- APIKeyDecryptFailed 标志位 + 单 monitor 路径检测,避免空 key 调用上游
handler:
- admin: CRUD + 手动触发 + 历史接口(api_key 脱敏)
- user: 只读列表 + 状态详情(去除 api_key/endpoint)
- ParseChannelMonitorID 共用 + dto.ChannelMonitorExtraModelStatus 共用
前端:
- 路由 /admin/channels/{pricing,monitor} + /monitor(用户只读)
- AppSidebar 父项 expandOnly 支持
- ChannelMonitorView 拆为 8 个子组件 + ChannelStatusView 拆出 detail dialog
- composables/useChannelMonitorFormat + constants/channelMonitor 共享
- i18n monitorCommon namespace 消除 admin/user 两 view 重复
合规:所有文件符合 CLAUDE.md(Go ≤ 500 行 / Vue ≤ 300 行 / 函数 ≤ 30 行)
CI: go build / gofmt / golangci-lint(0 issues) / make test-unit / pnpm build 全绿
This commit is contained in:
396
backend/internal/handler/admin/channel_monitor_handler.go
Normal file
396
backend/internal/handler/admin/channel_monitor_handler.go
Normal file
@@ -0,0 +1,396 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
// monitorMaxPageSize 列表分页上限。
|
||||
monitorMaxPageSize = 100
|
||||
// monitorAPIKeyMaskPrefix 脱敏时保留的明文前缀长度。
|
||||
monitorAPIKeyMaskPrefix = 4
|
||||
// monitorAPIKeyMaskSuffix 脱敏后追加的占位字符串。
|
||||
monitorAPIKeyMaskSuffix = "***"
|
||||
)
|
||||
|
||||
// ChannelMonitorHandler 渠道监控管理后台 handler。
|
||||
type ChannelMonitorHandler struct {
|
||||
monitorService *service.ChannelMonitorService
|
||||
}
|
||||
|
||||
// NewChannelMonitorHandler 创建 handler。
|
||||
func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *ChannelMonitorHandler {
|
||||
return &ChannelMonitorHandler{monitorService: monitorService}
|
||||
}
|
||||
|
||||
// --- Request / Response ---
|
||||
|
||||
type channelMonitorCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini"`
|
||||
Endpoint string `json:"endpoint" binding:"required,max=500"`
|
||||
APIKey string `json:"api_key" binding:"required,max=2000"`
|
||||
PrimaryModel string `json:"primary_model" binding:"required,max=200"`
|
||||
ExtraModels []string `json:"extra_models"`
|
||||
GroupName string `json:"group_name" binding:"max=100"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
IntervalSeconds int `json:"interval_seconds" binding:"required,min=15,max=3600"`
|
||||
}
|
||||
|
||||
type channelMonitorUpdateRequest struct {
|
||||
Name *string `json:"name" binding:"omitempty,max=100"`
|
||||
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini"`
|
||||
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
|
||||
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
|
||||
PrimaryModel *string `json:"primary_model" binding:"omitempty,max=200"`
|
||||
ExtraModels *[]string `json:"extra_models"`
|
||||
GroupName *string `json:"group_name" binding:"omitempty,max=100"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
IntervalSeconds *int `json:"interval_seconds" binding:"omitempty,min=15,max=3600"`
|
||||
}
|
||||
|
||||
type channelMonitorResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
APIKeyMasked string `json:"api_key_masked"`
|
||||
APIKeyDecryptFailed bool `json:"api_key_decrypt_failed"`
|
||||
PrimaryModel string `json:"primary_model"`
|
||||
ExtraModels []string `json:"extra_models"`
|
||||
GroupName string `json:"group_name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IntervalSeconds int `json:"interval_seconds"`
|
||||
LastCheckedAt *string `json:"last_checked_at"`
|
||||
CreatedBy int64 `json:"created_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
PrimaryStatus string `json:"primary_status"`
|
||||
PrimaryLatencyMs *int `json:"primary_latency_ms"`
|
||||
Availability7d float64 `json:"availability_7d"`
|
||||
ExtraModelsStatus []dto.ChannelMonitorExtraModelStatus `json:"extra_models_status"`
|
||||
}
|
||||
|
||||
type channelMonitorCheckResultResponse struct {
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
LatencyMs *int `json:"latency_ms"`
|
||||
PingLatencyMs *int `json:"ping_latency_ms"`
|
||||
Message string `json:"message"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
}
|
||||
|
||||
type channelMonitorHistoryItemResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
LatencyMs *int `json:"latency_ms"`
|
||||
PingLatencyMs *int `json:"ping_latency_ms"`
|
||||
Message string `json:"message"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
}
|
||||
|
||||
// maskAPIKey 对 API Key 明文做脱敏:前 4 字符 + "***",长度 ≤ 4 时只显示 "***"。
|
||||
func maskAPIKey(plain string) string {
|
||||
if len(plain) <= monitorAPIKeyMaskPrefix {
|
||||
return monitorAPIKeyMaskSuffix
|
||||
}
|
||||
return plain[:monitorAPIKeyMaskPrefix] + monitorAPIKeyMaskSuffix
|
||||
}
|
||||
|
||||
func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
extras := m.ExtraModels
|
||||
if extras == nil {
|
||||
extras = []string{}
|
||||
}
|
||||
resp := &channelMonitorResponse{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Provider: m.Provider,
|
||||
Endpoint: m.Endpoint,
|
||||
APIKeyMasked: maskAPIKey(m.APIKey),
|
||||
APIKeyDecryptFailed: m.APIKeyDecryptFailed,
|
||||
PrimaryModel: m.PrimaryModel,
|
||||
ExtraModels: extras,
|
||||
GroupName: m.GroupName,
|
||||
Enabled: m.Enabled,
|
||||
IntervalSeconds: m.IntervalSeconds,
|
||||
CreatedBy: m.CreatedBy,
|
||||
CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: m.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
// PrimaryStatus / PrimaryLatencyMs / Availability7d 由 List handler 在批量聚合后填充。
|
||||
}
|
||||
if m.LastCheckedAt != nil {
|
||||
s := m.LastCheckedAt.UTC().Format(time.RFC3339)
|
||||
resp.LastCheckedAt = &s
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func checkResultToResponse(r *service.CheckResult) channelMonitorCheckResultResponse {
|
||||
return channelMonitorCheckResultResponse{
|
||||
Model: r.Model,
|
||||
Status: r.Status,
|
||||
LatencyMs: r.LatencyMs,
|
||||
PingLatencyMs: r.PingLatencyMs,
|
||||
Message: r.Message,
|
||||
CheckedAt: r.CheckedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func historyEntryToResponse(e *service.ChannelMonitorHistoryEntry) channelMonitorHistoryItemResponse {
|
||||
return channelMonitorHistoryItemResponse{
|
||||
ID: e.ID,
|
||||
Model: e.Model,
|
||||
Status: e.Status,
|
||||
LatencyMs: e.LatencyMs,
|
||||
PingLatencyMs: e.PingLatencyMs,
|
||||
Message: e.Message,
|
||||
CheckedAt: e.CheckedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// ParseChannelMonitorID 提取并校验路径参数 :id(admin 与 user handler 共享)。
|
||||
// 校验失败时已写入 4xx 响应,调用方只需 return。
|
||||
func ParseChannelMonitorID(c *gin.Context) (int64, bool) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.ErrorFrom(c, infraerrors.BadRequest("INVALID_MONITOR_ID", "invalid monitor id"))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// parseListEnabled 解析 enabled query 参数:true/false 转为 *bool,空或非法则返回 nil。
|
||||
func parseListEnabled(raw string) *bool {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "true", "1", "yes":
|
||||
v := true
|
||||
return &v
|
||||
case "false", "0", "no":
|
||||
v := false
|
||||
return &v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
// List GET /api/v1/admin/channel-monitors
|
||||
func (h *ChannelMonitorHandler) List(c *gin.Context) {
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
if pageSize > monitorMaxPageSize {
|
||||
pageSize = monitorMaxPageSize
|
||||
}
|
||||
|
||||
params := service.ChannelMonitorListParams{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Provider: strings.TrimSpace(c.Query("provider")),
|
||||
Enabled: parseListEnabled(c.Query("enabled")),
|
||||
Search: strings.TrimSpace(c.Query("search")),
|
||||
}
|
||||
|
||||
items, total, err := h.monitorService.List(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
summaries := h.batchSummaryFor(c, items)
|
||||
out := make([]*channelMonitorResponse, 0, len(items))
|
||||
for _, m := range items {
|
||||
out = append(out, buildListItemResponse(m, summaries[m.ID]))
|
||||
}
|
||||
response.Paginated(c, out, total, page, pageSize)
|
||||
}
|
||||
|
||||
// batchSummaryFor 批量聚合 latest + 7d 可用率,避免每行 2 次 SQL(消除 N+1)。
|
||||
func (h *ChannelMonitorHandler) batchSummaryFor(c *gin.Context, items []*service.ChannelMonitor) map[int64]service.MonitorStatusSummary {
|
||||
ids := make([]int64, 0, len(items))
|
||||
primaryByID := make(map[int64]string, len(items))
|
||||
extrasByID := make(map[int64][]string, len(items))
|
||||
for _, m := range items {
|
||||
ids = append(ids, m.ID)
|
||||
primaryByID[m.ID] = m.PrimaryModel
|
||||
extrasByID[m.ID] = m.ExtraModels
|
||||
}
|
||||
return h.monitorService.BatchMonitorStatusSummary(c.Request.Context(), ids, primaryByID, extrasByID)
|
||||
}
|
||||
|
||||
// buildListItemResponse 把 monitor + summary 装成 admin list 的响应行。
|
||||
func buildListItemResponse(m *service.ChannelMonitor, summary service.MonitorStatusSummary) *channelMonitorResponse {
|
||||
resp := channelMonitorToResponse(m)
|
||||
resp.PrimaryStatus = summary.PrimaryStatus
|
||||
resp.PrimaryLatencyMs = summary.PrimaryLatencyMs
|
||||
resp.Availability7d = summary.Availability7d
|
||||
resp.ExtraModelsStatus = make([]dto.ChannelMonitorExtraModelStatus, 0, len(summary.ExtraModels))
|
||||
for _, e := range summary.ExtraModels {
|
||||
resp.ExtraModelsStatus = append(resp.ExtraModelsStatus, dto.ChannelMonitorExtraModelStatus{
|
||||
Model: e.Model,
|
||||
Status: e.Status,
|
||||
LatencyMs: e.LatencyMs,
|
||||
})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// Get GET /api/v1/admin/channel-monitors/:id
|
||||
func (h *ChannelMonitorHandler) Get(c *gin.Context) {
|
||||
id, ok := ParseChannelMonitorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m, err := h.monitorService.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, channelMonitorToResponse(m))
|
||||
}
|
||||
|
||||
// Create POST /api/v1/admin/channel-monitors
|
||||
func (h *ChannelMonitorHandler) Create(c *gin.Context) {
|
||||
var req channelMonitorCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.ErrorFrom(c, infraerrors.BadRequest("VALIDATION_ERROR", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
subject, _ := middleware2.GetAuthSubjectFromContext(c)
|
||||
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
m, err := h.monitorService.Create(c.Request.Context(), service.ChannelMonitorCreateParams{
|
||||
Name: req.Name,
|
||||
Provider: req.Provider,
|
||||
Endpoint: req.Endpoint,
|
||||
APIKey: req.APIKey,
|
||||
PrimaryModel: req.PrimaryModel,
|
||||
ExtraModels: req.ExtraModels,
|
||||
GroupName: req.GroupName,
|
||||
Enabled: enabled,
|
||||
IntervalSeconds: req.IntervalSeconds,
|
||||
CreatedBy: subject.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, channelMonitorToResponse(m))
|
||||
}
|
||||
|
||||
// Update PUT /api/v1/admin/channel-monitors/:id
|
||||
func (h *ChannelMonitorHandler) Update(c *gin.Context) {
|
||||
id, ok := ParseChannelMonitorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req channelMonitorUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.ErrorFrom(c, infraerrors.BadRequest("VALIDATION_ERROR", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
m, err := h.monitorService.Update(c.Request.Context(), id, service.ChannelMonitorUpdateParams{
|
||||
Name: req.Name,
|
||||
Provider: req.Provider,
|
||||
Endpoint: req.Endpoint,
|
||||
APIKey: req.APIKey,
|
||||
PrimaryModel: req.PrimaryModel,
|
||||
ExtraModels: req.ExtraModels,
|
||||
GroupName: req.GroupName,
|
||||
Enabled: req.Enabled,
|
||||
IntervalSeconds: req.IntervalSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, channelMonitorToResponse(m))
|
||||
}
|
||||
|
||||
// Delete DELETE /api/v1/admin/channel-monitors/:id
|
||||
func (h *ChannelMonitorHandler) Delete(c *gin.Context) {
|
||||
id, ok := ParseChannelMonitorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.monitorService.Delete(c.Request.Context(), id); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
// Run POST /api/v1/admin/channel-monitors/:id/run
|
||||
func (h *ChannelMonitorHandler) Run(c *gin.Context) {
|
||||
id, ok := ParseChannelMonitorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
results, err := h.monitorService.RunCheck(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
out := make([]channelMonitorCheckResultResponse, 0, len(results))
|
||||
for _, r := range results {
|
||||
out = append(out, checkResultToResponse(r))
|
||||
}
|
||||
response.Success(c, gin.H{"results": out})
|
||||
}
|
||||
|
||||
// History GET /api/v1/admin/channel-monitors/:id/history
|
||||
func (h *ChannelMonitorHandler) History(c *gin.Context) {
|
||||
id, ok := ParseChannelMonitorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := parseHistoryLimit(c.Query("limit"))
|
||||
model := strings.TrimSpace(c.Query("model"))
|
||||
|
||||
entries, err := h.monitorService.ListHistory(c.Request.Context(), id, model, limit)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
out := make([]channelMonitorHistoryItemResponse, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, historyEntryToResponse(e))
|
||||
}
|
||||
response.Success(c, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// parseHistoryLimit 解析 history 接口的 limit query。
|
||||
// 使用 service 包的统一上下限常量,避免在 handler 重复定义同名魔法值。
|
||||
func parseHistoryLimit(raw string) int {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return service.MonitorHistoryDefaultLimit
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return service.MonitorHistoryDefaultLimit
|
||||
}
|
||||
if v > service.MonitorHistoryMaxLimit {
|
||||
return service.MonitorHistoryMaxLimit
|
||||
}
|
||||
return v
|
||||
}
|
||||
127
backend/internal/handler/channel_monitor_user_handler.go
Normal file
127
backend/internal/handler/channel_monitor_user_handler.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ChannelMonitorUserHandler 渠道监控用户只读 handler。
|
||||
type ChannelMonitorUserHandler struct {
|
||||
monitorService *service.ChannelMonitorService
|
||||
}
|
||||
|
||||
// NewChannelMonitorUserHandler 创建 handler。
|
||||
func NewChannelMonitorUserHandler(monitorService *service.ChannelMonitorService) *ChannelMonitorUserHandler {
|
||||
return &ChannelMonitorUserHandler{monitorService: monitorService}
|
||||
}
|
||||
|
||||
// --- Response ---
|
||||
|
||||
type channelMonitorUserListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
GroupName string `json:"group_name"`
|
||||
PrimaryModel string `json:"primary_model"`
|
||||
PrimaryStatus string `json:"primary_status"`
|
||||
PrimaryLatencyMs *int `json:"primary_latency_ms"`
|
||||
Availability7d float64 `json:"availability_7d"`
|
||||
ExtraModels []dto.ChannelMonitorExtraModelStatus `json:"extra_models"`
|
||||
}
|
||||
|
||||
type channelMonitorUserDetailResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
GroupName string `json:"group_name"`
|
||||
Models []channelMonitorUserModelStat `json:"models"`
|
||||
}
|
||||
|
||||
type channelMonitorUserModelStat struct {
|
||||
Model string `json:"model"`
|
||||
LatestStatus string `json:"latest_status"`
|
||||
LatestLatencyMs *int `json:"latest_latency_ms"`
|
||||
Availability7d float64 `json:"availability_7d"`
|
||||
Availability15d float64 `json:"availability_15d"`
|
||||
Availability30d float64 `json:"availability_30d"`
|
||||
AvgLatency7dMs *int `json:"avg_latency_7d_ms"`
|
||||
}
|
||||
|
||||
func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListItem {
|
||||
extras := make([]dto.ChannelMonitorExtraModelStatus, 0, len(v.ExtraModels))
|
||||
for _, e := range v.ExtraModels {
|
||||
extras = append(extras, dto.ChannelMonitorExtraModelStatus{
|
||||
Model: e.Model,
|
||||
Status: e.Status,
|
||||
LatencyMs: e.LatencyMs,
|
||||
})
|
||||
}
|
||||
return channelMonitorUserListItem{
|
||||
ID: v.ID,
|
||||
Name: v.Name,
|
||||
Provider: v.Provider,
|
||||
GroupName: v.GroupName,
|
||||
PrimaryModel: v.PrimaryModel,
|
||||
PrimaryStatus: v.PrimaryStatus,
|
||||
PrimaryLatencyMs: v.PrimaryLatencyMs,
|
||||
Availability7d: v.Availability7d,
|
||||
ExtraModels: extras,
|
||||
}
|
||||
}
|
||||
|
||||
func userMonitorDetailToResponse(d *service.UserMonitorDetail) *channelMonitorUserDetailResponse {
|
||||
models := make([]channelMonitorUserModelStat, 0, len(d.Models))
|
||||
for _, m := range d.Models {
|
||||
models = append(models, channelMonitorUserModelStat{
|
||||
Model: m.Model,
|
||||
LatestStatus: m.LatestStatus,
|
||||
LatestLatencyMs: m.LatestLatencyMs,
|
||||
Availability7d: m.Availability7d,
|
||||
Availability15d: m.Availability15d,
|
||||
Availability30d: m.Availability30d,
|
||||
AvgLatency7dMs: m.AvgLatency7dMs,
|
||||
})
|
||||
}
|
||||
return &channelMonitorUserDetailResponse{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
Provider: d.Provider,
|
||||
GroupName: d.GroupName,
|
||||
Models: models,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
// List GET /api/v1/channel-monitors
|
||||
func (h *ChannelMonitorUserHandler) List(c *gin.Context) {
|
||||
views, err := h.monitorService.ListUserView(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
items := make([]channelMonitorUserListItem, 0, len(views))
|
||||
for _, v := range views {
|
||||
items = append(items, userMonitorViewToItem(v))
|
||||
}
|
||||
response.Success(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// GetStatus GET /api/v1/channel-monitors/:id/status
|
||||
func (h *ChannelMonitorUserHandler) GetStatus(c *gin.Context) {
|
||||
// 复用 admin.ParseChannelMonitorID 保持错误码与日志一致。
|
||||
id, ok := admin.ParseChannelMonitorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
detail, err := h.monitorService.GetUserDetail(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, userMonitorDetailToResponse(detail))
|
||||
}
|
||||
10
backend/internal/handler/dto/channel_monitor.go
Normal file
10
backend/internal/handler/dto/channel_monitor.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package dto
|
||||
|
||||
// ChannelMonitorExtraModelStatus 渠道监控附加模型最近一次状态。
|
||||
// 同时被 admin handler(List 响应)与 user handler(List 响应)复用,
|
||||
// 字段必须保持一致以保证前端拿到统一结构。
|
||||
type ChannelMonitorExtraModelStatus struct {
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
LatencyMs *int `json:"latency_ms"`
|
||||
}
|
||||
@@ -31,6 +31,7 @@ type AdminHandlers struct {
|
||||
APIKey *admin.AdminAPIKeyHandler
|
||||
ScheduledTest *admin.ScheduledTestHandler
|
||||
Channel *admin.ChannelHandler
|
||||
ChannelMonitor *admin.ChannelMonitorHandler
|
||||
Payment *admin.PaymentHandler
|
||||
}
|
||||
|
||||
@@ -43,6 +44,7 @@ type Handlers struct {
|
||||
Redeem *RedeemHandler
|
||||
Subscription *SubscriptionHandler
|
||||
Announcement *AnnouncementHandler
|
||||
ChannelMonitor *ChannelMonitorUserHandler
|
||||
Admin *AdminHandlers
|
||||
Gateway *GatewayHandler
|
||||
OpenAIGateway *OpenAIGatewayHandler
|
||||
|
||||
@@ -34,6 +34,7 @@ func ProvideAdminHandlers(
|
||||
apiKeyHandler *admin.AdminAPIKeyHandler,
|
||||
scheduledTestHandler *admin.ScheduledTestHandler,
|
||||
channelHandler *admin.ChannelHandler,
|
||||
channelMonitorHandler *admin.ChannelMonitorHandler,
|
||||
paymentHandler *admin.PaymentHandler,
|
||||
) *AdminHandlers {
|
||||
return &AdminHandlers{
|
||||
@@ -62,6 +63,7 @@ func ProvideAdminHandlers(
|
||||
APIKey: apiKeyHandler,
|
||||
ScheduledTest: scheduledTestHandler,
|
||||
Channel: channelHandler,
|
||||
ChannelMonitor: channelMonitorHandler,
|
||||
Payment: paymentHandler,
|
||||
}
|
||||
}
|
||||
@@ -85,6 +87,7 @@ func ProvideHandlers(
|
||||
redeemHandler *RedeemHandler,
|
||||
subscriptionHandler *SubscriptionHandler,
|
||||
announcementHandler *AnnouncementHandler,
|
||||
channelMonitorUserHandler *ChannelMonitorUserHandler,
|
||||
adminHandlers *AdminHandlers,
|
||||
gatewayHandler *GatewayHandler,
|
||||
openaiGatewayHandler *OpenAIGatewayHandler,
|
||||
@@ -103,6 +106,7 @@ func ProvideHandlers(
|
||||
Redeem: redeemHandler,
|
||||
Subscription: subscriptionHandler,
|
||||
Announcement: announcementHandler,
|
||||
ChannelMonitor: channelMonitorUserHandler,
|
||||
Admin: adminHandlers,
|
||||
Gateway: gatewayHandler,
|
||||
OpenAIGateway: openaiGatewayHandler,
|
||||
@@ -123,6 +127,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewRedeemHandler,
|
||||
NewSubscriptionHandler,
|
||||
NewAnnouncementHandler,
|
||||
NewChannelMonitorUserHandler,
|
||||
NewGatewayHandler,
|
||||
NewOpenAIGatewayHandler,
|
||||
NewTotpHandler,
|
||||
@@ -156,6 +161,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewAdminAPIKeyHandler,
|
||||
admin.NewScheduledTestHandler,
|
||||
admin.NewChannelHandler,
|
||||
admin.NewChannelMonitorHandler,
|
||||
admin.NewPaymentHandler,
|
||||
|
||||
// AdminHandlers and Handlers constructors
|
||||
|
||||
Reference in New Issue
Block a user