From 4474069a293379f2b81e366eef8751d60189bb7e Mon Sep 17 00:00:00 2001 From: chuan Date: Wed, 19 Aug 2026 21:03:56 +0800 Subject: [PATCH] feat(logs): persist business events --- internal/plugin/event_management.go | 100 ++++++++++++++++++++ internal/plugin/key_management.go | 12 ++- internal/plugin/management.go | 22 +++-- internal/plugin/management_test.go | 37 +++++++- internal/plugin/readonly_management.go | 2 + internal/repository/sqlite_access.go | 37 +++++++- internal/repository/sqlite_billing.go | 106 +++++++++++++++++++++- internal/repository/sqlite_events.go | 93 +++++++++++++++++++ internal/repository/sqlite_events_test.go | 79 ++++++++++++++++ internal/repository/sqlite_pricing.go | 30 +++++- internal/repository/sqlite_usage.go | 9 ++ internal/web/app/core/runtime.js | 5 +- internal/web/app/features/keys.js | 19 ++-- internal/web/app/features/logs.js | 48 ++++++++++ internal/web/app/main.js | 3 +- internal/web/styles/usage.css | 5 + internal/web/ui.html | 2 +- internal/webdemo/fake_data.go | 31 +++++++ internal/webdemo/fake_handler.go | 15 +++ 19 files changed, 623 insertions(+), 32 deletions(-) create mode 100644 internal/plugin/event_management.go create mode 100644 internal/repository/sqlite_events.go create mode 100644 internal/repository/sqlite_events_test.go create mode 100644 internal/web/app/features/logs.js diff --git a/internal/plugin/event_management.go b/internal/plugin/event_management.go new file mode 100644 index 0000000..f3ba84e --- /dev/null +++ b/internal/plugin/event_management.go @@ -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), "`", "") +} diff --git a/internal/plugin/key_management.go b/internal/plugin/key_management.go index 02a67b5..16782c5 100644 --- a/internal/plugin/key_management.go +++ b/internal/plugin/key_management.go @@ -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()) diff --git a/internal/plugin/management.go b/internal/plugin/management.go index 4b94746..5eac648 100644 --- a/internal/plugin/management.go +++ b/internal/plugin/management.go @@ -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", diff --git a/internal/plugin/management_test.go b/internal/plugin/management_test.go index 8fa56d5..9ea1850 100644 --- a/internal/plugin/management_test.go +++ b/internal/plugin/management_test.go @@ -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() diff --git a/internal/plugin/readonly_management.go b/internal/plugin/readonly_management.go index 53dcef6..32e889d 100644 --- a/internal/plugin/readonly_management.go +++ b/internal/plugin/readonly_management.go @@ -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", "只读资源类型不存在") } diff --git a/internal/repository/sqlite_access.go b/internal/repository/sqlite_access.go index 03c0fff..9dd93db 100644 --- a/internal/repository/sqlite_access.go +++ b/internal/repository/sqlite_access.go @@ -75,6 +75,13 @@ UPDATE usage_records SET managed_key_id = ?, key_alias = ? WHERE managed_key_id = '' AND api_key = ?`, key.ID, key.Name, key.Secret); err != nil { return fmt.Errorf("关联历史用量: %w", err) } + event := "管理员创建用户 " + quotedName(key.Name) + " 的 Key" + if key.ID == "key_default" { + event = "系统初始化用户 " + quotedName(key.Name) + " 的 Key" + } + if err := insertBusinessEvent(ctx, tx, event, BusinessEventSucceeded, key.CreatedAt); err != nil { + return err + } if err = tx.Commit(); err != nil { return fmt.Errorf("提交创建 Key: %w", err) } @@ -110,6 +117,13 @@ WHERE id=? AND status <> 'archived'`, key.Name, key.Status, key.RouteMode, key.U if err := replaceManagedKeyModels(ctx, tx, key.ID, key.Models); err != nil { return err } + event := "管理员修改用户 " + quotedName(key.Name) + " 的 Key" + if current.Name != key.Name { + event = "管理员修改用户名称:" + quotedName(current.Name) + " → " + quotedName(key.Name) + } + if err := insertBusinessEvent(ctx, tx, event, BusinessEventSucceeded, key.UpdatedAt); err != nil { + return err + } if err = tx.Commit(); err != nil { return fmt.Errorf("提交更新 Key: %w", err) } @@ -117,14 +131,33 @@ WHERE id=? AND status <> 'archived'`, key.Name, key.Status, key.RouteMode, key.U } func (r *SQLiteUsageRepository) ArchiveManagedKey(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, ` -UPDATE managed_keys SET status='archived', updated_at=? WHERE id=? AND status <> 'archived'`, formatTime(time.Now().UTC()), id) + now := time.Now().UTC() + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("开始归档 Key: %w", err) + } + defer func() { _ = tx.Rollback() }() + var name string + if err := tx.QueryRowContext(ctx, `SELECT name FROM managed_keys WHERE id=? AND status <> 'archived'`, id).Scan(&name); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrManagedKeyNotFound + } + return fmt.Errorf("查询归档 Key: %w", err) + } + result, err := tx.ExecContext(ctx, ` +UPDATE managed_keys SET status='archived', updated_at=? WHERE id=? AND status <> 'archived'`, formatTime(now), id) if err != nil { return fmt.Errorf("归档 Key: %w", err) } if affected, _ := result.RowsAffected(); affected == 0 { return ErrManagedKeyNotFound } + if err := insertBusinessEvent(ctx, tx, "管理员归档用户 "+quotedName(name)+" 的 Key", BusinessEventSucceeded, now); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交归档 Key: %w", err) + } return nil } diff --git a/internal/repository/sqlite_billing.go b/internal/repository/sqlite_billing.go index 2c3ea12..65b03d0 100644 --- a/internal/repository/sqlite_billing.go +++ b/internal/repository/sqlite_billing.go @@ -105,6 +105,23 @@ INSERT INTO billing_ledger (event_key, managed_key_id, cycle_sequence, kind, amo return current, fmt.Errorf("记录额度调整: %w", err) } } + settingsChanged := settings.QuotaMicros != current.QuotaMicros || + settings.ResetPeriod != current.ResetPeriod || + settings.MaxConcurrency != current.MaxConcurrency || + !sameOptionalTime(nextReset, current.NextResetAt) + if settingsChanged { + var name string + if err := tx.QueryRowContext(ctx, `SELECT name FROM managed_keys WHERE id=?`, keyID).Scan(&name); err != nil { + return current, fmt.Errorf("查询额度用户: %w", err) + } + event := "管理员修改用户 " + quotedName(name) + " 的额度设置" + if settings.QuotaMicros != current.QuotaMicros { + event = fmt.Sprintf("管理员调整用户 %s 的额度:$%s → $%s", quotedName(name), decimalMicros(current.QuotaMicros), decimalMicros(settings.QuotaMicros)) + } + if err := insertBusinessEvent(ctx, tx, event, BusinessEventSucceeded, now); err != nil { + return current, err + } + } state, err := scanBillingState(ctx, tx, keyID) if err != nil { return state, err @@ -138,7 +155,7 @@ FROM billing_accounts WHERE managed_key_id=?`, keyID).Scan(&period, &anchorDay, next = &value anchorDay = int64(now.In(shanghaiLocation()).Day()) } - if err := resetBillingCycle(ctx, tx, keyID, sequence, quota, now, next, int(anchorDay)); err != nil { + if err := resetBillingCycle(ctx, tx, keyID, sequence, quota, now, next, int(anchorDay), "manual"); err != nil { return managedaccess.BillingState{}, err } state, err := scanBillingState(ctx, tx, keyID) @@ -151,6 +168,66 @@ FROM billing_accounts WHERE managed_key_id=?`, keyID).Scan(&period, &anchorDay, return state, nil } +func (r *SQLiteUsageRepository) ResetAllBilling(ctx context.Context, now time.Time) (int, error) { + if now.IsZero() { + now = time.Now().UTC() + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback() }() + type account struct { + id string + period string + anchorDay, sequence int64 + quota int64 + } + rows, err := tx.QueryContext(ctx, ` +SELECT a.managed_key_id, a.reset_period, a.reset_anchor_day, a.current_cycle_sequence, a.quota_micros +FROM billing_accounts a JOIN managed_keys k ON k.id=a.managed_key_id +WHERE k.status <> 'archived' ORDER BY k.created_at, k.id`) + if err != nil { + return 0, fmt.Errorf("查询待重置用户: %w", err) + } + accounts := make([]account, 0) + for rows.Next() { + var item account + if err := rows.Scan(&item.id, &item.period, &item.anchorDay, &item.sequence, &item.quota); err != nil { + _ = rows.Close() + return 0, fmt.Errorf("读取待重置用户: %w", err) + } + accounts = append(accounts, item) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return 0, fmt.Errorf("遍历待重置用户: %w", err) + } + if err := rows.Close(); err != nil { + return 0, fmt.Errorf("关闭待重置用户查询: %w", err) + } + for _, item := range accounts { + var next *time.Time + anchorDay := item.anchorDay + if item.period != managedaccess.ResetNone { + value := advanceReset(now, item.period, now.In(shanghaiLocation()).Day()) + next = &value + anchorDay = int64(now.In(shanghaiLocation()).Day()) + } + if err := resetBillingCycle(ctx, tx, item.id, item.sequence, item.quota, now, next, int(anchorDay), ""); err != nil { + return 0, err + } + } + status := fmt.Sprintf("成功,处理 %d 个用户", len(accounts)) + if err := insertBusinessEvent(ctx, tx, "管理员手动重置全部用户额度", status, now); err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + return len(accounts), nil +} + func (r *SQLiteUsageRepository) AuthorizeBilling(ctx context.Context, keyID, requestID string, now time.Time) (managedaccess.BillingState, error) { requestID = strings.TrimSpace(requestID) if requestID == "" { @@ -311,10 +388,10 @@ FROM billing_accounts WHERE managed_key_id=?`, keyID).Scan(&period, &nextRaw, &a boundary = future future = advanceReset(future, period, anchorDay) } - return resetBillingCycle(ctx, tx, keyID, sequence, quota, boundary, &future, anchorDay) + return resetBillingCycle(ctx, tx, keyID, sequence, quota, boundary, &future, anchorDay, "automatic") } -func resetBillingCycle(ctx context.Context, tx *sql.Tx, keyID string, sequence, quota int64, boundary time.Time, next *time.Time, anchorDay int) error { +func resetBillingCycle(ctx context.Context, tx *sql.Tx, keyID string, sequence, quota int64, boundary time.Time, next *time.Time, anchorDay int, auditMode string) error { if _, err := tx.ExecContext(ctx, ` UPDATE billing_cycles SET ended_at=? WHERE managed_key_id=? AND sequence=?`, formatTime(boundary), keyID, sequence); err != nil { return err @@ -339,7 +416,28 @@ INSERT OR IGNORE INTO billing_ledger (event_key, managed_key_id, cycle_sequence, balance_after_micros, occurred_at, created_at) VALUES (?, ?, ?, 'cycle_reset', ?, ?, ?, ?)`, fmt.Sprintf("reset:%s:%d", keyID, newSequence), keyID, newSequence, quota, quota, formatTime(boundary), formatTime(time.Now().UTC())) - return err + if err != nil { + return err + } + if auditMode == "" { + return nil + } + var name string + if err := tx.QueryRowContext(ctx, `SELECT name FROM managed_keys WHERE id=?`, keyID).Scan(&name); err != nil { + return fmt.Errorf("查询重置用户: %w", err) + } + event := "管理员手动重置用户 " + quotedName(name) + " 的额度" + if auditMode == "automatic" { + event = "系统执行用户 " + quotedName(name) + " 的额度自然重置" + } + return insertBusinessEvent(ctx, tx, event, BusinessEventSucceeded, boundary) +} + +func sameOptionalTime(left, right *time.Time) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return left.Equal(*right) } func validResetPeriod(period string) bool { diff --git a/internal/repository/sqlite_events.go b/internal/repository/sqlite_events.go new file mode 100644 index 0000000..3102785 --- /dev/null +++ b/internal/repository/sqlite_events.go @@ -0,0 +1,93 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +const BusinessEventSucceeded = "成功" + +type BusinessEvent struct { + ID int64 `json:"id"` + OccurredAt time.Time `json:"occurred_at"` + Event string `json:"event"` + Status string `json:"status"` +} + +func (r *SQLiteUsageRepository) RecordBusinessEvent(ctx context.Context, event, status string, occurredAt time.Time) error { + return insertBusinessEvent(ctx, r.db, event, status, occurredAt) +} + +func (r *SQLiteUsageRepository) ListBusinessEvents(ctx context.Context, limit int) ([]BusinessEvent, error) { + if limit < 1 || limit > 500 { + limit = 200 + } + rows, err := r.readDB.QueryContext(ctx, ` +SELECT id, occurred_at, event, status +FROM business_events ORDER BY occurred_at DESC, id DESC LIMIT ?`, limit) + if err != nil { + return nil, fmt.Errorf("查询业务日志: %w", err) + } + defer rows.Close() + events := make([]BusinessEvent, 0, limit) + for rows.Next() { + var item BusinessEvent + var occurredRaw string + if err := rows.Scan(&item.ID, &occurredRaw, &item.Event, &item.Status); err != nil { + return nil, fmt.Errorf("读取业务日志: %w", err) + } + item.OccurredAt, _ = time.Parse(time.RFC3339Nano, occurredRaw) + events = append(events, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("遍历业务日志: %w", err) + } + return events, nil +} + +type businessEventExecer interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +func insertBusinessEvent(ctx context.Context, executor businessEventExecer, event, status string, occurredAt time.Time) error { + event = strings.TrimSpace(event) + status = strings.TrimSpace(status) + if event == "" || status == "" { + return fmt.Errorf("业务日志事件和状态不能为空") + } + if occurredAt.IsZero() { + occurredAt = time.Now().UTC() + } + if _, err := executor.ExecContext(ctx, ` +INSERT INTO business_events (occurred_at, event, status) VALUES (?, ?, ?)`, + formatTime(occurredAt), event, status); err != nil { + return fmt.Errorf("写入业务日志: %w", err) + } + return nil +} + +func quotedName(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "未命名用户" + } + return "`" + strings.ReplaceAll(value, "`", "") + "`" +} + +func decimalMicros(value int64) string { + prefix := "" + if value < 0 { + prefix = "-" + value = -value + } + whole := value / 1_000_000 + fraction := fmt.Sprintf("%06d", value%1_000_000) + fraction = strings.TrimRight(fraction, "0") + if fraction == "" { + return prefix + fmt.Sprintf("%d", whole) + } + return prefix + fmt.Sprintf("%d.%s", whole, fraction) +} diff --git a/internal/repository/sqlite_events_test.go b/internal/repository/sqlite_events_test.go new file mode 100644 index 0000000..a3758c3 --- /dev/null +++ b/internal/repository/sqlite_events_test.go @@ -0,0 +1,79 @@ +package repository_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + managedaccess "billing/internal/access" + "billing/internal/repository" +) + +func TestBusinessEventsPersistKeyQuotaAndAutomaticResetActions(t *testing.T) { + path := filepath.Join(t.TempDir(), "usage.db") + store, err := repository.OpenSQLiteUsage(path) + if err != nil { + t.Fatal(err) + } + if _, err := store.BootstrapManagedKey(context.Background(), "default", "000000"); err != nil { + t.Fatal(err) + } + + now := time.Date(2026, 8, 19, 1, 0, 0, 0, time.UTC) + next := now.Add(time.Hour) + if _, err := store.UpdateBilling(context.Background(), "key_default", managedaccess.BillingSettings{ + QuotaMicros: 20_000_000, ResetPeriod: managedaccess.ResetDaily, NextResetAt: &next, MaxConcurrency: 4, + }, now); err != nil { + t.Fatal(err) + } + if _, err := store.BillingState(context.Background(), "key_default", next.Add(time.Minute)); err != nil { + t.Fatal(err) + } + if _, err := store.ResetBilling(context.Background(), "key_default", next.Add(2*time.Minute)); err != nil { + t.Fatal(err) + } + + events, err := store.ListBusinessEvents(context.Background(), 100) + if err != nil { + t.Fatal(err) + } + joined := businessEventText(events) + for _, expected := range []string{ + "系统初始化用户 `default` 的 Key", + "管理员调整用户 `default` 的额度:$0 → $20", + "系统执行用户 `default` 的额度自然重置", + "管理员手动重置用户 `default` 的额度", + } { + if !strings.Contains(joined, expected) { + t.Fatalf("missing %q in %s", expected, joined) + } + } + for _, event := range events { + if event.Status != repository.BusinessEventSucceeded { + t.Fatalf("unexpected status: %+v", event) + } + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + reopened, err := repository.OpenSQLiteUsage(path) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + persisted, err := reopened.ListBusinessEvents(context.Background(), 100) + if err != nil || len(persisted) != len(events) { + t.Fatalf("persisted events=%d want=%d err=%v", len(persisted), len(events), err) + } +} + +func businessEventText(events []repository.BusinessEvent) string { + values := make([]string, 0, len(events)) + for _, event := range events { + values = append(values, event.Event) + } + return strings.Join(values, "\n") +} diff --git a/internal/repository/sqlite_pricing.go b/internal/repository/sqlite_pricing.go index 342b8d2..b13eda8 100644 --- a/internal/repository/sqlite_pricing.go +++ b/internal/repository/sqlite_pricing.go @@ -125,10 +125,23 @@ func (r *SQLiteUsageRepository) UpsertPriceRecords(ctx context.Context, records return fmt.Errorf("开始保存模型价格: %w", err) } for _, record := range records { + var exists int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM model_prices WHERE model=?`, record.Policy.Model).Scan(&exists); err != nil { + _ = tx.Rollback() + return fmt.Errorf("检查模型价格: %w", err) + } if err := upsertPriceRecord(ctx, tx, record); err != nil { _ = tx.Rollback() return err } + action := "新增" + if exists > 0 { + action = "修改" + } + if err := insertBusinessEvent(ctx, tx, "管理员"+action+"模型 `"+strings.ReplaceAll(record.Policy.Model, "`", "")+"` 的价格", BusinessEventSucceeded, time.Now().UTC()); err != nil { + _ = tx.Rollback() + return err + } } if err := tx.Commit(); err != nil { return fmt.Errorf("提交模型价格: %w", err) @@ -319,8 +332,23 @@ WHERE id = ? AND cost_micros IS NULL`) } func (r *SQLiteUsageRepository) DeletePrice(ctx context.Context, model string) error { - if _, err := r.db.ExecContext(ctx, `DELETE FROM model_prices WHERE model = ?`, model); err != nil { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("开始删除模型价格: %w", err) + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, `DELETE FROM model_prices WHERE model = ?`, model) + if err != nil { return fmt.Errorf("删除模型价格: %w", err) } + if affected, _ := result.RowsAffected(); affected > 0 { + event := "管理员删除模型 `" + strings.ReplaceAll(strings.TrimSpace(model), "`", "") + "` 的价格" + if err := insertBusinessEvent(ctx, tx, event, BusinessEventSucceeded, time.Now().UTC()); err != nil { + return err + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交删除模型价格: %w", err) + } return nil } diff --git a/internal/repository/sqlite_usage.go b/internal/repository/sqlite_usage.go index 562f8ce..ddfab10 100644 --- a/internal/repository/sqlite_usage.go +++ b/internal/repository/sqlite_usage.go @@ -230,6 +230,15 @@ CREATE TABLE IF NOT EXISTS billing_ledger ( ); CREATE INDEX IF NOT EXISTS idx_billing_ledger_key ON billing_ledger(managed_key_id, id DESC); + +CREATE TABLE IF NOT EXISTS business_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + occurred_at TEXT NOT NULL, + event TEXT NOT NULL, + status TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_business_events_time +ON business_events(occurred_at DESC, id DESC); ` const ( diff --git a/internal/web/app/core/runtime.js b/internal/web/app/core/runtime.js index e9d7f69..8d3aa36 100644 --- a/internal/web/app/core/runtime.js +++ b/internal/web/app/core/runtime.js @@ -15,7 +15,8 @@ export const routes = Object.freeze({ upstreams: managementBase + "/upstreams", models: managementBase + "/model-suggestions", billingReset: managementBase + "/billing-reset", - billingLedger: managementBase + "/billing-ledger" + billingLedger: managementBase + "/billing-ledger", + events: managementBase + "/events" }); export const keyInput = document.querySelector("#key"); @@ -145,7 +146,7 @@ function readOnlyURL(managementURL) { const views = new Map([ [routes.usage, "usage"], [routes.usageSummary, "usage-summary"], [routes.prices, "prices"], [routes.catalog, "price-catalog"], [routes.keys, "keys"], [routes.keyStats, "key-stats"], - [routes.upstreams, "upstreams"], [routes.models, "model-suggestions"] + [routes.upstreams, "upstreams"], [routes.models, "model-suggestions"], [routes.events, "events"] ]); const view = views.get(source.pathname); if (!view) throw new Error("该数据不支持匿名读取"); diff --git a/internal/web/app/features/keys.js b/internal/web/app/features/keys.js index 0358037..f74b70e 100644 --- a/internal/web/app/features/keys.js +++ b/internal/web/app/features/keys.js @@ -352,16 +352,15 @@ async function resetAllManagedBilling() { if (!targets.length || !confirm(`重置全部 ${targets.length} 个用户的额度?当前剩余余额不会结转。`)) return; resetAllBillingNode.disabled = true; keyStatusNode.textContent = `正在重置 ${targets.length} 个用户`; - const results = await Promise.allSettled(targets.map(key => managedFetch(routes.billingReset, { - method: "POST", - body: JSON.stringify({ id: key.id }) - }))); - const failed = results.filter(result => result.status === "rejected"); - await loadKeys(); - resetAllBillingNode.disabled = false; - keyStatusNode.textContent = failed.length - ? `已重置 ${targets.length - failed.length} 个用户,${failed.length} 个失败` - : `已重置全部 ${targets.length} 个用户的额度`; + try { + const result = await managedFetch(routes.billingReset, { method: "POST", body: JSON.stringify({ all: true }) }); + await loadKeys(); + keyStatusNode.textContent = `已重置全部 ${result.reset_count || 0} 个用户的额度`; + } catch (error) { + keyStatusNode.textContent = "重置失败:" + error.message; + } finally { + resetAllBillingNode.disabled = false; + } } async function archiveManagedKey(key) { diff --git a/internal/web/app/features/logs.js b/internal/web/app/features/logs.js new file mode 100644 index 0000000..69a5098 --- /dev/null +++ b/internal/web/app/features/logs.js @@ -0,0 +1,48 @@ +import { dataFetch, routes } from "../core/runtime.js"; + +const rowsNode = document.querySelector("#event-rows"); + +export async function loadEvents() { + rowsNode.replaceChildren(emptyRow("正在读取")); + try { + const payload = await dataFetch(routes.events + "?limit=200"); + const events = payload.events || []; + rowsNode.replaceChildren(...(events.length ? events.map(eventRow) : [emptyRow("暂无日志")])); + } catch (error) { + rowsNode.replaceChildren(emptyRow("读取失败:" + error.message)); + } +} + +function eventRow(item) { + const row = document.createElement("tr"); + const occurredAt = document.createElement("td"); + occurredAt.className = "left"; + occurredAt.textContent = formatTime(item.occurred_at); + const event = document.createElement("td"); + event.className = "left"; + event.textContent = item.event || "-"; + const status = document.createElement("td"); + status.className = "left event-status " + (String(item.status || "").startsWith("成功") ? "ok" : "failed"); + status.textContent = item.status || "-"; + row.append(occurredAt, event, status); + return row; +} + +function emptyRow(message) { + const row = document.createElement("tr"); + const cell = document.createElement("td"); + cell.className = "left empty"; + cell.colSpan = 3; + cell.textContent = message; + row.append(cell); + return row; +} + +function formatTime(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false + }).format(date); +} diff --git a/internal/web/app/main.js b/internal/web/app/main.js index e2cbcea..4625a16 100644 --- a/internal/web/app/main.js +++ b/internal/web/app/main.js @@ -2,8 +2,9 @@ import { initializeRuntime, onAccessChange } from "./core/runtime.js"; import { closeKeyDrawer, initializeKeys, loadKeys } from "./features/keys.js"; import { initializePricing, loadPricing } from "./features/pricing.js"; import { activateUsage, initializeUsage, refreshVisibleUsage, resetUsage } from "./features/usage.js"; +import { loadEvents } from "./features/logs.js"; -const loaders = Object.freeze({ stats: loadKeys, usage: activateUsage, pricing: loadPricing, logs: () => {}, users: loadKeys }); +const loaders = Object.freeze({ stats: loadKeys, usage: activateUsage, pricing: loadPricing, logs: loadEvents, users: loadKeys }); initializeKeys(); initializeUsage(); diff --git a/internal/web/styles/usage.css b/internal/web/styles/usage.css index ec943c2..439e8bd 100644 --- a/internal/web/styles/usage.css +++ b/internal/web/styles/usage.css @@ -28,3 +28,8 @@ tbody tr:hover { background: color-mix(in srgb, var(--text-primary) 3%, transpar .usage-filter input, .usage-filter select { height: 32px; padding: 6px 8px; font-size: 11px; } .usage-filter.request-filter { grid-column: span 2; } .usage-filter-actions { display: flex; align-items: end; justify-content: flex-end; gap: 7px; grid-column: span 2; } +.event-table { table-layout: fixed; } +.event-table th:nth-child(1), .event-table td:nth-child(1) { width: 190px; } +.event-table th:nth-child(3), .event-table td:nth-child(3) { width: 220px; } +.event-table td { height: 42px; white-space: normal; } +.event-status { font-weight: 650; } diff --git a/internal/web/ui.html b/internal/web/ui.html index 5c1c882..1117cdb 100644 --- a/internal/web/ui.html +++ b/internal/web/ui.html @@ -91,7 +91,7 @@
-
暂无日志
+
时间事件状态
diff --git a/internal/webdemo/fake_data.go b/internal/webdemo/fake_data.go index 4a9a2de..b27b4b3 100644 --- a/internal/webdemo/fake_data.go +++ b/internal/webdemo/fake_data.go @@ -15,6 +15,7 @@ type fakeInput struct { prices []map[string]any catalog []map[string]any ledger []fakeLedger + events []fakeEvent now time.Time } @@ -87,6 +88,13 @@ type fakeLedger struct { OccurredAt time.Time `json:"occurred_at"` } +type fakeEvent struct { + ID int64 `json:"id"` + OccurredAt time.Time `json:"occurred_at"` + Event string `json:"event"` + Status string `json:"status"` +} + func NewFakeInput() Input { now := time.Now() input := &fakeInput{now: now} @@ -102,9 +110,32 @@ func NewFakeInput() Input { input.prices = fakePrices() input.catalog = fakeCatalog() input.ledger = fakeLedgerRecords(now, input.keys, input.models) + input.events = fakeEvents(now) return input } +func fakeEvents(now time.Time) []fakeEvent { + values := []struct { + event string + status string + }{ + {"管理员修改模型 `gpt-5.6-sol` 的价格", "成功"}, + {"管理员手动重置用户 `Alice` 的额度", "成功"}, + {"管理员手动重置全部用户额度", "成功,处理 4 个用户"}, + {"管理员调整用户 `Bob` 的额度:$8 → $10", "成功"}, + {"系统执行用户 `默认用户` 的额度自然重置", "成功"}, + {"管理员归档用户 `旧测试账号` 的 Key", "成功"}, + {"管理员创建用户 `Carol` 的 Key", "成功"}, + {"管理员删除模型 `legacy-model` 的价格", "失败:模型价格不存在"}, + {"管理员新增模型 `deepseek-v4-flash` 的价格", "成功"}, + } + events := make([]fakeEvent, 0, len(values)) + for index, value := range values { + events = append(events, fakeEvent{ID: int64(len(values) - index), OccurredAt: now.Add(-time.Duration(index*37) * time.Minute), Event: value.event, Status: value.status}) + } + return events +} + func fakeKeys(now time.Time) []fakeKey { monthlyReset := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location()) monthlyStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) diff --git a/internal/webdemo/fake_handler.go b/internal/webdemo/fake_handler.go index 521e0b0..66b2fa8 100644 --- a/internal/webdemo/fake_handler.go +++ b/internal/webdemo/fake_handler.go @@ -49,6 +49,8 @@ func (input *fakeInput) ServeHTTP(response http.ResponseWriter, request *http.Re payload = map[string]any{"models": input.models} case request.Method == http.MethodGet && path == "/billing-ledger": payload = input.billingLedger(request) + case request.Method == http.MethodGet && path == "/events": + payload = map[string]any{"events": input.events} case request.Method == http.MethodPost && path == "/billing-reset": payload, err = input.resetBilling(request) case request.Method == http.MethodGet && path == "/prices": @@ -355,6 +357,19 @@ func (input *fakeInput) resetBilling(request *http.Request) (map[string]any, err if err := decodeBody(request, &value); err != nil { return nil, err } + if all, _ := value["all"].(bool); all { + count := 0 + for index := range input.keys { + if input.keys[index].Status == "archived" { + continue + } + input.keys[index].Billing["spent_usd"] = "0" + input.keys[index].Billing["balance_usd"] = fmt.Sprint(input.keys[index].Billing["quota_usd"]) + input.keys[index].Billing["cycle_started_at"] = time.Now() + count++ + } + return map[string]any{"reset_count": count}, nil + } index := input.keyIndex(fmt.Sprint(value["id"])) if index < 0 { return nil, fmt.Errorf("key not found")