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)

View File

@ -52,7 +52,8 @@ func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
}
func (g *Gateway) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// access log wraps all requests (auth, unauthenticated, login).
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// login entry point + login/logout API are the only unauthenticated routes
if r.URL.Path == "/login" || r.URL.Path == "/api/login" || r.URL.Path == "/api/logout" {
g.routes(w, r)
@ -60,6 +61,58 @@ func (g *Gateway) Handler() http.Handler {
}
g.auth(http.HandlerFunc(g.routes)).ServeHTTP(w, r)
})
return g.logAccess(inner)
}
// logAccess wraps an http.Handler so every response is written to the audit
// file as an access event (method, path, status, latency, key).
func (g *Gateway) logAccess(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t0 := time.Now()
sr := &statusRecorder{ResponseWriter: w}
key := ""
if h := r.Header.Get("Authorization"); h != "" {
parts := strings.SplitN(h, " ", 2)
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
key = parts[1]
}
}
if key == "" {
key = r.URL.Query().Get("api_key")
}
if key == "" {
if c, err := r.Cookie("gw_key"); err == nil {
key = c.Value
}
}
next.ServeHTTP(sr, r)
g.stats.AppendAudit("access", map[string]interface{}{
"method": r.Method,
"path": r.URL.RequestURI(),
"status": sr.code,
"lat_ms": time.Since(t0).Milliseconds(),
"key": keyID(key),
})
})
}
type statusRecorder struct {
http.ResponseWriter
code int
}
func (s *statusRecorder) WriteHeader(code int) {
if s.code == 0 {
s.code = code
}
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Write(b []byte) (int, error) {
if s.code == 0 {
s.code = http.StatusOK
}
return s.ResponseWriter.Write(b)
}
func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
@ -345,13 +398,20 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(host, ":") {
host = "127.0.0.1" + host
}
// gateway_keys: return only the current authenticated user's key so the
// connection snippet on the home page always shows the correct key
// (previously every user saw the first admin key in the list).
myKey := reqKey(r.Context())
ks := make([]config.GWKey, 0, len(g.core.ListKeys()))
for _, k := range g.core.ListKeys() {
ks = append(ks, config.GWKey{
Key: k.Key,
Role: k.Role,
Name: k.Name,
})
if k.Key == myKey {
ks = append(ks, config.GWKey{
Key: k.Key,
Role: k.Role,
Name: k.Name,
})
break
}
}
scheme := "http"
if r.TLS != nil {
@ -361,14 +421,38 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
if baseURL == "" {
baseURL = scheme + "://" + host + "/v1"
}
writeJSON(w, http.StatusOK, map[string]interface{}{
models := g.core.Registry().ModelList()
if reqRole(r.Context()) != "admin" {
models = g.scopedModelList(r.Context(), models)
}
resp := map[string]interface{}{
"default_model": g.core.DefaultModel(),
"models": g.core.Registry().ModelList(),
"sources": g.core.Registry().Status(),
"adapters": g.core.ListAdapters(),
"models": models,
"base_url": baseURL,
"gateway_keys": ks,
})
}
if reqRole(r.Context()) == "admin" {
resp["sources"] = g.core.Registry().Status()
resp["adapters"] = g.core.ListAdapters()
}
writeJSON(w, http.StatusOK, resp)
}
// scopedModelList filters the full model set to only those allowed by the
// request's gateway key (used for users with a restricted model scope).
func (g *Gateway) scopedModelList(ctx context.Context, full []string) []string {
allow := g.allowedModels(ctx)
if allow == nil {
return full
}
out := make([]string, 0, len(allow))
for _, m := range allow {
if m.Model == "" {
continue
}
out = append(out, m.Model)
}
return out
}
func writeError(w http.ResponseWriter, code int, errType, msg string) {

View File

@ -196,6 +196,29 @@ func (s *Stats) Record(r Req) {
}
}
// AppendAudit writes a generic event line (access log entry, login event,
// config change, …) to the same audit file without touching the aggregates.
func (s *Stats) AppendAudit(obj string, data map[string]interface{}) {
s.mu.Lock()
path := s.auditPath
s.mu.Unlock()
if path == "" {
return
}
row := map[string]interface{}{"obj": obj, "time": time.Now().UnixMilli()}
for k, v := range data {
row[k] = v
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
if b, err := json.Marshal(row); err == nil {
_, _ = f.Write(append(b, '\n'))
}
}
// ModelTokens returns the tokens consumed per model for one gateway key id
// (used for per-model token quota enforcement).
func (s *Stats) ModelTokens(key string) map[string]int64 {

View File

@ -473,7 +473,7 @@ const STR = {
sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效',
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'该源暂无模型',
kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟',
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载',
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载', expKeysCsv:'导出密钥用量',
thModel:'模型', thSrc:'源', thKey:'密钥', thReqs:'请求', thOk:'成功', thErr:'失败',
thPrompt:'输入 Tokens', thCompl:'输出 Tokens', thAvgLat:'平均延迟', thMaxLat:'最长延迟',
thTime:'时间', thType:'类型', thStatus:'状态', thLatMs:'延迟',
@ -635,12 +635,12 @@ async function renderStatus() {
const s = await api('/api/status');
const base = window._base = s.base_url || location.origin + '/v1';
const key = window._key = (s.gateway_keys && s.gateway_keys[0]) || '';
const srcRows = s.sources.map(x =>
const srcRows = s.sources ? s.sources.map(x =>
`<tr><td>${x.live_available ? `<span class="tag tag-green"><i class="net-dot"></i>${t('online')}</span>` : `<span class="tag tag-red" title="${esc(x.last_error || '')}"><i class="net-dot"></i>${t('offline')}</span>`}</td>
<td><b>${esc(x.name)}</b></td><td>${esc(x.adapter)}</td>
<td><div class="src-models">${x.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join('')}</div></td>
<td><span class="muted">${esc(x.base_url || '')}</span></td>
<td>${x.max_concurrent}</td></tr>`).join('');
<td>${x.max_concurrent}</td></tr>`).join('') : '';
$('#tab-status').innerHTML = `
<div class="kpis" id="kpi-row"></div>
<div class="card"><h2>${t('connTitle')}</h2>
@ -651,14 +651,14 @@ async function renderStatus() {
<label>${t('tModels')}</label>
<div class="chips" id="model-chips"></div>
</div>
<div class="card"><h2>${t('srcTitle')} (${s.sources.length})</h2>
${s.sources ? `<div class="card"><h2>${t('srcTitle')} (${s.sources.length})</h2>
<div class="tbl-wrap"><table><tr><th>${t('tConn')}</th><th>${t('tName')}</th><th>${t('tAdapter')}</th><th>${t('tModels')}</th><th>${t('tURL')}</th><th>${t('tConc')}</th></tr>${srcRows || `<tr><td colspan="6" class="empty">${t('srcEmpty')}</td></tr>`}</table></div>
</div>
</div>` : ''}
<div class="dash-row">
<div class="card"><h2>${t('dashModel')}</h2><div id="tb-model"></div></div>
<div class="card"><h2>${t('dashSrc')}</h2><div id="tb-src"></div></div>
${s.sources ? `<div class="card"><h2>${t('dashSrc')}</h2><div id="tb-src"></div></div>` : ''}
</div>
<div class="card"><h2>${t('dashKey')}</h2><div id="tb-key"></div></div>
<div class="card"><h2>${t('dashKey')}<span class="grow"></span><button class="ghost small" onclick="openExportModal()">${t('exportCsv')}</button></h2><div id="tb-key"></div></div>
<div class="card"><h2><span>${t('dashRecs')}</span><span class="grow"></span><button class="ghost small" onclick="openExportModal()">${t('exportCsv')}</button></h2>
<div class="filter-line">
<span class="muted">${t('recFilter')}</span>
@ -666,10 +666,10 @@ async function renderStatus() {
</div>
<div class="recs-scroll" id="tb-recs"></div>
</div>
<div class="card"><h2>${t('adTitle')} (${s.adapters.length})</h2>
${s.adapters ? `<div class="card"><h2>${t('adTitle')} (${s.adapters.length})</h2>
<div class="tbl-wrap"><table><tr><th>${t('tName')}</th><th>${t('tVersion')}</th></tr>
${s.adapters.map(a => `<tr><td>${esc(a.name)}</td><td>${esc(a.version || '')}</td></tr>`).join('')}</table></div>
</div>`;
</div>` : ''}`;
$('#conncfg').textContent = '';
showModelConfig(null, '');
$('#model-chips').innerHTML = s.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig(null,'${escAttr(m)}')">${esc(m)}</span>`).join('');
@ -700,6 +700,9 @@ function openExportModal() {
<div><label>${t('expEnd')}</label><input id="exp-to" type="date" value="${now.toISOString().slice(0, 10)}"></div></div>
<p><button onclick="downloadStatsCsvFromForm()">${t('expDownload')}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
<hr style="margin:12px 0">
<p><button onclick="downloadKeysCsv()">${t('expKeysCsv')}</button></p>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
</div>`;
document.body.appendChild(wrap);
}
@ -709,11 +712,9 @@ function downloadStatsCsv(from, to) {
location.href = '/api/stats?' + q.toString();
const w = $('#modal-wrap'); if (w) w.remove();
}
function downloadStatsCsvFromForm() {
const f = $('#exp-from').value, t0 = $('#exp-to').value;
const from = f ? new Date(f + 'T00:00:00').getTime() : 0;
const to = t0 ? new Date(t0 + 'T23:59:59').getTime() : Date.now();
downloadStatsCsv(from, to);
function downloadKeysCsv() {
location.href = '/api/stats?export=keys-csv' + (statsKeyF ? '&key=' + encodeURIComponent(statsKeyF) : '');
const w = $('#modal-wrap'); if (w) w.remove();
}
async function paintStats() {
try {