feat: add permanent deletion for revoked keys

DELETE /admin/api/keys/{id}/purge removes the key record; it requires the key to be revoked first and keeps historical logs. The keys page swaps the revoke button for a delete button once a key is revoked.
This commit is contained in:
2026-10-25 18:40:32 +08:00
parent 293f7f524a
commit 578afe509c
4 changed files with 86 additions and 4 deletions
+2 -2
View File
@@ -141,11 +141,11 @@ curl http://127.0.0.1:8080/v1/chat/completions \
统计包含请求数、成功/失败数、输入、输出、缓存读取、缓存写入 Token,支持按 Key 和时间范围筛选,以及按天、渠道/模型汇总。调用记录保存请求模型与真实模型,不保存提示词、响应正文或工具参数。
输入 Token 统一为包含缓存的总输入,缓存读取/写入为细分项,不能再重复相加。OpenAI 的缓存通常包含在输入内;Anthropic 普通输入与缓存字段相加得到总输入。缺失 usage 标记未知;流中断时已收到的用量可能不完整。撤销 Key 不删除历史记录。
输入 Token 统一为包含缓存的总输入,缓存读取/写入为细分项,不能再重复相加。OpenAI 的缓存通常包含在输入内;Anthropic 普通输入与缓存字段相加得到总输入。缺失 usage 标记未知;流中断时已收到的用量可能不完整。撤销或删除 Key 都不删除历史记录。
后端统一保存 UTC 时间。页面记录、Key 创建/到期时间按浏览器本地时区展示;时间筛选、今天/近 7/30 天和有效期输入按浏览器本地时间解释,并转换为 UTC 提交。无需时区配置。每日用量也按浏览器时区的自然日汇总:前端自动传递 IANA 时区名称,后端仅在查询时调整分组边界,支持夏令时且不改变 UTC 存储。统计 API 的 `timezone` 参数省略时默认 UTC,非法时区返回 400。
汇总仅累计完整已知用量,未知/中断请求另行计数。统计由数据库聚合,不再将全部原始日志载入应用内存;查询有超时及结果规模限制,大量历史数据建议按时间范围筛选。可通过 `storage.log_retention_days` 显式开启日志清理,默认关闭。Key 创建后支持停用与撤销,修改授权需重新创建 Key。
汇总仅累计完整已知用量,未知/中断请求另行计数。统计由数据库聚合,不再将全部原始日志载入应用内存;查询有超时及结果规模限制,大量历史数据建议按时间范围筛选。可通过 `storage.log_retention_days` 显式开启日志清理,默认关闭。Key 创建后支持停用与撤销;已撤销的 Key 可进一步删除(永久删除 Key 记录,历史日志保留),修改授权需重新创建 Key。
### 异步日志持久化
+21
View File
@@ -203,6 +203,27 @@ func (s *Server) keys(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) keyID(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/admin/api/keys/")
if purgeID, ok := strings.CutSuffix(id, "/purge"); ok {
if r.Method != "DELETE" {
errJSON(w, 405, "method not allowed")
return
}
var revoked bool
if e := s.db.QueryRow("SELECT revoked FROM keys WHERE id=?", purgeID).Scan(&revoked); e == sql.ErrNoRows {
errJSON(w, 404, "not found")
return
} else if e != nil {
errJSON(w, 500, "database error")
return
}
if !revoked {
errJSON(w, 409, "revoke the key before deleting")
return
}
res, e := s.db.Exec("DELETE FROM keys WHERE id=?", purgeID)
mutation(w, res, e)
return
}
if r.Method == "DELETE" {
res, e := s.db.Exec("UPDATE keys SET enabled=0,revoked=1 WHERE id=?", id)
mutation(w, res, e)
+50
View File
@@ -252,6 +252,56 @@ func TestKeyRevocationAndProxy(t *testing.T) {
if n != 1 {
t.Fatal(n)
}
w = call(s, "DELETE", "/admin/api/keys/"+id+"/purge", "", c)
if w.Code != 200 {
t.Fatal(w.Body.String())
}
s.db.QueryRow("SELECT count(*) FROM keys WHERE id=?", id).Scan(&n)
if n != 0 {
t.Fatal("purged key still stored", n)
}
w = call(s, "DELETE", "/admin/api/keys/"+id+"/purge", "", c)
if w.Code != 404 {
t.Fatal("purged key not reported missing", w.Code)
}
s.db.QueryRow("SELECT count(*) FROM logs").Scan(&n)
if n != 1 {
t.Fatal("purge removed history", n)
}
}
func TestKeyPurgeRequiresRevocation(t *testing.T) {
s := testServer(t)
c := loginCookie(t, s)
w := call(s, "POST", "/admin/api/keys", `{"name":"gone","all_channels":true}`, c)
var key map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &key); err != nil || w.Code != 200 {
t.Fatal(w.Code, w.Body.String(), err)
}
var id string
if err := s.db.QueryRow("SELECT id FROM keys WHERE name='gone'").Scan(&id); err != nil {
t.Fatal(err)
}
if w = call(s, "DELETE", "/admin/api/keys/"+id+"/purge", "", c); w.Code != 409 {
t.Fatal("purged active key", w.Code, w.Body.String())
}
if w = call(s, "DELETE", "/admin/api/keys/"+id, "", c); w.Code != 200 {
t.Fatal(w.Body.String())
}
if w = call(s, "DELETE", "/admin/api/keys/"+id+"/purge", "", c); w.Code != 200 {
t.Fatal(w.Body.String())
}
w = call(s, "GET", "/admin/api/keys", "", c)
if strings.Contains(w.Body.String(), `"gone"`) {
t.Fatal("purged key still listed", w.Body.String())
}
r := httptest.NewRequest("GET", "/v1/models", nil)
r.Header.Set("Authorization", "Bearer "+key["key"])
w = httptest.NewRecorder()
s.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Fatal("purged key still authenticates", w.Code)
}
}
func TestStatsUnknownAndFilters(t *testing.T) {
s := testServer(t)
+13 -2
View File
@@ -71,7 +71,16 @@ export function KeysPage({ keys, stats, channels, timezone, busy, error, notice,
action(async () => {
await api('/keys/' + encodeURIComponent(k.id), 'DELETE')
refresh()
notify('Key 已撤销,历史记录已保留。')
notify('Key 已撤销,历史记录已保留;如需彻底移除可点击“删除”。')
})
}
function purge(k: Key) {
if (!confirm(`确定永久删除 Key“${k.name}”?删除后不可恢复,历史调用记录会保留。`)) return
action(async () => {
await api('/keys/' + encodeURIComponent(k.id) + '/purge', 'DELETE')
refresh()
notify('Key 已删除,历史记录已保留。')
})
}
@@ -108,7 +117,9 @@ export function KeysPage({ keys, stats, channels, timezone, busy, error, notice,
<>{time(k.expires_at) === '—' ? '永不过期' : time(k.expires_at)}<small className="sub">创建于 {time(k.created_at)}</small></>,
<div className="actions">
<button disabled={busy || k.revoked} onClick={() => toggle(k)}>{k.enabled ? '停用' : '启用'}</button>
<button className="danger" disabled={busy} onClick={() => revoke(k)}>撤销</button>
{k.revoked
? <button className="danger" disabled={busy} onClick={() => purge(k)}>删除</button>
: <button className="danger" disabled={busy} onClick={() => revoke(k)}>撤销</button>}
</div>,
]
})}