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", "只读资源类型不存在")
}
+35 -2
View File
@@ -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
}
+102 -4
View File
@@ -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 {
+93
View File
@@ -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)
}
+79
View File
@@ -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")
}
+29 -1
View File
@@ -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
}
+9
View File
@@ -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 (
+3 -2
View File
@@ -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("该数据不支持匿名读取");
+9 -10
View File
@@ -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) {
+48
View File
@@ -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);
}
+2 -1
View File
@@ -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();
+5
View File
@@ -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; }
+1 -1
View File
@@ -91,7 +91,7 @@
</section>
<section id="view-logs" class="view">
<div class="surface"><div class="empty">暂无日志</div></div>
<div class="surface"><div class="table-wrap"><table class="event-table"><thead><tr><th class="left">时间</th><th class="left">事件</th><th class="left">状态</th></tr></thead><tbody id="event-rows"></tbody></table></div></div>
</section>
</main>
</div>
+31
View File
@@ -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())
+15
View File
@@ -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")