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:
@@ -141,11 +141,11 @@ curl http://127.0.0.1:8080/v1/chat/completions \
|
|||||||
|
|
||||||
统计包含请求数、成功/失败数、输入、输出、缓存读取、缓存写入 Token,支持按 Key 和时间范围筛选,以及按天、渠道/模型汇总。调用记录保存请求模型与真实模型,不保存提示词、响应正文或工具参数。
|
统计包含请求数、成功/失败数、输入、输出、缓存读取、缓存写入 Token,支持按 Key 和时间范围筛选,以及按天、渠道/模型汇总。调用记录保存请求模型与真实模型,不保存提示词、响应正文或工具参数。
|
||||||
|
|
||||||
输入 Token 统一为包含缓存的总输入,缓存读取/写入为细分项,不能再重复相加。OpenAI 的缓存通常包含在输入内;Anthropic 普通输入与缓存字段相加得到总输入。缺失 usage 标记未知;流中断时已收到的用量可能不完整。撤销 Key 不删除历史记录。
|
输入 Token 统一为包含缓存的总输入,缓存读取/写入为细分项,不能再重复相加。OpenAI 的缓存通常包含在输入内;Anthropic 普通输入与缓存字段相加得到总输入。缺失 usage 标记未知;流中断时已收到的用量可能不完整。撤销或删除 Key 都不删除历史记录。
|
||||||
|
|
||||||
后端统一保存 UTC 时间。页面记录、Key 创建/到期时间按浏览器本地时区展示;时间筛选、今天/近 7/30 天和有效期输入按浏览器本地时间解释,并转换为 UTC 提交。无需时区配置。每日用量也按浏览器时区的自然日汇总:前端自动传递 IANA 时区名称,后端仅在查询时调整分组边界,支持夏令时且不改变 UTC 存储。统计 API 的 `timezone` 参数省略时默认 UTC,非法时区返回 400。
|
后端统一保存 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。
|
||||||
|
|
||||||
### 异步日志持久化
|
### 异步日志持久化
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,27 @@ func (s *Server) keys(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
func (s *Server) keyID(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/")
|
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" {
|
if r.Method == "DELETE" {
|
||||||
res, e := s.db.Exec("UPDATE keys SET enabled=0,revoked=1 WHERE id=?", id)
|
res, e := s.db.Exec("UPDATE keys SET enabled=0,revoked=1 WHERE id=?", id)
|
||||||
mutation(w, res, e)
|
mutation(w, res, e)
|
||||||
|
|||||||
@@ -252,6 +252,56 @@ func TestKeyRevocationAndProxy(t *testing.T) {
|
|||||||
if n != 1 {
|
if n != 1 {
|
||||||
t.Fatal(n)
|
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) {
|
func TestStatsUnknownAndFilters(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
|
|||||||
+13
-2
@@ -71,7 +71,16 @@ export function KeysPage({ keys, stats, channels, timezone, busy, error, notice,
|
|||||||
action(async () => {
|
action(async () => {
|
||||||
await api('/keys/' + encodeURIComponent(k.id), 'DELETE')
|
await api('/keys/' + encodeURIComponent(k.id), 'DELETE')
|
||||||
refresh()
|
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></>,
|
<>{time(k.expires_at) === '—' ? '永不过期' : time(k.expires_at)}<small className="sub">创建于 {time(k.created_at)}</small></>,
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button disabled={busy || k.revoked} onClick={() => toggle(k)}>{k.enabled ? '停用' : '启用'}</button>
|
<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>,
|
</div>,
|
||||||
]
|
]
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user