Skip to content

Commit fe64337

Browse files
committed
feat: 新增请求日志(元信息审计 + 面板日志页),统计补充实际扣费与均值口径
新增 logs 包:请求路径上只做内存追加,由 30s 定时器与优雅关闭统一落盘 data.json (logs 字段)。仅记录元信息——时间、模型、密钥 ID/备注、是否流式、token 用量、 credits、耗时、状态码与截断至 500 字符的错误摘要,不含任何对话内容;按保留天数 与条数上限自动裁剪最旧条目,避免 data.json 无限增长。 主要变更: - logs: 新 Recorder(Record/Query/Flush/SetLimits),过滤支持模型模糊、密钥精确、 状态码与「仅失败」,分页返回;持久化经 Persister 接口复用 store.Store。 - store: Config 增加 logs / log_retention_days / log_max_entries 的读写与校验 (缺失或越界自动归一化/夹取);新增常数时间的 LookupKey,返回密钥 ID 与备注。 - bridge: BridgeResolver 改为返回 ResolvedBridge(bridge + KeyID/KeyNote);chat handler 全错误路径埋点、defer 记录耗时,鉴权失败不落日志;上游状态码映射抽为 clientStatusForUpstream,客户端中断记为 499。 - stats: usage 帧按 model 归属到 by_model / hourly;新增 billed_requests、 credits、avg_credits(分母只算计费帧),趋势 tooltip 显示每小时消耗。 - admin: 新增 GET /admin/api/logs;/admin/api/config 读写日志限额、对 env 固定项 拒改,保存后即时热应用到 recorder;新增 QODER_LOG_* 环境变量(与超时同优先级)。 - web: 新增日志页(筛选 + 分页),模型页内容并入统计页并从导航移除;设置页新增 日志配置;统计页新增「实际消耗 Credits」卡与按模型消耗/均价列。 影响范围: - 数据兼容:data.json 新增字段缺失时取默认值,旧文件无需迁移,历史 credits 与 周期数据不受影响(billed_requests 自本版起累计)。 - 接口:/v1/chat/completions 响应行为不变,仅增加一次内存写(日志标签在 defer 记录,错误状态码口径与原回传完全一致);新增 /admin/api/logs 与 config 的两个 可选字段(0/留空 = 保持当前值)。 - 文档与测试:README 同步参数、接口与计费口径说明;补 logs/store/stats/bridge/ admin/web 测试。
1 parent cdfd5d7 commit fe64337

30 files changed

Lines changed: 2342 additions & 200 deletions

‎README.md‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
- **多模态支持** —— 支持图片输入
1414
- **Tool Calls** —— 支持函数调用
1515
- **模型动态加载** —— 自动从网关获取可用模型列表
16-
- **Web 管理面板** —— 浅色排版风格中文界面(支持深浅模式),支持密码登录、API 密钥管理、PAT 配置、模型查看、请求统计(总量 / 成功失败 / 总输入 / 总输出 / 缓存命中 / **本周期扣费额度与重置倒计时** / 按模型 / 近 24 小时趋势)
16+
- **Web 管理面板** —— 浅色排版风格中文界面(支持深浅模式),支持密码登录、API 密钥管理、PAT 配置、模型查看、请求统计(总量 / 成功失败 / 总输入 / 总输出 / 缓存命中 / **本服务实际扣费额度与均值** / 本周期扣费与重置倒计时 / 按模型消耗 / 近 24 小时趋势)
17+
- **请求日志** —— 记录每次调用的元信息(时间、模型、密钥、流式与否、token 用量、扣费额度、耗时、状态码与错误摘要,不保存对话内容),面板「日志」页支持按模型 / 密钥 / 状态筛选与分页,保留天数与条数上限可配置、自动清理最旧条目
1718
- **订阅周期感知** —— 从网关 `/algo/api/v3/user/status` 读取真实订阅信息(套餐、组织、`nextResetAt` 重置时间、`isQuotaExceeded` 超额标记),使面板额度按计费周期统计而非终身累计
1819
- **单文件部署** —— 编译为单一二进制文件,零外部依赖
1920

@@ -79,10 +80,12 @@ docker run -d -p 10081:10081 -v qoder2api-data:/app/data -e QODER_DATA_PATH=/app
7980
| `QODER_CHAT_TIMEOUT_SECONDS` | `120` | Chat 响应超时:等待上游开始响应(返回响应头)的最长秒数,范围 1-3600;设置后管理面板中不可修改 |
8081
| `QODER_IDLE_TIMEOUT_SECONDS` | `300` | 流空闲超时:流式响应中持续无数据的最长等待秒数,范围 1-3600(数据持续到达时流不会被中断);设置后管理面板中不可修改 |
8182
| `QODER_MAX_CONCURRENCY` | `1` | 单 PAT 同时发往上游的聊天请求数上限;超出部分本地排队等待。调大会提升吞吐,但超过 Qoder 账号的并发窗口时网关会拒绝请求(业务码 10605) |
83+
| `QODER_LOG_RETENTION_DAYS` | `90` | 请求日志保留天数,范围 1-3650;超期条目自动删除。设置后管理面板中不可修改 |
84+
| `QODER_LOG_MAX_ENTRIES` | `2000` | 请求日志条数上限,范围 100-100000;超限时自动删除最旧条目,与保留天数同时生效。设置后管理面板中不可修改 |
8285

8386
> 两个超时也可在管理面板「设置 → 超时配置」中修改,保存后即时生效(无需重启)。响应超时只约束上游「开始响应」,不会截断已经建立的流。
8487
85-
> 请求统计(`stats` 字段)随 data.json 持久化:服务每 30 秒批量写入一次,并在收到 `SIGINT`/`SIGTERM` 优雅关闭时执行最终落盘;仅统计通过密钥鉴权且请求体合法的调用,客户端主动断开不计入失败。令牌用量(总输入 / 总输出 / 缓存命中 / 实际扣费额度)从网关 usage 帧自动累计,流中断无 usage 帧时仅计次不计量。
88+
> 请求统计(`stats` 字段)与请求日志(`logs` 字段)随 data.json 持久化:服务每 30 秒批量写入一次,并在收到 `SIGINT`/`SIGTERM` 优雅关闭时执行最终落盘;仅统计通过密钥鉴权且请求体合法的调用,客户端主动断开不计入失败。令牌用量(总输入 / 总输出 / 缓存命中 / 实际扣费额度)从网关 usage 帧自动累计,流中断无 usage 帧时仅计次不计量。请求日志同样只记录通过密钥鉴权的调用(鉴权失败不记录),错误信息截断至 500 字符,日志配置亦可在管理面板「设置 → 日志配置」中修改并即时生效。
8689
8790
### 配置文件 (data.json)
8891

@@ -96,6 +99,8 @@ docker run -d -p 10081:10081 -v qoder2api-data:/app/data -e QODER_DATA_PATH=/app
9699
"password": "password",
97100
"chat_timeout_seconds": 120,
98101
"idle_timeout_seconds": 300,
102+
"log_retention_days": 90,
103+
"log_max_entries": 2000,
99104
"api_keys": [
100105
{
101106
"id": "xxxxxxxx",
@@ -238,7 +243,7 @@ curl http://localhost:10081/v1/models \
238243
| `/admin/api/keys` | GET/POST/DELETE | API 密钥管理 |
239244
| `/admin/api/pat` | GET/POST | PAT 令牌管理 |
240245
| `/admin/api/models` | GET | 获取模型列表 |
241-
| `/admin/api/stats` | GET | 请求统计(总量 / 按模型 / 近 24 小时趋势 / token 用量 / 扣费额度 / 计费周期与套餐信息) |
246+
| `/admin/api/stats` | GET | 请求统计(总量 / 按模型 / 近 24 小时趋势 / token 用量 / 实际扣费额度与均值 / 计费周期与套餐信息) |
242247
| `/admin/api/config` | GET/POST | 服务器配置 |
243248
| `/admin/api/password` | POST | 修改管理密码 |
244249

@@ -280,6 +285,20 @@ curl http://localhost:10081/v1/models \
280285

281286
- **官方额度优先**:面板的套餐用量来自账号级 OpenAPI,包含 IDE、CLI 和本服务产生的全部消耗,也能覆盖服务启动前的当期用量。
282287
- **额度来源分别展示**:套餐额度、加购额度和组织资源包在额度明细卡中分别成行展示,并汇总各额度来源返回的剩余 Credits;组织资源包的“可用/不可用”状态以 OpenAPI 返回值为准。
288+
- **本服务实际消耗单独成卡**:官方额度池是账号级数字,无法回答“本服务花了多少”。统计页的「实际消耗 Credits」卡直接累加网关 usage 帧里的 `credits`(实际扣费额度,已含折扣),因此可核对到每一次请求:
289+
290+
| 字段 | 含义 |
291+
| ------ | ------ |
292+
| `credits` | 本服务历史累计实际扣费 |
293+
| `cycle_credits` | 本订阅周期内的实际扣费(有周期边界时卡片展示此项) |
294+
| `billed_requests` | 实际计费的请求数(仅 `billable=true` 的 usage 帧) |
295+
| `avg_credits` | `credits / billed_requests`,平均每次请求的实际花费 |
296+
297+
> **分母为什么不是请求总数**:`total` 含失败请求、`billable=false` 的免费帧,以及流中断未收到 usage 帧的请求,用它作分母会低估均值。`billed_requests` 只统计真正扣费的帧,均值才等于「每次计费请求的均价」。
298+
299+
> **按模型 / 按小时归因**:`by_model[].credits` 与 `hourly[].credits` 把消耗归到具体模型与时段,行内的 `avg_credits` 给出该行的均价(按该行 `billed` 计);模型表的「消耗 / 平均」列与趋势图 tooltip 均取此口径。该模型未产生计费请求时「平均」列显示 `—` 而非 `0`,避免把「未计费」误读为「免费」。
300+
301+
- **升级说明**:`billed_requests` 自本版起累计,旧 `data.json` 中没有该字段时,卡片副行会提示「计费次数与均值自本版起统计」,而不是在非零的累计值下方报「暂无计费请求」;已累计的 `credits` 与历史周期数据不受影响。
283302
- **本地累计仅作降级**:usage 帧中 `billable=false` 的请求仍计入 token,但不计入本地 credits;OpenAPI 从未成功时,面板才显示本服务观察到的周期消耗,并明确标注口径。
284303
- **周期边界权威同步**:重置时刻以网关 `/user/status` 的 `nextResetAt` 为权威,OpenAPI `expiresAt` 仅在其缺失时兜底;且 `expiresAt` 必须小于 `2100-01-01` 才会被采纳——无期限套餐会把它填成 `9999-12-31` 哨兵值,该值会被忽略。本地周期起点按自然月回退推导(月末会夹取到目标月最后一天,3/31 → 2/28),断网期间仍可完成一次性滚动。
285304
- **边界变更的接受规则**(防止累计值被抖动或陈旧缓存误清):首次拿到边界时**保留**已累计的 credits;边界**未向前推进**(`/user/status` 缓存 1 小时,可能上报已滚过的旧值)则直接忽略;前进幅度小于 7 天时视为同一周期,不清零;达到 7 天以上才判定为真实续期并清零上一周期。

‎admin/admin.go‎

Lines changed: 114 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import (
1313
"net/http"
1414
"os"
1515
"strconv"
16+
"strings"
1617
"sync"
1718
"time"
1819

1920
"qoder2api/auth"
21+
"qoder2api/logs"
2022
"qoder2api/models"
2123
"qoder2api/stats"
2224
"qoder2api/store"
@@ -32,6 +34,7 @@ type Admin struct {
3234
store *store.Store
3335
modelFetcher ModelFetcher
3436
stats *stats.Recorder
37+
logRec *logs.Recorder
3538
sessions map[string]time.Time
3639
sessionMu sync.Mutex
3740
limiter *loginLimiter
@@ -40,12 +43,13 @@ type Admin struct {
4043
// ModelFetcher returns the current model catalog (dynamic or fallback).
4144
type ModelFetcher func(ctx context.Context) []string
4245

43-
// New creates an Admin instance. rec may be nil.
44-
func New(s *store.Store, mf ModelFetcher, rec *stats.Recorder) *Admin {
46+
// New creates an Admin instance. rec and lr may be nil.
47+
func New(s *store.Store, mf ModelFetcher, rec *stats.Recorder, lr *logs.Recorder) *Admin {
4548
return &Admin{
4649
store: s,
4750
modelFetcher: mf,
4851
stats: rec,
52+
logRec: lr,
4953
sessions: make(map[string]time.Time),
5054
limiter: newLoginLimiter(),
5155
}
@@ -67,6 +71,7 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) {
6771
mux.HandleFunc("/admin/api/pat", a.requireAuth(a.handlePAT))
6872
mux.HandleFunc("/admin/api/models", a.requireAuth(a.handleModels))
6973
mux.HandleFunc("/admin/api/stats", a.requireAuth(a.handleStats))
74+
mux.HandleFunc("/admin/api/logs", a.requireAuth(a.handleLogs))
7075
mux.HandleFunc("/admin/api/config", a.requireAuth(a.handleConfig))
7176
mux.HandleFunc("/admin/api/password", a.requireAuth(a.handlePassword))
7277
}
@@ -429,24 +434,40 @@ func timeoutEnvManaged() bool {
429434
os.Getenv("QODER_IDLE_TIMEOUT_SECONDS") != ""
430435
}
431436

437+
// logLimitsEnvManaged reports whether the log limits are pinned by env vars,
438+
// in which case the panel must not change them (same policy as the timeouts).
439+
func logLimitsEnvManaged() bool {
440+
return os.Getenv("QODER_LOG_RETENTION_DAYS") != "" ||
441+
os.Getenv("QODER_LOG_MAX_ENTRIES") != ""
442+
}
443+
432444
func (a *Admin) handleConfig(w http.ResponseWriter, r *http.Request) {
433445
switch r.Method {
434446
case http.MethodGet:
435-
// Report the effective stream timeouts (env startup overrides are
436-
// already folded into them), not just the persisted file values.
447+
// Report effective values, not the persisted file values: env startup
448+
// overrides are already folded into the stream timeouts and into the
449+
// log limits held by the recorder.
437450
timeouts := auth.CurrentStreamTimeouts()
451+
retentionDays, maxEntries := a.store.GetLogRetentionDays(), a.store.GetLogMaxEntries()
452+
if a.logRec != nil {
453+
retentionDays, maxEntries = a.logRec.Limits()
454+
}
438455
writeJSON(w, 200, map[string]interface{}{
439456
"host": a.store.GetHost(),
440457
"port": a.store.GetPort(),
441458
"chat_timeout_seconds": int(timeouts.Header.Seconds()),
442459
"idle_timeout_seconds": int(timeouts.Idle.Seconds()),
460+
"log_retention_days": retentionDays,
461+
"log_max_entries": maxEntries,
443462
})
444463
case http.MethodPost:
445464
var body struct {
446465
Host string `json:"host"`
447466
Port int `json:"port"`
448467
ChatTimeoutSeconds int `json:"chat_timeout_seconds"`
449468
IdleTimeoutSeconds int `json:"idle_timeout_seconds"`
469+
LogRetentionDays int `json:"log_retention_days"`
470+
LogMaxEntries int `json:"log_max_entries"`
450471
}
451472
if err := readJSON(w, r, &body); err != nil {
452473
writeJSONError(w, 400, "请求格式错误")
@@ -482,6 +503,25 @@ func (a *Admin) handleConfig(w http.ResponseWriter, r *http.Request) {
482503
return
483504
}
484505
}
506+
// Log limits share the timeout convention: 0 = keep current, and a
507+
// value pinned by env vars is rejected.
508+
wantsLogLimits := body.LogRetentionDays != 0 || body.LogMaxEntries != 0
509+
if wantsLogLimits && logLimitsEnvManaged() {
510+
writeJSONError(w, 400, "日志配置由环境变量 QODER_LOG_RETENTION_DAYS / QODER_LOG_MAX_ENTRIES 管理,无法在面板中修改")
511+
return
512+
}
513+
if body.LogRetentionDays != 0 {
514+
if err := store.ValidateLogRetentionDays(body.LogRetentionDays); err != nil {
515+
writeJSONError(w, 400, "日志保留天数:"+err.Error())
516+
return
517+
}
518+
}
519+
if body.LogMaxEntries != 0 {
520+
if err := store.ValidateLogMaxEntries(body.LogMaxEntries); err != nil {
521+
writeJSONError(w, 400, "日志条数上限:"+err.Error())
522+
return
523+
}
524+
}
485525
if err := a.store.SetHostPort(body.Host, body.Port); err != nil {
486526
writeJSONError(w, 500, err.Error())
487527
return
@@ -498,6 +538,23 @@ func (a *Admin) handleConfig(w http.ResponseWriter, r *http.Request) {
498538
return
499539
}
500540
}
541+
if body.LogRetentionDays != 0 {
542+
if err := a.store.SetLogRetentionDays(body.LogRetentionDays); err != nil {
543+
writeJSONError(w, 500, err.Error())
544+
return
545+
}
546+
}
547+
if body.LogMaxEntries != 0 {
548+
if err := a.store.SetLogMaxEntries(body.LogMaxEntries); err != nil {
549+
writeJSONError(w, 500, err.Error())
550+
return
551+
}
552+
}
553+
// Hot-apply the log limits so a lowered cap prunes the live recorder at
554+
// once, not at the next request or flush.
555+
if wantsLogLimits && a.logRec != nil {
556+
a.logRec.SetLimits(a.store.GetLogRetentionDays(), a.store.GetLogMaxEntries())
557+
}
501558
// Hot-apply only when the request actually carried timeout fields:
502559
// re-applying store values unconditionally would clobber env-pinned
503560
// timeouts on a legacy host/port-only save.
@@ -508,12 +565,18 @@ func (a *Admin) handleConfig(w http.ResponseWriter, r *http.Request) {
508565
)
509566
}
510567
timeouts := auth.CurrentStreamTimeouts()
568+
retentionDays, maxEntries := a.store.GetLogRetentionDays(), a.store.GetLogMaxEntries()
569+
if a.logRec != nil {
570+
retentionDays, maxEntries = a.logRec.Limits()
571+
}
511572
writeJSON(w, 200, map[string]interface{}{
512573
"host": body.Host,
513574
"port": body.Port,
514575
"chat_timeout_seconds": int(timeouts.Header.Seconds()),
515576
"idle_timeout_seconds": int(timeouts.Idle.Seconds()),
516-
"restart": "修改主机或端口后需要重启服务才能生效(超时配置已即时生效)",
577+
"log_retention_days": retentionDays,
578+
"log_max_entries": maxEntries,
579+
"restart": "修改主机或端口后需要重启服务才能生效(超时与日志配置已即时生效)",
517580
})
518581
default:
519582
writeJSONError(w, 405, "方法不允许")
@@ -579,6 +642,52 @@ func (a *Admin) handleStats(w http.ResponseWriter, r *http.Request) {
579642
writeJSON(w, 200, a.stats.Report())
580643
}
581644

645+
// --- Logs endpoint ---
646+
647+
// handleLogs serves GET /admin/api/logs: a paginated, filterable request log.
648+
// Query params: model (substring), key_id (exact), status ("error" or a code),
649+
// page, page_size.
650+
func (a *Admin) handleLogs(w http.ResponseWriter, r *http.Request) {
651+
if r.Method != http.MethodGet {
652+
writeJSONError(w, 405, "方法不允许")
653+
return
654+
}
655+
if a.logRec == nil {
656+
writeJSON(w, 200, logs.Result{Logs: []logs.LogEntry{}})
657+
return
658+
}
659+
q := r.URL.Query()
660+
f := logs.Filter{
661+
Model: strings.TrimSpace(q.Get("model")),
662+
KeyID: strings.TrimSpace(q.Get("key_id")),
663+
Page: atoiDefault(q.Get("page"), 1),
664+
PageSize: atoiDefault(q.Get("page_size"), 20),
665+
}
666+
switch v := q.Get("status"); v {
667+
case "error":
668+
f.Status = -1
669+
case "":
670+
// no status filter
671+
default:
672+
if n, err := strconv.Atoi(v); err == nil {
673+
f.Status = n
674+
}
675+
}
676+
writeJSON(w, 200, a.logRec.Query(f))
677+
}
678+
679+
// atoiDefault parses s as an int, falling back to def when empty or invalid.
680+
func atoiDefault(s string, def int) int {
681+
if s == "" {
682+
return def
683+
}
684+
n, err := strconv.Atoi(s)
685+
if err != nil {
686+
return def
687+
}
688+
return n
689+
}
690+
582691
// maskPAT returns a masked version of the PAT for display.
583692
func maskPAT(pat string) string {
584693
if len(pat) <= 8 {

0 commit comments

Comments
 (0)