后端 - /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 与类型定义;控制台/管理菜单挂载
170 lines
5.2 KiB
Go
170 lines
5.2 KiB
Go
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
|
||
}
|