- 两个只读端点:GET /admin/channels/events(列表,8 个过滤条件 + 分页钳制)与 .../events/counters(逐类计数,恒带 approx:true 如实标「近似」) - 诚实边界:>90 天窗口 ⇒ 400(不静默裁剪);未知 platform/档位 ⇒ 400;**未接线 ⇒ 500 channel_events_unavailable**(绝不回空 200 假装「没有事件」);微信平台的空如实说明「该渠道不提供非消息事件」而不借别平台顶替 - 面板:新 section「渠道事件」(计数卡 + 事件行 + 过滤条 + 30s 静默刷新),空/错误态分开且失败显示错误不显示 0 - 组合根接线(主会话补):wire_frontend.go 的 Deps 注入 EventStore: a.channelEventStore()(未接线时为真 nil ⇒ 保留诚实 500) - 契约不升版(查询面 v0.67 已登记):就地改「未实现 → 已实现 + 端点路径」,并补登记 5 处原先留白的落地值(camelCase / 通用 bad_request / RBAC owner|admin / data.approx / total 语义) - 顺带把 §5 三个 CHANNEL_EVENT_* env 键由「尚未实现」改「已接线」(E4) - 变异实测:去掉 90 天上限 ⇒ status 200 want 400;未接线回空 200 ⇒ status 200 want 500 - 债务登记:events.Filter 无 AccountID 列 ⇒ 服务端暂不能按账号筛(列表仍如实下发 accountId),要加需先改 internal/runtime/events
208 lines
7.5 KiB
Go
208 lines
7.5 KiB
Go
package gateway
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// LLM 用量汇总**只读**端点(GET /api/v1/admin/llm/usage,切片 U1,主人裁决
|
||
// 「2.4 落」;契约 v0.64)。面板的「用量」小卡(近 24h 掉用数 / 输入 token /
|
||
// 缓存命中率)与将来的成本视图都以它为数源。
|
||
//
|
||
// 四条纪律:
|
||
//
|
||
// 1. **只读**:只有 SELECT(汇总)—— 不写、不迁移,复用组合根注入的活连接
|
||
// (见 Deps.Usage)。
|
||
// 2. **不回显凭据**:响应里只有 token 计数与模型/用途标签 —— `llm_usage` 表里
|
||
// 根本没有凭据与消息正文,因此没有任何「回显 DSN/apiKey」的代码路径。
|
||
// 3. **不猜**:没接汇总出口、或查库失败,一律 500 + 明确 code,绝不回一份
|
||
// 全 0 的假汇总(面板据此显示错误,而不是一个看着正常的假数字)。
|
||
// 4. **不静默裁剪窗口**:`since`/`until` 跨度超过 90 天时回 400 并点名上限 ——
|
||
// 静默裁剪会让客户端把「我没查那么久」误读成「就那么少」。
|
||
const llmUsageLogSource = "gateway/admin_llm_usage"
|
||
|
||
// llmUsageWindow 回显本次生效的时间窗与用途过滤(缺省值补齐后的真值,客户端
|
||
// 不必自己猜)。`purpose` 为空 = 未过滤。
|
||
type llmUsageWindow struct {
|
||
Since string `json:"since"`
|
||
Until string `json:"until"`
|
||
Purpose string `json:"purpose"`
|
||
}
|
||
|
||
// llmUsageTotals 是一个窗口/分组内的用量合计。`hitRate` = cacheHit /
|
||
// (cacheHit + cacheMiss),**分母为 0 时下发 0**(口径与 model.UsageTotals.HitRate
|
||
// 同源,绝不下发 NaN/Inf)。
|
||
type llmUsageTotals struct {
|
||
Calls int64 `json:"calls"`
|
||
Prompt int64 `json:"prompt"`
|
||
Completion int64 `json:"completion"`
|
||
CacheHit int64 `json:"cacheHit"`
|
||
CacheMiss int64 `json:"cacheMiss"`
|
||
HitRate float64 `json:"hitRate"`
|
||
}
|
||
|
||
// llmUsagePurposeRow / llmUsageModelRow 是按用途 / 按模型分解的一行。两类分开
|
||
// 建模(而不是共用一个带 `key` 的结构),好让线上字段名就是它真实的分组名。
|
||
type llmUsagePurposeRow struct {
|
||
Purpose string `json:"purpose"`
|
||
llmUsageTotals
|
||
}
|
||
|
||
type llmUsageModelRow struct {
|
||
Model string `json:"model"`
|
||
llmUsageTotals
|
||
}
|
||
|
||
// llmUsageResponse 是响应 data 本体。
|
||
type llmUsageResponse struct {
|
||
Window llmUsageWindow `json:"window"`
|
||
Totals llmUsageTotals `json:"totals"`
|
||
ByPurpose []llmUsagePurposeRow `json:"byPurpose"`
|
||
ByModel []llmUsageModelRow `json:"byModel"`
|
||
}
|
||
|
||
// adminLlmUsage 处理 GET /api/v1/admin/llm/usage(owner|admin 读,与同页其它
|
||
// 管理读同门)。它是只读查询,因此不审计(审计只覆盖动作)。
|
||
func (s *Server) adminLlmUsage(w http.ResponseWriter, r *http.Request) {
|
||
if s.usage == nil {
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "usage_unavailable", "LLM 用量汇总未接线"))
|
||
return
|
||
}
|
||
query, err := llmUsageQuery(r)
|
||
if err != nil {
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%s", err.Error()))
|
||
return
|
||
}
|
||
summary, err := s.usage.Summary(r.Context(), query)
|
||
if err != nil {
|
||
// 原始错误(可能带 SQL/路径)只进日志;响应只给稳定 code + 中文说明。
|
||
logger.WithSource(s.log, llmUsageLogSource).Error("read llm usage summary", "err", err)
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "usage_unavailable", "读取 LLM 用量汇总失败"))
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, llmUsageResponseOf(summary))
|
||
}
|
||
|
||
// llmUsageQuery 解析并**校验**查询参数:`since`/`until` 见 parseInstantParam,
|
||
// 缺省窗口为近 24h,跨度上限见 model.UsageMaxWindow。
|
||
//
|
||
// 校验顺序是「形状先于语义」:时间点解析不了就直接 400,不会拿一个零值窗口去查库。
|
||
func llmUsageQuery(r *http.Request) (model.UsageQuery, error) {
|
||
params := r.URL.Query()
|
||
since, err := parseInstantParam(params.Get("since"))
|
||
if err != nil {
|
||
return model.UsageQuery{}, fmt.Errorf("since 非法:%w", err)
|
||
}
|
||
until, err := parseInstantParam(params.Get("until"))
|
||
if err != nil {
|
||
return model.UsageQuery{}, fmt.Errorf("until 非法:%w", err)
|
||
}
|
||
if until.IsZero() {
|
||
until = time.Now()
|
||
}
|
||
if since.IsZero() {
|
||
since = until.Add(-model.UsageDefaultWindow)
|
||
}
|
||
if !since.Before(until) {
|
||
return model.UsageQuery{}, fmt.Errorf("since 必须早于 until")
|
||
}
|
||
if until.Sub(since) > model.UsageMaxWindow {
|
||
return model.UsageQuery{}, fmt.Errorf("时间窗超过上限 %s", model.UsageMaxWindow)
|
||
}
|
||
return model.UsageQuery{
|
||
Since: since,
|
||
Until: until,
|
||
Purpose: strings.TrimSpace(params.Get("purpose")),
|
||
}, nil
|
||
}
|
||
|
||
// parseInstantParam 解析一个时间点参数。接受两种形态:
|
||
//
|
||
// - **RFC3339**(如 `2026-10-05T00:00:00Z`,JS 的 `toISOString()` 就是这个);
|
||
// - **Unix 毫秒**(纯数字,如 `1759622400000`)—— 与 `/sessions/{id}/messages`
|
||
// 的 `before`/`after` 游标同一种口径,便于客户端复用游标。
|
||
//
|
||
// 空串 = 未给(返回零值)。其它形态报错(绝不静默当成「现在」)。
|
||
//
|
||
// 它是**跨端点共用**的解析器:`/admin/llm/usage` 与 `/admin/channels/events`
|
||
// (切片 E6)的 `since`/`until` 口径相同(同一形态 ⇒ 同一解析 ⇒ 同一比较语义),
|
||
// 因此只此一份,不各写一遍。
|
||
func parseInstantParam(raw string) (time.Time, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return time.Time{}, nil
|
||
}
|
||
if allDigits(raw) {
|
||
ms, err := strconv.ParseInt(raw, 10, 64)
|
||
if err != nil {
|
||
return time.Time{}, fmt.Errorf("毫秒时间戳 %q 越界", raw)
|
||
}
|
||
return time.UnixMilli(ms), nil
|
||
}
|
||
parsed, err := time.Parse(time.RFC3339, raw)
|
||
if err != nil {
|
||
return time.Time{}, fmt.Errorf("不是 RFC3339 或毫秒时间戳:%q", raw)
|
||
}
|
||
return parsed, nil
|
||
}
|
||
|
||
// allDigits 报告 s 是否由纯 ASCII 数字构成(空串为 false)。
|
||
func allDigits(s string) bool {
|
||
if s == "" {
|
||
return false
|
||
}
|
||
for i := 0; i < len(s); i++ {
|
||
if s[i] < '0' || s[i] > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// llmUsageResponseOf 把存储层汇总翻成线上结构。时间一律以 **UTC RFC3339** 回显;
|
||
// 分组列表恒为非 nil 切片(无数据时是 `[]` 而非 `null`)。
|
||
func llmUsageResponseOf(summary model.UsageSummary) llmUsageResponse {
|
||
out := llmUsageResponse{
|
||
Window: llmUsageWindow{
|
||
Since: summary.Window.Since.UTC().Format(time.RFC3339),
|
||
Until: summary.Window.Until.UTC().Format(time.RFC3339),
|
||
Purpose: summary.Window.Purpose,
|
||
},
|
||
Totals: llmUsageTotalsOf(summary.Totals),
|
||
ByPurpose: make([]llmUsagePurposeRow, 0, len(summary.ByPurpose)),
|
||
ByModel: make([]llmUsageModelRow, 0, len(summary.ByModel)),
|
||
}
|
||
for _, group := range summary.ByPurpose {
|
||
out.ByPurpose = append(out.ByPurpose, llmUsagePurposeRow{
|
||
Purpose: group.Key,
|
||
llmUsageTotals: llmUsageTotalsOf(group.UsageTotals),
|
||
})
|
||
}
|
||
for _, group := range summary.ByModel {
|
||
out.ByModel = append(out.ByModel, llmUsageModelRow{
|
||
Model: group.Key,
|
||
llmUsageTotals: llmUsageTotalsOf(group.UsageTotals),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// llmUsageTotalsOf 是一处换算:合计计数 + 命中率(分母 0 ⇒ 0)。
|
||
func llmUsageTotalsOf(t model.UsageTotals) llmUsageTotals {
|
||
return llmUsageTotals{
|
||
Calls: t.Calls,
|
||
Prompt: t.Prompt,
|
||
Completion: t.Completion,
|
||
CacheHit: t.CacheHit,
|
||
CacheMiss: t.CacheMiss,
|
||
HitRate: t.HitRate(),
|
||
}
|
||
}
|