feat(logs): persist business events

This commit is contained in:
2026-08-19 21:03:56 +08:00
parent 62c811fea2
commit 4474069a29
19 changed files with 623 additions and 32 deletions
+100
View File
@@ -0,0 +1,100 @@
package plugin
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
func (a *App) businessEvents(query url.Values) ManagementResponse {
limit, _ := strconv.Atoi(query.Get("limit"))
store, ok := a.currentStore()
if !ok {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "业务日志数据库尚未初始化")
}
events, err := store.ListBusinessEvents(context.Background(), limit)
if err != nil {
return managementError(http.StatusInternalServerError, "database_error", err.Error())
}
return jsonManagementResponse(http.StatusOK, map[string]any{"events": events})
}
func (a *App) auditFailedManagement(response ManagementResponse, event string) ManagementResponse {
if response.StatusCode < http.StatusBadRequest {
return response
}
store, ok := a.currentStore()
if !ok {
return response
}
status := "失败"
var payload struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(response.Body, &payload) == nil {
message := strings.TrimSpace(payload.Error.Message)
message = strings.Join(strings.Fields(message), " ")
if len([]rune(message)) > 160 {
message = string([]rune(message)[:160])
}
if message != "" {
status += "" + message
}
}
_ = store.RecordBusinessEvent(context.Background(), event, status, time.Now().UTC())
return response
}
func keyEvent(body []byte, action string) string {
var value struct {
ID string `json:"id"`
Name string `json:"name"`
}
_ = json.Unmarshal(body, &value)
target := strings.TrimSpace(value.Name)
if target == "" {
target = strings.TrimSpace(value.ID)
}
if target == "" {
return "管理员" + action + "用户 Key"
}
return fmt.Sprintf("管理员%s用户 `%s` 的 Key", action, safeEventValue(target))
}
func priceEvent(body []byte, action string) string {
var value struct {
Model string `json:"model"`
}
_ = json.Unmarshal(body, &value)
model := strings.TrimSpace(value.Model)
if model == "" {
return "管理员" + action + "模型价格"
}
return fmt.Sprintf("管理员%s模型 `%s` 的价格", action, safeEventValue(model))
}
func billingResetEvent(body []byte) string {
var value struct {
ID string `json:"id"`
All bool `json:"all"`
}
_ = json.Unmarshal(body, &value)
if value.All {
return "管理员手动重置全部用户额度"
}
if strings.TrimSpace(value.ID) == "" {
return "管理员手动重置用户额度"
}
return fmt.Sprintf("管理员手动重置用户 `%s` 的额度", safeEventValue(value.ID))
}
func safeEventValue(value string) string {
return strings.ReplaceAll(strings.TrimSpace(value), "`", "")
}
+10 -2
View File
@@ -286,15 +286,23 @@ func managedKeyResponse(key managedaccess.ManagedKey, state managedaccess.Billin
func (a *App) resetManagedKeyBilling(body []byte) ManagementResponse {
var request struct {
ID string `json:"id"`
ID string `json:"id"`
All bool `json:"all"`
}
if err := json.Unmarshal(body, &request); err != nil || strings.TrimSpace(request.ID) == "" {
if err := json.Unmarshal(body, &request); err != nil || !request.All && strings.TrimSpace(request.ID) == "" {
return managementError(http.StatusBadRequest, "invalid_request", "缺少有效的 Key ID")
}
store, ok := a.currentStore()
if !ok {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "额度数据库尚未初始化")
}
if request.All {
count, err := store.ResetAllBilling(context.Background(), time.Now().UTC())
if err != nil {
return managementError(http.StatusBadRequest, "reset_failed", err.Error())
}
return jsonManagementResponse(http.StatusOK, map[string]int{"reset_count": count})
}
state, err := store.ResetBilling(context.Background(), request.ID, time.Now().UTC())
if err != nil {
return managementError(http.StatusBadRequest, "reset_failed", err.Error())
+14 -8
View File
@@ -30,6 +30,7 @@ const (
routeModels = "/model-suggestions"
routeBillingReset = "/billing-reset"
routeBillingLedger = "/billing-ledger"
routeEvents = "/events"
resourceUI = "/ui"
)
@@ -41,6 +42,7 @@ var resourceAssets = []string{
"/app/features/keys.js",
"/app/features/pricing.js",
"/app/features/usage.js",
"/app/features/logs.js",
"/styles/base.css",
"/styles/keys.css",
"/styles/layout.css",
@@ -70,6 +72,7 @@ func managementRegistration() ManagementRegistrationResponse {
{Method: http.MethodGet, Path: managementBase + routeModels, Description: "查看模型建议。"},
{Method: http.MethodPost, Path: managementBase + routeBillingReset, Description: "立即重置 Key 额度。"},
{Method: http.MethodGet, Path: managementBase + routeBillingLedger, Description: "查看 Key 额度账目。"},
{Method: http.MethodGet, Path: managementBase + routeEvents, Description: "查看业务事件日志。"},
},
Resources: []ResourceRoute{
{Path: resourceBase + resourceUI, Menu: "用量记录", Description: "查看 CPA 最近收到的请求用量。"},
@@ -117,13 +120,13 @@ func (a *App) handleManagement(raw []byte) ([]byte, error) {
case http.MethodGet:
return OKEnvelope(a.listPrices())
case http.MethodPut:
return OKEnvelope(a.putPrice(req.Body))
return OKEnvelope(a.auditFailedManagement(a.putPrice(req.Body), priceEvent(req.Body, "保存")))
case http.MethodDelete:
return OKEnvelope(a.deletePrice(req.Body))
return OKEnvelope(a.auditFailedManagement(a.deletePrice(req.Body), priceEvent(req.Body, "删除")))
}
}
if req.Method == http.MethodPost && path == managementBase+routePriceImport {
return OKEnvelope(a.importCatalogPrice(req.Body))
return OKEnvelope(a.auditFailedManagement(a.importCatalogPrice(req.Body), priceEvent(req.Body, "导入")))
}
if req.Method == http.MethodGet && path == managementBase+routeCatalog {
return OKEnvelope(a.searchPriceCatalog(req.Query))
@@ -132,18 +135,18 @@ func (a *App) handleManagement(raw []byte) ([]byte, error) {
return OKEnvelope(a.refreshPriceCatalog())
}
if req.Method == http.MethodPost && path == managementBase+routeCatalogApply {
return OKEnvelope(a.applyCatalogChanges(req.Body))
return OKEnvelope(a.auditFailedManagement(a.applyCatalogChanges(req.Body), "管理员批量更新模型价格"))
}
if path == managementBase+routeKeys {
switch req.Method {
case http.MethodGet:
return OKEnvelope(a.listManagedKeys(req.Query.Get("include_archived") == "1"))
case http.MethodPost:
return OKEnvelope(a.createManagedKey(req.Body))
return OKEnvelope(a.auditFailedManagement(a.createManagedKey(req.Body), keyEvent(req.Body, "创建")))
case http.MethodPatch:
return OKEnvelope(a.updateManagedKey(req.Body))
return OKEnvelope(a.auditFailedManagement(a.updateManagedKey(req.Body), keyEvent(req.Body, "修改")))
case http.MethodDelete:
return OKEnvelope(a.archiveManagedKey(req.Body))
return OKEnvelope(a.auditFailedManagement(a.archiveManagedKey(req.Body), keyEvent(req.Body, "归档")))
}
}
if req.Method == http.MethodGet && path == managementBase+routeKeyStats {
@@ -156,11 +159,14 @@ func (a *App) handleManagement(raw []byte) ([]byte, error) {
return OKEnvelope(a.modelSuggestions())
}
if req.Method == http.MethodPost && path == managementBase+routeBillingReset {
return OKEnvelope(a.resetManagedKeyBilling(req.Body))
return OKEnvelope(a.auditFailedManagement(a.resetManagedKeyBilling(req.Body), billingResetEvent(req.Body)))
}
if req.Method == http.MethodGet && path == managementBase+routeBillingLedger {
return OKEnvelope(a.managedKeyLedger(req.Query))
}
if req.Method == http.MethodGet && path == managementBase+routeEvents {
return OKEnvelope(a.businessEvents(req.Query))
}
return OKEnvelope(jsonManagementResponse(http.StatusNotFound, map[string]any{
"error": map[string]string{
"code": "not_found",
+36 -1
View File
@@ -54,7 +54,7 @@ func TestManagementRegistrationDeclaresUsageAPIAndUI(t *testing.T) {
if err := json.Unmarshal(envelope.Result, &registration); err != nil {
t.Fatal(err)
}
if len(registration.Routes) != 18 || registration.Routes[0].Path != managementBase+routeUsage || registration.Routes[1].Path != managementBase+routeUsageSummary || registration.Routes[2].Path != managementBase+routePrices {
if len(registration.Routes) != 19 || registration.Routes[0].Path != managementBase+routeUsage || registration.Routes[1].Path != managementBase+routeUsageSummary || registration.Routes[2].Path != managementBase+routePrices || registration.Routes[18].Path != managementBase+routeEvents {
t.Fatalf("unexpected management routes: %+v", registration.Routes)
}
if len(registration.Resources) != 1+len(resourceAssets) || registration.Resources[0].Path != resourceBase+resourceUI {
@@ -67,6 +67,41 @@ func TestManagementRegistrationDeclaresUsageAPIAndUI(t *testing.T) {
}
}
func TestBusinessEventsPersistSuccessfulAndFailedManagementActions(t *testing.T) {
app := NewApp()
defer app.Shutdown()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil {
t.Fatal(err)
}
created := managementCallBody(t, app, http.MethodPost, managementBase+routeKeys, []byte(`{"name":"Alice","secret":"alice-000000"}`))
if created.StatusCode != http.StatusCreated {
t.Fatalf("create status=%d body=%s", created.StatusCode, created.Body)
}
failed := managementCallBody(t, app, http.MethodPost, managementBase+routeKeys, []byte(`{"name":"","secret":"secret-000000"}`))
if failed.StatusCode != http.StatusBadRequest {
t.Fatalf("failed create status=%d body=%s", failed.StatusCode, failed.Body)
}
resetAll := managementCallBody(t, app, http.MethodPost, managementBase+routeBillingReset, []byte(`{"all":true}`))
if resetAll.StatusCode != http.StatusOK || !strings.Contains(string(resetAll.Body), `"reset_count":2`) {
t.Fatalf("reset all status=%d body=%s", resetAll.StatusCode, resetAll.Body)
}
events := managementCall(t, app, http.MethodGet, managementBase+routeEvents)
body := string(events.Body)
if events.StatusCode != http.StatusOK || !strings.Contains(body, "管理员创建用户 `Alice` 的 Key") || !strings.Contains(body, `"status":"成功"`) || !strings.Contains(body, `"status":"失败:Key 名称必须为 1-64 个字符"`) || !strings.Contains(body, `"status":"成功,处理 2 个用户"`) {
t.Fatalf("events status=%d body=%s", events.StatusCode, body)
}
if strings.Contains(body, "alice-000000") || strings.Contains(body, "secret-000000") {
t.Fatalf("business events exposed a key: %s", body)
}
readOnly := managementCallRequest(t, app, ManagementRequest{Method: http.MethodGet, Path: resourceBase + resourceUI, Query: url.Values{"view": {"events"}}})
if readOnly.StatusCode != http.StatusOK || !strings.Contains(string(readOnly.Body), "管理员创建用户 `Alice` 的 Key") {
t.Fatalf("read-only events status=%d body=%s", readOnly.StatusCode, readOnly.Body)
}
}
func TestReadOnlyResourceReturnsRealDataWithoutSecrets(t *testing.T) {
app := NewApp()
defer app.Shutdown()
+2
View File
@@ -41,6 +41,8 @@ func (a *App) readOnlyResponse(query url.Values) ManagementResponse {
return a.modelSuggestions()
case "key-stats":
return a.readOnlyManagedKeyStats(strings.TrimSpace(query.Get("id")))
case "events":
return a.businessEvents(query)
default:
return managementError(http.StatusBadRequest, "invalid_view", "只读资源类型不存在")
}