Skip to content

Commit 7dcce90

Browse files
committed
feat(image): 按 ModelFamily 调度 + 按账号 image 用量统计
调度层 - scheduler/family.go 新增 ModelFamily(platform, model) 把 gpt-image-* 折叠 到 "gpt-image" 家族;FamilyCooldown 把限流冷却按"账号-家族"维度隔离, 避免 gpt-image 撞限流影响同账号 chat 模型可用性 - scheduler/scheduler.go Scheduler 注入 familyCooldown 转发层 - plugin/{types,request}.go forwardState 新增 realtime 字段,stream/ realtime 解耦:流式响应和实时透传互相独立 - plugin/forwarder_test.go 配套 realtime 测试 后台账号面 - app/account/{stats,types}.go BuildStatsResult 在已有总量基础上新增 image_count / image_cost 维度,按 isImageModel 切分 - infra/store/account_store.go 仓储层支持新字段聚合 - server/{dto,handler/account*}.go HTTP 层暴露 image 字段 前端管理页 - pages/admin/{AccountsPage,AccountStatsModal}.tsx 新增 image 列与图表 - shared/{api/accounts,types}.ts 类型与请求扩展 - i18n/{en,zh}.json 文案 - app/{layout/AppShell,router}.tsx + pages/PluginPage.tsx 配套调整
1 parent 105c111 commit 7dcce90

22 files changed

Lines changed: 606 additions & 60 deletions

File tree

backend/internal/app/account/service_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ func (s stubRepository) BatchWindowStats(context.Context, []int, time.Time) (map
7878
return nil, nil
7979
}
8080

81+
func (s stubRepository) BatchImageStats(context.Context, []int, time.Time) (map[int]AccountImageStats, error) {
82+
return nil, nil
83+
}
84+
8185
func (s stubRepository) SaveCredentials(context.Context, int, map[string]string) error { return nil }
8286

8387
// stubStateWriter 捕获 StateWriter 调用。

backend/internal/app/account/stats.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@ package account
22

33
import (
44
"sort"
5+
"strings"
56
"time"
67

78
"github.com/DouDOU-start/airgate-core/internal/pkg/timezone"
89
)
910

11+
// isImageModel 判断 usage_log.model 是否属于生图家族。
12+
// 与 scheduler.ModelFamily 的 "gpt-image-*" 规则保持一致 —— 不直接 import scheduler
13+
// 包是为了避免 stats 这一层依赖调度模块。两边都改时记得同步更新。
14+
func isImageModel(model string) bool {
15+
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-image")
16+
}
17+
1018
const (
1119
defaultPage = 1
1220
defaultPageSize = 20
@@ -85,6 +93,7 @@ func BuildStatsResult(account Account, logs []UsageLog, now, startDate, endDate
8593
for _, log := range logs {
8694
// 按用户时区对齐日期 key,避免 UTC 切换导致跨日错位
8795
dateKey := log.CreatedAt.In(location).Format("2006-01-02")
96+
isImage := isImageModel(log.Model)
8897

8998
result.Range.Count++
9099
result.Range.InputTokens += log.InputTokens
@@ -93,6 +102,10 @@ func BuildStatsResult(account Account, logs []UsageLog, now, startDate, endDate
93102
result.Range.AccountCost += log.AccountCost
94103
result.Range.ActualCost += log.ActualCost
95104
totalDurationMs += log.DurationMs
105+
if isImage {
106+
result.Range.ImageCount++
107+
result.Range.ImageCost += log.AccountCost
108+
}
96109

97110
if !log.CreatedAt.Before(today) {
98111
result.Today.Count++
@@ -101,6 +114,10 @@ func BuildStatsResult(account Account, logs []UsageLog, now, startDate, endDate
101114
result.Today.TotalCost += log.TotalCost
102115
result.Today.AccountCost += log.AccountCost
103116
result.Today.ActualCost += log.ActualCost
117+
if isImage {
118+
result.Today.ImageCount++
119+
result.Today.ImageCost += log.AccountCost
120+
}
104121
}
105122

106123
if stats, ok := dailyMap[dateKey]; ok {

backend/internal/app/account/types.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ type Account struct {
4242
Extra map[string]any
4343
CreatedAt time.Time
4444
UpdatedAt time.Time
45+
// ImageStats 仅 OpenAI 平台账号在列表查询路径上填充;其它平台 / 详情查询路径为 nil。
46+
// 取自 usage_log model 名前缀 "gpt-image" 的子集聚合。
47+
ImageStats *AccountImageStats
4548
}
4649

4750
// AccountWindowStats 单个账号在某个时间窗口内的聚合统计。
@@ -53,6 +56,17 @@ type AccountWindowStats struct {
5356
UserCost float64 // SUM(actual_cost),用户扣费总额(平台计费)
5457
}
5558

59+
// AccountImageStats 单账号生图请求计数。
60+
//
61+
// 用于账号列表页"今日 N · 累计 M"展示。仅 OpenAI 平台账号填充(Claude / Anthropic
62+
// 等平台没有图像生成 endpoint,调用方按零值跳过)。
63+
//
64+
// "生图" 的判定与 stats.go::isImageModel 保持一致:model 名前缀 "gpt-image"。
65+
type AccountImageStats struct {
66+
TodayCount int64 // 今日 00:00(服务器本地时区)至今的生图请求数
67+
TotalCount int64 // 全部历史生图请求数
68+
}
69+
5670
// UsageLog 使用记录聚合输入。
5771
type UsageLog struct {
5872
Model string
@@ -209,13 +223,18 @@ type StatsQuery struct {
209223
// - TotalCost = SUM(usage_log.total_cost) 原始上游定价
210224
// - AccountCost = SUM(usage_log.account_cost) 账号实际成本 = total × account_rate("账号计费")
211225
// - ActualCost = SUM(usage_log.actual_cost) 用户扣费 = total × billing_rate
226+
//
227+
// ImageCount / ImageCost 把 model 名为 "gpt-image*" 的请求单独再聚一次,
228+
// 不影响 Count/cost(它们仍然是全部请求总和)。给后台展示"今日生图 N 张 / $X"用。
212229
type PeriodStats struct {
213230
Count int `json:"count"`
214231
InputTokens int64 `json:"input_tokens"`
215232
OutputTokens int64 `json:"output_tokens"`
216233
TotalCost float64 `json:"total_cost"`
217234
AccountCost float64 `json:"account_cost"`
218235
ActualCost float64 `json:"actual_cost"`
236+
ImageCount int `json:"image_count"`
237+
ImageCost float64 `json:"image_cost"`
219238
}
220239

221240
// DailyStats 每日统计。
@@ -314,5 +333,8 @@ type Repository interface {
314333
FindUsageLogs(context.Context, int, time.Time, time.Time) ([]UsageLog, error)
315334
// BatchWindowStats 批量聚合统计,没有记录的账号不出现在返回 map 中。
316335
BatchWindowStats(ctx context.Context, accountIDs []int, startTime time.Time) (map[int]AccountWindowStats, error)
336+
// BatchImageStats 批量统计指定账号的生图请求数(model 前缀 "gpt-image")。
337+
// 同时返回 [todayStart, now] 区间和 全部历史 两个计数。无记录的账号不出现在返回 map 中。
338+
BatchImageStats(ctx context.Context, accountIDs []int, todayStart time.Time) (map[int]AccountImageStats, error)
317339
SaveCredentials(context.Context, int, map[string]string) error
318340
}

backend/internal/infra/store/account_store.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,76 @@ func (s *AccountStore) BatchWindowStats(ctx context.Context, accountIDs []int, s
320320
return result, nil
321321
}
322322

323+
// imageModelPrefix 与 app/account/stats.go::isImageModel 保持一致。
324+
// 改这里时记得同步那边,避免"统计口径"和"列表展示"对不上。
325+
const imageModelPrefix = "gpt-image"
326+
327+
// BatchImageStats 一次拿"今日生图数"和"累计生图数"两组聚合,
328+
// 仅算 model 前缀 "gpt-image" 的请求。两条 GROUP BY 查询:
329+
//
330+
// SELECT account_id, COUNT(*) FROM usage_log
331+
// WHERE account_id IN (...) AND model LIKE 'gpt-image%' [AND created_at >= ?]
332+
// GROUP BY account_id
333+
//
334+
// 调用方传 openai 平台的账号 ID 子集即可——chat-only 平台传进来不会出错(match 0 行),
335+
// 只是浪费一次查询。
336+
func (s *AccountStore) BatchImageStats(ctx context.Context, accountIDs []int, todayStart time.Time) (map[int]appaccount.AccountImageStats, error) {
337+
result := map[int]appaccount.AccountImageStats{}
338+
if len(accountIDs) == 0 {
339+
return result, nil
340+
}
341+
342+
type row struct {
343+
AccountID int `json:"account_usage_logs"`
344+
Count int `json:"count"`
345+
}
346+
347+
// 累计:不带 created_at 限制
348+
var totalRows []row
349+
if err := s.db.UsageLog.Query().
350+
Where(
351+
entusagelog.HasAccountWith(entaccount.IDIn(accountIDs...)),
352+
entusagelog.ModelHasPrefix(imageModelPrefix),
353+
).
354+
GroupBy(entusagelog.AccountColumn).
355+
Aggregate(ent.Count()).
356+
Scan(ctx, &totalRows); err != nil {
357+
return nil, err
358+
}
359+
for _, r := range totalRows {
360+
if r.AccountID == 0 {
361+
continue
362+
}
363+
entry := result[r.AccountID]
364+
entry.TotalCount = int64(r.Count)
365+
result[r.AccountID] = entry
366+
}
367+
368+
// 今日:created_at >= todayStart
369+
var todayRows []row
370+
if err := s.db.UsageLog.Query().
371+
Where(
372+
entusagelog.HasAccountWith(entaccount.IDIn(accountIDs...)),
373+
entusagelog.ModelHasPrefix(imageModelPrefix),
374+
entusagelog.CreatedAtGTE(todayStart),
375+
).
376+
GroupBy(entusagelog.AccountColumn).
377+
Aggregate(ent.Count()).
378+
Scan(ctx, &todayRows); err != nil {
379+
return nil, err
380+
}
381+
for _, r := range todayRows {
382+
if r.AccountID == 0 {
383+
continue
384+
}
385+
entry := result[r.AccountID]
386+
entry.TodayCount = int64(r.Count)
387+
result[r.AccountID] = entry
388+
}
389+
390+
return result, nil
391+
}
392+
323393
// SaveCredentials 保存账号凭证。
324394
func (s *AccountStore) SaveCredentials(ctx context.Context, id int, credentials map[string]string) error {
325395
if err := s.db.Account.UpdateOneID(id).

backend/internal/plugin/forwarder_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
package plugin
22

33
import (
4+
"net/http"
5+
"net/http/httptest"
46
"testing"
57

8+
"github.com/gin-gonic/gin"
9+
10+
"github.com/DouDOU-start/airgate-core/ent"
611
"github.com/DouDOU-start/airgate-core/internal/auth"
712
"github.com/DouDOU-start/airgate-core/internal/routing"
813
)
@@ -41,6 +46,72 @@ func TestParseBody_StreamTrue(t *testing.T) {
4146
}
4247
}
4348

49+
func TestBuildPluginRequestUsesWriterForStreamRequest(t *testing.T) {
50+
t.Parallel()
51+
52+
recorder := httptest.NewRecorder()
53+
c, _ := gin.CreateTestContext(recorder)
54+
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
55+
state := &forwardState{
56+
requestPath: "/v1/images/generations",
57+
stream: true,
58+
realtime: true,
59+
keyInfo: &auth.APIKeyInfo{},
60+
account: &ent.Account{},
61+
}
62+
63+
req := buildPluginRequest(c, state)
64+
if !req.Stream {
65+
t.Fatalf("Stream = false, want true")
66+
}
67+
if req.Writer == nil {
68+
t.Fatalf("Writer = nil, want stream writer")
69+
}
70+
}
71+
72+
func TestBuildPluginRequestOmitsWriterForPlainNonStreamRequest(t *testing.T) {
73+
t.Parallel()
74+
75+
recorder := httptest.NewRecorder()
76+
c, _ := gin.CreateTestContext(recorder)
77+
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
78+
state := &forwardState{
79+
requestPath: "/v1/chat/completions",
80+
stream: false,
81+
realtime: false,
82+
keyInfo: &auth.APIKeyInfo{},
83+
account: &ent.Account{},
84+
}
85+
86+
req := buildPluginRequest(c, state)
87+
if req.Writer != nil {
88+
t.Fatalf("Writer = %T, want nil", req.Writer)
89+
}
90+
}
91+
92+
func TestBuildPluginRequestOmitsWriterForNonStreamImagesRequest(t *testing.T) {
93+
t.Parallel()
94+
95+
recorder := httptest.NewRecorder()
96+
c, _ := gin.CreateTestContext(recorder)
97+
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
98+
state := &forwardState{
99+
requestPath: "/v1/images/generations",
100+
stream: false,
101+
realtime: false,
102+
keyInfo: &auth.APIKeyInfo{},
103+
account: &ent.Account{},
104+
}
105+
106+
req := buildPluginRequest(c, state)
107+
if req.Stream {
108+
t.Fatalf("Stream = true, want false")
109+
}
110+
if req.Writer != nil {
111+
t.Fatalf("Writer = %T, want nil", req.Writer)
112+
}
113+
}
114+
44115
func TestRoutesForAPIKeyUsesBoundGroupOnly(t *testing.T) {
45116
t.Parallel()
46117

backend/internal/plugin/request.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ func (f *Forwarder) parseRequest(c *gin.Context) (*forwardState, bool) {
5656
body: body,
5757
model: parsed.Model,
5858
stream: parsed.Stream,
59+
realtime: parsed.Stream,
5960
sessionID: parsed.SessionID,
6061
requestedPlatform: requestedPlatform,
6162
keyInfo: keyInfo,
@@ -216,7 +217,7 @@ func buildPluginRequest(c *gin.Context, state *forwardState) *sdk.ForwardRequest
216217
Model: state.model,
217218
Stream: state.stream,
218219
}
219-
if state.stream {
220+
if state.realtime {
220221
req.Writer = c.Writer
221222
}
222223
return req

backend/internal/plugin/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type forwardState struct {
1919
body []byte
2020
model string
2121
stream bool
22+
realtime bool
2223
sessionID string
2324

2425
requestedPlatform string

0 commit comments

Comments
 (0)