Files
opencatd-open/backend/internal/dao/usage.go
T
Sakurasan a259e5eb4d feat: 普通用户用量统计 + 管理后台用量明细
后端
- /api/usage/stats:当前用户按日聚合统计(请求数/输入/输出/缓存 tokens/费用)
- /api/usage/logs:当前用户用量明细分页
- /api/admin/usage/logs:全量明细(分页 + 协议/状态/模型/用户筛选 + 用户名关联)
- /api/admin/usage/summary:全量汇总(按用户分组)
- dao:UsageFilter 支持筛选分页;DailyUsageDAO.ListAll
- 路由加固:新增 middleware.AdminOnly,既有 /api/admin/* 全部迁移到
  带管理员角色校验的分组(此前登录即可访问,属安全隐患)

前端
- 普通用户「用量统计」页:统计卡片 + 纯 CSS 每日请求量条形图 + 明细表格分页
- 管理后台「用量明细」页:汇总卡片 + 协议/状态/模型/用户筛选 +
  明细表格 + 原始请求/响应查看弹窗(记录开关开启时)
- stores/usage.ts 与类型定义;控制台/管理菜单挂载
2026-09-01 02:55:55 +08:00

170 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dao
import (
"context"
"opencatd-open/internal/store"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type UsageDAO struct {
db *gorm.DB
}
type DailyUsageDAO struct {
db *gorm.DB
}
func NewUsageDAO(db *gorm.DB) *UsageDAO {
return &UsageDAO{db: db}
}
func NewDailyUsageDAO(db *gorm.DB) *DailyUsageDAO {
return &DailyUsageDAO{db: db}
}
// UsageLog DAO
func (d *UsageDAO) Create(ctx context.Context, log *store.UsageLog) error {
return d.db.WithContext(ctx).Create(log).Error
}
func (d *UsageDAO) BatchCreate(ctx context.Context, logs []*store.UsageLog) error {
return d.db.WithContext(ctx).Create(logs).Error
}
func (d *UsageDAO) ListByUserID(ctx context.Context, userID uint64, limit, offset int) ([]*store.UsageLog, error) {
var logs []*store.UsageLog
err := d.db.WithContext(ctx).
Where("user_id = ?", userID).
Order("created_at DESC").
Limit(limit).
Offset(offset).
Find(&logs).Error
return logs, err
}
func (d *UsageDAO) Delete(ctx context.Context, id uint64) error {
return d.db.WithContext(ctx).Delete(&store.UsageLog{}, id).Error
}
func (d *UsageDAO) CountByUserID(ctx context.Context, userID uint64) (int64, error) {
var count int64
err := d.db.WithContext(ctx).Model(&store.UsageLog{}).Where("user_id = ?", userID).Count(&count).Error
return count, err
}
// UsageFilter 用量明细筛选条件(管理后台)。
type UsageFilter struct {
UserID *uint64 // 指定用户(nil=全部)
Protocol string // 协议 chat/messages/responses(空=全部)
Status string // success/error/canceled(空=全部)
ModelName string // 模型名模糊(空=全部)
Limit int
Offset int
}
// ListAll 管理后台全量用量明细(分页 + 筛选),并带用户名。
func (d *UsageDAO) ListAll(ctx context.Context, f UsageFilter) ([]store.UsageLog, error) {
q := d.db.WithContext(ctx).Model(&store.UsageLog{})
q = applyUsageFilter(q, f)
var logs []store.UsageLog
err := q.Order("created_at DESC").Limit(f.Limit).Offset(f.Offset).Find(&logs).Error
return logs, err
}
// CountAll 统计符合筛选条件的明细总数。
func (d *UsageDAO) CountAll(ctx context.Context, f UsageFilter) (int64, error) {
q := d.db.WithContext(ctx).Model(&store.UsageLog{})
q = applyUsageFilter(q, f)
var count int64
err := q.Count(&count).Error
return count, err
}
func applyUsageFilter(q *gorm.DB, f UsageFilter) *gorm.DB {
if f.UserID != nil {
q = q.Where("user_id = ?", *f.UserID)
}
if f.Protocol != "" {
q = q.Where("protocol = ?", f.Protocol)
}
if f.Status != "" {
q = q.Where("status = ?", f.Status)
}
if f.ModelName != "" {
q = q.Where("model_name LIKE ?", "%"+f.ModelName+"%")
}
return q
}
// UsageDaily DAO
func (d *DailyUsageDAO) Create(ctx context.Context, log *store.UsageDaily) error {
return d.db.WithContext(ctx).Create(log).Error
}
func (d *DailyUsageDAO) ListByUserID(ctx context.Context, userID uint64, limit, offset int) ([]*store.UsageDaily, error) {
var logs []*store.UsageDaily
err := d.db.WithContext(ctx).
Where("user_id = ?", userID).
Order("date DESC").
Limit(limit).
Offset(offset).
Find(&logs).Error
return logs, err
}
func (d *DailyUsageDAO) GetByDate(ctx context.Context, userID uint64, date string) (*store.UsageDaily, error) {
var log store.UsageDaily
err := d.db.WithContext(ctx).
Where("user_id = ? AND date = ?", userID, date).
First(&log).Error
if err != nil {
return nil, err
}
return &log, nil
}
// UpsertDailyUsage 按 (user_id, model_id, date) 累加式 upsert:
// 行不存在则插入;存在则在原值基础上增量累加(不能用 AssignmentColumns 覆盖,
// 否则多次 flush 会互相清零)。非限定列名在 SQLite/MySQL/PG 的 upsert 语义下都指向目标行。
func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, log *store.UsageDaily) error {
return d.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "model_id"}, {Name: "date"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"requests": gorm.Expr("requests + ?", log.Requests),
"input_tokens": gorm.Expr("input_tokens + ?", log.InputTokens),
"output_tokens": gorm.Expr("output_tokens + ?", log.OutputTokens),
"cache_read_tokens": gorm.Expr("cache_read_tokens + ?", log.CacheReadTokens),
"cost": gorm.Expr("cost + ?", log.Cost),
}),
}).Create(log).Error
}
func (d *DailyUsageDAO) ListByDateRange(ctx context.Context, userID uint64, start, end time.Time) ([]*store.UsageDaily, error) {
var logs []*store.UsageDaily
err := d.db.WithContext(ctx).
Where("user_id = ? AND date >= ? AND date <= ?", userID, start.Format("2006-01-02"), end.Format("2006-01-02")).
Order("date DESC").
Find(&logs).Error
return logs, err
}
// ListAll 管理后台:全部用户的日聚合(可选按用户/日期范围筛选),按日期倒序。
func (d *DailyUsageDAO) ListAll(ctx context.Context, userID *uint64, start, end string) ([]store.UsageDaily, error) {
q := d.db.WithContext(ctx).Model(&store.UsageDaily{})
if userID != nil {
q = q.Where("user_id = ?", *userID)
}
if start != "" {
q = q.Where("date >= ?", start)
}
if end != "" {
q = q.Where("date <= ?", end)
}
var logs []store.UsageDaily
err := q.Order("date DESC, user_id ASC").Find(&logs).Error
return logs, err
}