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", "只读资源类型不存在")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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("该数据不支持匿名读取");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user