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

@ -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) {