M0+M1: 基建 + 用户/密钥/核心代理
后端 (Go/Gin/GORM): - 配置(viper+env)、SQLite/Postgres 迁移、argon2id、AES-GCM 渠道密钥、JWT+refresh cookie - 用户注册/登录/刷新/登出、API Key CRUD(仅存哈希、明文一次展示) - 代理网关: /v1/chat/completions、/v1/responses、/v1/models 直通 OpenAI 渠道 非流式+流式(SSE 零缓冲转发), 用量捕获(chat 末块/responses completed 嵌套), OpenAI 错误格式(401/402/404/502), 余额检查 - 异步批量记账 + 余额流水 + 日聚合, admin 用户/余额/配置 API - 单测: crypto/jwt/apikey/流式 usage 提取 前端 (Vue3+TS+Vite+Tailwind v4): - taste-skill 设计 tokens: 深色仪表盘, 石墨+信号铜色, Outfit+JetBrains Mono - Landing/登录/注册, 控制台(仪表盘图表/密钥管理/用量明细) - 基础组件 Button/Input/Badge/Modal, ECharts 用量图 部署: docker-compose(nginx+api+postgres), 双 Dockerfile, nginx SSE 反代 联调: scripts/mockupstream 本地 mock 上游, 端到端验证通过
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
// Package usage 异步记账:请求完成后写入 usage_logs,批量落库(PLANNING §3.2)。
|
||||
package usage
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/openteam/server/internal/store"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Recorder struct {
|
||||
db *gorm.DB
|
||||
ch chan *store.UsageLog
|
||||
wg sync.WaitGroup
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
const batchSize = 32
|
||||
|
||||
func NewRecorder(db *gorm.DB) *Recorder {
|
||||
r := &Recorder{
|
||||
db: db,
|
||||
ch: make(chan *store.UsageLog, 512),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
r.wg.Add(1)
|
||||
go r.run()
|
||||
return r
|
||||
}
|
||||
|
||||
// Record 提交一条用量(非阻塞;队列满时同步写入,保证不丢账)。
|
||||
func (r *Recorder) Record(l *store.UsageLog) {
|
||||
select {
|
||||
case r.ch <- l:
|
||||
default:
|
||||
// 队列积压:直接同步写,避免丢账
|
||||
if err := r.flush([]*store.UsageLog{l}); err != nil {
|
||||
log.Printf("usage: sync write failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Recorder) Close() {
|
||||
close(r.closed)
|
||||
r.wg.Wait()
|
||||
close(r.ch)
|
||||
}
|
||||
|
||||
func (r *Recorder) run() {
|
||||
defer r.wg.Done()
|
||||
buf := make([]*store.UsageLog, 0, batchSize)
|
||||
tick := time.NewTicker(2 * time.Second)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case l, ok := <-r.ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
buf = append(buf, l)
|
||||
if len(buf) >= batchSize {
|
||||
if err := r.flush(buf); err != nil {
|
||||
log.Printf("usage: batch write failed: %v", err)
|
||||
}
|
||||
buf = buf[:0]
|
||||
}
|
||||
case <-r.closed:
|
||||
if len(buf) > 0 {
|
||||
if err := r.flush(buf); err != nil {
|
||||
log.Printf("usage: final batch write failed: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
case <-tick.C:
|
||||
if len(buf) > 0 {
|
||||
if err := r.flush(buf); err != nil {
|
||||
log.Printf("usage: batch write failed: %v", err)
|
||||
}
|
||||
buf = buf[:0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flush 批量插入用量明细,并同步更新余额、余额流水与日聚合。
|
||||
// 记账口径:单次成本 = in×in_price + out×out_price + cache_read×cache_read_price(每百万 token)。
|
||||
func (r *Recorder) flush(logs []*store.UsageLog) error {
|
||||
if len(logs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(logs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range logs {
|
||||
if l.Status != store.UsageStatusSuccess || l.Cost <= 0 {
|
||||
continue
|
||||
}
|
||||
// 扣余额(余额可为负,流式请求不中断;后续请求被拒)
|
||||
var user store.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, l.UserID).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
newBalance := user.Balance - l.Cost
|
||||
tx.Model(&store.User{}).Where("id = ?", l.UserID).Update("balance", newBalance)
|
||||
tx.Create(&store.BalanceLog{
|
||||
UserID: l.UserID,
|
||||
Change: -l.Cost,
|
||||
BalanceAfter: newBalance,
|
||||
Type: store.BalanceTypeUsage,
|
||||
RefID: usageRefID(l.TraceID),
|
||||
Remark: "usage: " + l.ModelName,
|
||||
})
|
||||
|
||||
// 日聚合 upsert
|
||||
date := l.CreatedAt.UTC().Format("2006-01-02")
|
||||
tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "model_id"}, {Name: "date"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"requests": gorm.Expr("requests + 1"),
|
||||
"input_tokens": gorm.Expr("input_tokens + ?", l.InputTokens),
|
||||
"output_tokens": gorm.Expr("output_tokens + ?", l.OutputTokens),
|
||||
"cache_read_tokens": gorm.Expr("cache_read_tokens + ?", l.CacheReadTokens),
|
||||
"cost": gorm.Expr("cost + ?", l.Cost),
|
||||
}),
|
||||
}).Create(&store.UsageDaily{
|
||||
UserID: l.UserID, ModelID: l.ModelID, Date: date,
|
||||
Requests: 1, InputTokens: l.InputTokens, OutputTokens: l.OutputTokens,
|
||||
CacheReadTokens: l.CacheReadTokens, Cost: l.Cost,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func usageRefID(traceID string) string {
|
||||
if traceID == "" {
|
||||
traceID = "unknown"
|
||||
}
|
||||
return "usage:" + traceID
|
||||
}
|
||||
Reference in New Issue
Block a user