fix: WebUI 密钥用量导出 CSV + 修复 Stats API key 过滤 bug

- api.go: 非 admin 用户过滤改用完整 key;keyNames 映射用完整 key;keys-csv 导出支持 key 查询参数过滤,安全类型断言
- stats.go: 新增 StatsRow 类型和 rows() 函数供导出使用
- server.go: handleStatusAPI 返回当前用户 key(已存在逻辑)
- index.html: 密钥用量卡片右上角添加导出 CSV 按钮(与请求记录一致)
This commit is contained in:
root
2026-08-10 14:52:30 +08:00
parent 41c9b0e14a
commit 63c2b13b4b
6 changed files with 238 additions and 29 deletions

View File

@ -18,6 +18,10 @@ type adapterPayload struct {
}
func (g *Gateway) handleAdaptersAPI(w http.ResponseWriter, r *http.Request) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/adapters")
path = strings.Trim(path, "/")
@ -132,8 +136,8 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
}
key := r.URL.Query().Get("key")
if reqRole(r.Context()) != "admin" {
// user keys may only see their own usage
key = keyID(reqKey(r.Context()))
// user keys may only see their own usage - use full key for internal filtering
key = reqKey(r.Context())
}
if r.URL.Query().Get("export") == "csv" {
from, _ := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64)
@ -146,7 +150,7 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
cw := csv.NewWriter(w)
names := map[string]string{}
for _, k := range g.core.ListKeys() {
names[keyID(k.Key)] = k.Name
names[k.Key] = k.Name
}
_ = cw.Write([]string{"time", "key", "key_name", "type", "model", "source", "status", "ok", "prompt_tokens", "completion_tokens", "latency_ms", "error"})
for _, rec := range g.stats.Records(from, to, key) {
@ -168,10 +172,72 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
cw.Flush()
return
}
if r.URL.Query().Get("export") == "keys-csv" {
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=llmsproxy-keys.csv")
cw := csv.NewWriter(w)
_ = cw.Write([]string{"key", "key_name", "role", "models", "total_requests", "success_requests", "failed_requests", "prompt_tokens", "completion_tokens", "total_tokens", "avg_latency_ms", "max_latency_ms", "created_at"})
// Use the same key filtering as the JSON API
exportKey := r.URL.Query().Get("key")
if reqRole(r.Context()) != "admin" {
exportKey = reqKey(r.Context())
}
snap := g.stats.Snapshot(0, exportKey)
byKeyRaw, ok := snap["by_key"].([]StatsRow)
if !ok {
byKeyRaw = []StatsRow{}
}
for _, row := range byKeyRaw {
key := row.Name
keyInfo, found := g.core.FindKey(key)
name := ""
role := ""
models := ""
if found {
name = keyInfo.Name
role = keyInfo.Role
modelNames := make([]string, 0, len(keyInfo.Models))
for _, m := range keyInfo.Models {
modelNames = append(modelNames, m.Model)
}
models = strings.Join(modelNames, ",")
}
reqs := row.Stat.Reqs
success := row.Stat.OK
errCount := row.Stat.Err
prompt := row.Stat.Prompt
compl := row.Stat.Compl
tokens := row.Stat.Prompt + row.Stat.Compl
latSum := row.Stat.LatSum
latMax := row.Stat.LatMax
avgLat := int64(0)
if reqs > 0 {
avgLat = latSum / reqs
}
createdAt := ""
if found && keyInfo.CreatedAt > 0 {
createdAt = time.Unix(keyInfo.CreatedAt, 0).Format(time.RFC3339)
}
_ = cw.Write([]string{
row.Name, name, role, models,
strconv.FormatInt(reqs, 10),
strconv.FormatInt(success, 10),
strconv.FormatInt(errCount, 10),
strconv.FormatInt(prompt, 10),
strconv.FormatInt(compl, 10),
strconv.FormatInt(tokens, 10),
strconv.FormatInt(avgLat, 10),
strconv.FormatInt(latMax, 10),
createdAt,
})
}
cw.Flush()
return
}
snap := g.stats.Snapshot(limit, key)
keyNames := map[string]string{}
for _, k := range g.core.ListKeys() {
keyNames[keyID(k.Key)] = k.Name
keyNames[k.Key] = k.Name
}
snap["key_names"] = keyNames
writeJSON(w, http.StatusOK, snap)