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:
Sakurasan
2026-08-15 13:10:47 +08:00
commit 360c6b33a6
75 changed files with 7044 additions and 0 deletions
+144
View File
@@ -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
}