Files
opencatd-open/internal/usage/recorder.go
T
Sakurasan ef3025dd80 refactor: complete backend rewrite for multi-protocol proxy
Major rewrite of the Go backend to support:
- Three API format imports: openai, anthropic, compatible
- Three protocol conversions: Chat Completions, Responses, Messages
- Hub-and-spoke architecture with Chat as intermediate format

Deleted:
- opencat.go (old entry)
- store/, team/, pkg/team/, pkg/store/ (old data layer)
- internal/model/, internal/consts/ (old types)
- internal/service/team/, internal/controller/team/ (old handlers)
- llm/ (removed LLM client library, pure proxy mode)
- dist/, assets/ (old build artifacts)

Added:
- internal/store/ — 9 GORM models + multi-DB support
- internal/pkg/ — crypto (AES-GCM), apikey, jwt, ratelimit, resp, tokenizer
- internal/channel/ — channel selection, weighted LB, health checks
- internal/proxy/convert/ — 6 protocol conversion functions + SSE streaming
- internal/proxy/ — gateway with request dispatch and upstream selection
- internal/usage/ — async usage recorder with batch writes
- internal/api/ — management API (auth, users, keys, channels, models)
- Makefile for build/test/deploy

Fixed API to match frontend expectations:
- Login response wraps token in { data: { token } }
- GET /api/profile route added
- Profile response wraps user in { code, data }
- Role returned as number (10=admin, 1=user)
2026-08-30 11:49:31 +08:00

141 lines
2.9 KiB
Go

package usage
import (
"context"
"log"
"opencatd-open/internal/dao"
"opencatd-open/internal/store"
"sync"
"time"
)
// Event represents a usage event to be recorded
type Event struct {
UserID uint64
ModelName string
ChannelID uint64
PromptTokens int
CompletionTokens int
CacheReadTokens int
Cost float64
IsError bool
IsCanceled bool
RequestID string
}
// Recorder handles async usage recording
type Recorder struct {
usageDAO *dao.UsageDAO
dailyDAO *dao.DailyUsageDAO
ch chan Event
batchSize int
flushInterval time.Duration
wg sync.WaitGroup
}
// NewRecorder creates a new usage recorder
func NewRecorder(usageDAO *dao.UsageDAO, dailyDAO *dao.DailyUsageDAO) *Recorder {
return &Recorder{
usageDAO: usageDAO,
dailyDAO: dailyDAO,
ch: make(chan Event, 10000),
batchSize: 100,
flushInterval: 5 * time.Second,
}
}
// Start starts the recorder's background workers
func (r *Recorder) Start(ctx context.Context) {
r.wg.Add(1)
go r.processLoop(ctx)
}
// Stop gracefully stops the recorder
func (r *Recorder) Stop() {
close(r.ch)
r.wg.Wait()
}
// Record queues a usage event for async recording
func (r *Recorder) Record(event Event) {
select {
case r.ch <- event:
default:
log.Printf("Usage channel full, dropping event for user %d model %s", event.UserID, event.ModelName)
}
}
func (r *Recorder) processLoop(ctx context.Context) {
defer r.wg.Done()
batch := make([]Event, 0, r.batchSize)
ticker := time.NewTicker(r.flushInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
if len(batch) > 0 {
r.flush(batch)
}
return
case event, ok := <-r.ch:
if !ok {
if len(batch) > 0 {
r.flush(batch)
}
return
}
batch = append(batch, event)
if len(batch) >= r.batchSize {
r.flush(batch)
batch = make([]Event, 0, r.batchSize)
}
case <-ticker.C:
if len(batch) > 0 {
r.flush(batch)
batch = make([]Event, 0, r.batchSize)
}
}
}
}
func (r *Recorder) flush(events []Event) {
if len(events) == 0 {
return
}
// Batch create usage logs
logs := make([]*store.UsageLog, 0, len(events))
for _, e := range events {
status := store.UsageStatusSuccess
if e.IsError {
status = store.UsageStatusError
}
if e.IsCanceled {
status = store.UsageStatusCanceled
}
log := &store.UsageLog{
UserID: e.UserID,
ModelName: e.ModelName,
ChannelID: e.ChannelID,
InputTokens: int64(e.PromptTokens),
OutputTokens: int64(e.CompletionTokens),
CacheReadTokens: int64(e.CacheReadTokens),
Cost: e.Cost,
Status: status,
RequestID: e.RequestID,
}
logs = append(logs, log)
}
// Write to database
if err := r.usageDAO.BatchCreate(context.Background(), logs); err != nil {
log.Printf("Failed to batch create usage logs: %v", err)
}
log.Printf("Flushed %d usage logs", len(logs))
}