Skip to content

Commit 9fcd50b

Browse files
feat: recover prefer-paid dispatch + dialog logs from pre-merge stash
Recovered from stash@{0} (WIP from before upstream merge 2026-05-10). prefer-paid: runtime toggle to prefer plus/pro/team accounts with free fallback. Decoupled from free_gpt55_enabled. Admin UI + PG/SQLite schema migration (prefer_paid_enabled column). Matches live v1.7.47 behavior. dialog_logs: async collector writing successful request/response pairs to PG dialog_logs table. Env DIALOG_COLLECTION_ENABLED=false disables at startup; admin runtime toggle for hot control. panic-isolated, channel- drop on overflow, PG-only (SQLite skipped for local dev). Conflict in proxy/handler.go resolved keeping isFirstTokenEvent TTFT blacklist (from bc8cbe9) plus dialog collector raw-event capture in both image-generation non-stream and ChatCompletions non-stream branches.
1 parent 7885d37 commit 9fcd50b

16 files changed

Lines changed: 2004 additions & 14 deletions

File tree

admin/handler.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ type Handler struct {
5151
// 增删 key 后调用,让鉴权中间件的 dbKeys 缓存立即过期。
5252
apiKeyCacheInvalidator func()
5353

54+
// 对话采集器引用(main.go 注入,可空表示未启用)
55+
dialogCollector *proxy.DialogCollector
56+
5457
// 图表聚合内存缓存(10秒 TTL)
5558
chartCacheMu sync.RWMutex
5659
chartCacheData map[string]*chartCacheEntry
@@ -96,6 +99,122 @@ func (h *Handler) InvalidateStatsCache() {
9699
h.listActiveCache.Store(nil)
97100
}
98101

102+
// SetDialogCollector 注入对话采集器(main.go 在初始化时调用)。
103+
// 允许传入 nil(采集功能未启用时所有 dialog API 返回 disabled 状态)。
104+
func (h *Handler) SetDialogCollector(c *proxy.DialogCollector) {
105+
if h == nil {
106+
return
107+
}
108+
h.dialogCollector = c
109+
}
110+
111+
// GetDialogStats 查询对话采集运行时指标 + DB 累计量。
112+
// GET /api/admin/dialog-stats
113+
func (h *Handler) GetDialogStats(c *gin.Context) {
114+
out := gin.H{
115+
"installed": h.dialogCollector != nil,
116+
}
117+
if h.dialogCollector != nil {
118+
out["runtime"] = h.dialogCollector.Stats()
119+
}
120+
if h.db != nil {
121+
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
122+
defer cancel()
123+
if dbStats, err := h.db.GetDialogStats(ctx); err == nil {
124+
out["db"] = dbStats
125+
} else {
126+
out["db_error"] = err.Error()
127+
}
128+
}
129+
c.JSON(http.StatusOK, out)
130+
}
131+
132+
// ListDialogLogs 分页列出对话采集记录(不含 body 大字段)。
133+
// GET /api/admin/dialog-logs?endpoint=&model=&limit=50&offset=0
134+
func (h *Handler) ListDialogLogs(c *gin.Context) {
135+
if h.db == nil {
136+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "db unavailable"})
137+
return
138+
}
139+
limit, _ := strconv.Atoi(c.Query("limit"))
140+
if limit <= 0 {
141+
limit = 50
142+
}
143+
offset, _ := strconv.Atoi(c.Query("offset"))
144+
if offset < 0 {
145+
offset = 0
146+
}
147+
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
148+
defer cancel()
149+
rows, total, err := h.db.ListDialogLogs(ctx, database.DialogLogListParams{
150+
Endpoint: strings.TrimSpace(c.Query("endpoint")),
151+
Model: strings.TrimSpace(c.Query("model")),
152+
Limit: limit,
153+
Offset: offset,
154+
})
155+
if err != nil {
156+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
157+
return
158+
}
159+
c.JSON(http.StatusOK, gin.H{
160+
"items": rows,
161+
"total": total,
162+
"limit": limit,
163+
"offset": offset,
164+
})
165+
}
166+
167+
// GetDialogLogDetail 查看单条详情(含完整 body)。
168+
// GET /api/admin/dialog-logs/:id
169+
func (h *Handler) GetDialogLogDetail(c *gin.Context) {
170+
if h.db == nil {
171+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "db unavailable"})
172+
return
173+
}
174+
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
175+
if err != nil || id <= 0 {
176+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
177+
return
178+
}
179+
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
180+
defer cancel()
181+
detail, err := h.db.GetDialogLogByID(ctx, id)
182+
if err != nil {
183+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
184+
return
185+
}
186+
if detail == nil {
187+
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
188+
return
189+
}
190+
c.JSON(http.StatusOK, detail)
191+
}
192+
193+
// ToggleDialogCollection 运行时开关,无需重启容器。
194+
// POST /api/admin/dialog-toggle body: {"enabled": true/false}
195+
func (h *Handler) ToggleDialogCollection(c *gin.Context) {
196+
if h.dialogCollector == nil {
197+
c.JSON(http.StatusServiceUnavailable, gin.H{
198+
"error": gin.H{"message": "采集器未启用(启动级 ENV 关闭或非 PG 后端)", "type": "not_available"},
199+
})
200+
return
201+
}
202+
var req struct {
203+
Enabled bool `json:"enabled"`
204+
}
205+
if err := c.ShouldBindJSON(&req); err != nil {
206+
c.JSON(http.StatusBadRequest, gin.H{
207+
"error": gin.H{"message": "invalid body: " + err.Error(), "type": "invalid_request"},
208+
})
209+
return
210+
}
211+
h.dialogCollector.SetEnabled(req.Enabled)
212+
c.JSON(http.StatusOK, gin.H{
213+
"enabled": req.Enabled,
214+
"runtime": h.dialogCollector.Stats(),
215+
})
216+
}
217+
99218
// getCachedListActive 返回带 5 秒 TTL 缓存的 ListActive 结果,
100219
// 避免分页/筛选请求每次都走 DB 全量查询。
101220
func (h *Handler) getCachedListActive(ctx context.Context) ([]*database.AccountRow, error) {
@@ -197,6 +316,12 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
197316
api.POST("/proxies/batch-delete", h.BatchDeleteProxies)
198317
api.POST("/proxies/test", h.TestProxy)
199318

319+
// 对话采集(dialog_logs)
320+
api.GET("/dialog-stats", h.GetDialogStats)
321+
api.POST("/dialog-toggle", h.ToggleDialogCollection)
322+
api.GET("/dialog-logs", h.ListDialogLogs)
323+
api.GET("/dialog-logs/:id", h.GetDialogLogDetail)
324+
200325
// OAuth 授权流程
201326
api.POST("/oauth/generate-auth-url", h.GenerateOAuthURL)
202327
api.POST("/oauth/exchange-code", h.ExchangeOAuthCode)
@@ -2276,6 +2401,7 @@ type settingsResponse struct {
22762401
RTManagerEnabled bool `json:"rt_manager_enabled"`
22772402
RTManagerPasswordSet bool `json:"rt_manager_password_set"`
22782403
FreeGPT55Enabled bool `json:"free_gpt55_enabled"`
2404+
PreferPaidEnabled bool `json:"prefer_paid_enabled"`
22792405
}
22802406

22812407
type updateSettingsReq struct {
@@ -2308,6 +2434,7 @@ type updateSettingsReq struct {
23082434
RTManagerPassword *string `json:"rt_manager_password"`
23092435
RTManagerEnabled *bool `json:"rt_manager_enabled"`
23102436
FreeGPT55Enabled *bool `json:"free_gpt55_enabled"`
2437+
PreferPaidEnabled *bool `json:"prefer_paid_enabled"`
23112438
}
23122439

23132440
// GetSettings 获取当前系统设置
@@ -2365,6 +2492,7 @@ func (h *Handler) GetSettings(c *gin.Context) {
23652492
RTManagerEnabled: rtManagerEnabled,
23662493
RTManagerPasswordSet: rtManagerPasswordSet,
23672494
FreeGPT55Enabled: h.store.GetFreeGPT55Enabled(),
2495+
PreferPaidEnabled: h.store.GetPreferPaidEnabled(),
23682496
})
23692497
}
23702498

@@ -2646,6 +2774,12 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
26462774
log.Printf("设置已更新: free_gpt55_enabled = %t", *req.FreeGPT55Enabled)
26472775
}
26482776

2777+
// prefer_paid_enabled:全局开关,false=prefer_free(默认省额度) / true=prefer_paid(体验优先)
2778+
if req.PreferPaidEnabled != nil {
2779+
h.store.SetPreferPaidEnabled(*req.PreferPaidEnabled)
2780+
log.Printf("设置已更新: prefer_paid_enabled = %t", *req.PreferPaidEnabled)
2781+
}
2782+
26492783
// 持久化保存到数据库
26502784
err := h.db.UpdateSystemSettings(c.Request.Context(), &database.SystemSettings{
26512785
MaxConcurrency: h.store.GetMaxConcurrency(),
@@ -2677,6 +2811,7 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
26772811
RTManagerPassword: rtManagerPassword,
26782812
RTManagerEnabled: rtManagerEnabled,
26792813
FreeGPT55Enabled: h.store.GetFreeGPT55Enabled(),
2814+
PreferPaidEnabled: h.store.GetPreferPaidEnabled(),
26802815
})
26812816
if err != nil {
26822817
log.Printf("无法持久化保存设置: %v", err)
@@ -2732,6 +2867,7 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
27322867
RTManagerEnabled: rtManagerEnabled,
27332868
RTManagerPasswordSet: strings.TrimSpace(rtManagerPassword) != "",
27342869
FreeGPT55Enabled: h.store.GetFreeGPT55Enabled(),
2870+
PreferPaidEnabled: h.store.GetPreferPaidEnabled(),
27352871
})
27362872
}
27372873

auth/store.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,7 @@ type Store struct {
10571057
autoCleanupBatch atomic.Bool
10581058
fastAliasEnabled atomic.Bool // 是否把客户端的 service_tier=fast 映射为上游 priority
10591059
freeGPT55Enabled atomic.Bool // 是否允许 free 账号承接 gpt-5.5(默认 ON;OFF 时 5.5 走 premium-only)
1060+
preferPaidEnabled atomic.Bool // 是否切换为「付费优先 free 兜底」调度(默认 OFF;ON 时 prefer plus/pro/team)
10601061
maxRetries int64 // 请求失败最大重试次数(换号重试)
10611062
backgroundRefreshInterval int64 // 后台刷新/探针巡检间隔(ns)
10621063
usageProbeMaxAge int64 // 用量探针快照最大缓存时长(ns)
@@ -1156,6 +1157,7 @@ func NewStore(db *database.DB, tc cache.TokenCache, settings *database.SystemSet
11561157
s.autoCleanExpired.Store(settings.AutoCleanExpired)
11571158
s.fastAliasEnabled.Store(settings.FastAliasEnabled)
11581159
s.freeGPT55Enabled.Store(settings.FreeGPT55Enabled)
1160+
s.preferPaidEnabled.Store(settings.PreferPaidEnabled)
11591161

11601162
// rt-manager 客户端:始终初始化(持久化设置注入),是否真生效由 Enabled() 决定
11611163
s.rtManager = NewRTManager()
@@ -1459,6 +1461,25 @@ func (s *Store) SetFreeGPT55Enabled(enabled bool) {
14591461
s.freeGPT55Enabled.Store(enabled)
14601462
}
14611463

1464+
// GetPreferPaidEnabled 是否启用「付费优先 free 兜底」调度。
1465+
// false(默认):preferPlan = "free",沿用原有 prefer_free(省付费额度)。
1466+
// true:preferPlan = "plus,pro,team",付费账号优先调度,free 兜底(体验优先)。
1467+
// 与 free_gpt55_enabled / premium_only 解耦:5.5 仍按其自身策略决定 free 是否参与。
1468+
func (s *Store) GetPreferPaidEnabled() bool {
1469+
if s == nil {
1470+
return false
1471+
}
1472+
return s.preferPaidEnabled.Load()
1473+
}
1474+
1475+
// SetPreferPaidEnabled 设置是否启用「付费优先 free 兜底」调度。
1476+
func (s *Store) SetPreferPaidEnabled(enabled bool) {
1477+
if s == nil {
1478+
return
1479+
}
1480+
s.preferPaidEnabled.Store(enabled)
1481+
}
1482+
14621483
// RTManager 返回外部 rt-manager 联动客户端(启动时一定非 nil,是否启用看 Enabled())。
14631484
func (s *Store) RTManager() *RTManager {
14641485
if s == nil {

0 commit comments

Comments
 (0)