feat(logs): persist business events
This commit is contained in:
@@ -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), "`", "")
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestManagementRegistrationDeclaresUsageAPIAndUI(t *testing.T) {
|
||||
if err := json.Unmarshal(envelope.Result, ®istration); 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()
|
||||
|
||||
@@ -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", "只读资源类型不存在")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user