margin_service.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "log"
  7. "log/slog"
  8. "strings"
  9. "sync"
  10. "time"
  11. )
  12. // SettingKeyMarginFuseSettings 毛利熔断配置(JSON,见 MarginFuseSettings)。
  13. const SettingKeyMarginFuseSettings = "margin_fuse_settings"
  14. // SettingKeyMarginFusedChannels 熔断中渠道(JSON map[渠道ID]熔断时间,重启不丢)。
  15. const SettingKeyMarginFusedChannels = "margin_fused_channels"
  16. // MarginFuseSettings 毛利熔断阈值:窗口内某渠道预估支出达标且毛利率
  17. // 低于阈值时自动停用该渠道;冷却后自动恢复试探。
  18. type MarginFuseSettings struct {
  19. Enabled bool `json:"enabled"`
  20. WindowHours int `json:"window_hours"`
  21. MinSpend float64 `json:"min_spend"`
  22. MinMarginRate float64 `json:"min_margin_rate"`
  23. CooldownHours int `json:"cooldown_hours"`
  24. IntervalMinutes int `json:"interval_minutes"`
  25. }
  26. // DefaultMarginFuseSettings 默认:24h 窗口,预估支出 ≥$5 且毛利率 <0% 熔断,冷却 6h。
  27. func DefaultMarginFuseSettings() MarginFuseSettings {
  28. return MarginFuseSettings{
  29. Enabled: true, WindowHours: 24, MinSpend: 5,
  30. MinMarginRate: 0, CooldownHours: 6, IntervalMinutes: 5,
  31. }
  32. }
  33. // MarginRow 单(渠道,分组,模型)毛利行。EstCost 为空表示无目录价、无法估算。
  34. type MarginRow struct {
  35. ChannelID *int64 `json:"channel_id"`
  36. ChannelName string `json:"channel_name"`
  37. GroupID *int64 `json:"group_id"`
  38. GroupName string `json:"group_name"`
  39. Model string `json:"model"`
  40. Requests int64 `json:"requests"`
  41. Revenue float64 `json:"revenue"`
  42. EstCost *float64 `json:"est_cost,omitempty"`
  43. Margin *float64 `json:"margin,omitempty"`
  44. MarginRate *float64 `json:"margin_rate,omitempty"`
  45. }
  46. // MarginFuseEvent 熔断/恢复事件(内存保留近 100 条)。
  47. type MarginFuseEvent struct {
  48. At time.Time `json:"at"`
  49. ChannelID int64 `json:"channel_id"`
  50. Name string `json:"name"`
  51. Action string `json:"action"` // fused / recovered
  52. Reason string `json:"reason"`
  53. }
  54. // ChannelStatusStore 熔断需要的渠道读写接口(ChannelService 已实现)。
  55. type ChannelStatusStore interface {
  56. GetByID(ctx context.Context, id int64) (*Channel, error)
  57. Update(ctx context.Context, id int64, input *UpdateChannelInput) (*Channel, error)
  58. }
  59. // MarginService 毛利看板 + 熔断:收入取 usage_logs.actual_cost(用户实扣),
  60. // 成本按目录价 × token 估算(中转上游真实成本不可见时行业通用做法)。
  61. type MarginService struct {
  62. db *sql.DB
  63. channels ChannelStatusStore
  64. billingService *BillingService
  65. settingRepo SettingRepository
  66. mu sync.RWMutex
  67. fused map[int64]time.Time // 熔断中渠道 → 熔断时间
  68. events []MarginFuseEvent
  69. stopCh chan struct{}
  70. stopOnce sync.Once
  71. wg sync.WaitGroup
  72. }
  73. // NewMarginService 创建毛利服务(不启动后台循环)。
  74. func NewMarginService(db *sql.DB, channels ChannelStatusStore, billing *BillingService, settingRepo SettingRepository) *MarginService {
  75. return &MarginService{
  76. db: db, channels: channels, billingService: billing,
  77. settingRepo: settingRepo, fused: make(map[int64]time.Time),
  78. stopCh: make(chan struct{}),
  79. }
  80. }
  81. // ProvideMarginService 创建并启动毛利服务(wire 用)。
  82. func ProvideMarginService(db *sql.DB, channelService *ChannelService, billing *BillingService, settingRepo SettingRepository) *MarginService {
  83. svc := NewMarginService(db, channelService, billing, settingRepo)
  84. svc.Start()
  85. return svc
  86. }
  87. // Start 启动周期熔断评估。
  88. func (s *MarginService) Start() {
  89. if s == nil || s.db == nil || s.channels == nil {
  90. return
  91. }
  92. s.loadFused()
  93. s.wg.Add(1)
  94. go func() {
  95. defer s.wg.Done()
  96. ticker := time.NewTicker(time.Duration(s.currentSettings().IntervalMinutes) * time.Minute)
  97. defer ticker.Stop()
  98. s.runOnce()
  99. for {
  100. select {
  101. case <-ticker.C:
  102. s.runOnce()
  103. case <-s.stopCh:
  104. return
  105. }
  106. }
  107. }()
  108. }
  109. // Stop 停止后台循环。
  110. func (s *MarginService) Stop() {
  111. if s == nil {
  112. return
  113. }
  114. s.stopOnce.Do(func() { close(s.stopCh) })
  115. s.wg.Wait()
  116. }
  117. func (s *MarginService) currentSettings() MarginFuseSettings {
  118. cfg := DefaultMarginFuseSettings()
  119. if s == nil || s.settingRepo == nil {
  120. return cfg
  121. }
  122. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  123. defer cancel()
  124. raw, err := s.settingRepo.GetValue(ctx, SettingKeyMarginFuseSettings)
  125. if err != nil || strings.TrimSpace(raw) == "" {
  126. return cfg
  127. }
  128. var parsed MarginFuseSettings
  129. if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
  130. slog.Warn("margin.bad_settings", "error", err)
  131. return cfg
  132. }
  133. return normalizeMarginFuseSettings(parsed)
  134. }
  135. func normalizeMarginFuseSettings(in MarginFuseSettings) MarginFuseSettings {
  136. def := DefaultMarginFuseSettings()
  137. if in.WindowHours <= 0 {
  138. in.WindowHours = def.WindowHours
  139. }
  140. if in.MinSpend < 0 {
  141. in.MinSpend = def.MinSpend
  142. }
  143. if in.CooldownHours <= 0 {
  144. in.CooldownHours = def.CooldownHours
  145. }
  146. if in.IntervalMinutes < 1 {
  147. in.IntervalMinutes = def.IntervalMinutes
  148. }
  149. return in
  150. }
  151. // GetSettings 读取当前配置。
  152. func (s *MarginService) GetSettings(_ context.Context) MarginFuseSettings {
  153. if s == nil {
  154. return DefaultMarginFuseSettings()
  155. }
  156. return s.currentSettings()
  157. }
  158. // UpdateSettings 保存配置。
  159. func (s *MarginService) UpdateSettings(ctx context.Context, in MarginFuseSettings) (MarginFuseSettings, error) {
  160. if s == nil || s.settingRepo == nil {
  161. return DefaultMarginFuseSettings(), nil
  162. }
  163. norm := normalizeMarginFuseSettings(in)
  164. raw, err := json.Marshal(norm)
  165. if err != nil {
  166. return norm, err
  167. }
  168. if err := s.settingRepo.Set(ctx, SettingKeyMarginFuseSettings, string(raw)); err != nil {
  169. return norm, err
  170. }
  171. return norm, nil
  172. }
  173. // Events 返回近期熔断/恢复事件。
  174. func (s *MarginService) Events() []MarginFuseEvent {
  175. if s == nil {
  176. return nil
  177. }
  178. s.mu.RLock()
  179. defer s.mu.RUnlock()
  180. out := make([]MarginFuseEvent, len(s.events))
  181. copy(out, s.events)
  182. return out
  183. }
  184. type marginAggRow struct {
  185. channelID sql.NullInt64
  186. channelName sql.NullString
  187. groupID sql.NullInt64
  188. groupName sql.NullString
  189. model string
  190. requests int64
  191. revenue float64
  192. inTok int64
  193. outTok int64
  194. crTok int64
  195. cwTok int64
  196. }
  197. // Summary 聚合毛利:groupID 为 nil 表示全部分组。
  198. func (s *MarginService) Summary(ctx context.Context, hours int, groupID *int64) ([]MarginRow, error) {
  199. if s == nil || s.db == nil {
  200. return nil, nil
  201. }
  202. if hours <= 0 || hours > 24*90 {
  203. hours = 24
  204. }
  205. q := `
  206. SELECT u.channel_id, c.name, u.group_id, g.name, u.model,
  207. COUNT(*), COALESCE(SUM(u.actual_cost),0),
  208. COALESCE(SUM(u.input_tokens),0), COALESCE(SUM(u.output_tokens),0),
  209. COALESCE(SUM(u.cache_read_tokens),0), COALESCE(SUM(u.cache_creation_tokens),0)
  210. FROM usage_logs u
  211. LEFT JOIN channels c ON c.id = u.channel_id
  212. LEFT JOIN groups g ON g.id = u.group_id
  213. WHERE u.created_at >= NOW() - ($1 || ' hours')::interval
  214. AND ($2::bigint IS NULL OR u.group_id = $2)
  215. GROUP BY u.channel_id, c.name, u.group_id, g.name, u.model
  216. ORDER BY SUM(u.actual_cost) DESC
  217. LIMIT 2000`
  218. var gid any
  219. if groupID != nil {
  220. gid = *groupID
  221. }
  222. rows, err := s.db.QueryContext(ctx, q, hours, gid)
  223. if err != nil {
  224. return nil, err
  225. }
  226. defer func() { _ = rows.Close() }()
  227. out := make([]MarginRow, 0)
  228. for rows.Next() {
  229. var r marginAggRow
  230. if err := rows.Scan(&r.channelID, &r.channelName, &r.groupID, &r.groupName,
  231. &r.model, &r.requests, &r.revenue, &r.inTok, &r.outTok, &r.crTok, &r.cwTok); err != nil {
  232. return nil, err
  233. }
  234. out = append(out, s.toMarginRow(r))
  235. }
  236. return out, rows.Err()
  237. }
  238. func (s *MarginService) toMarginRow(r marginAggRow) MarginRow {
  239. row := MarginRow{
  240. Model: r.model, Requests: r.requests, Revenue: r.revenue,
  241. ChannelName: "direct",
  242. GroupName: "ungrouped",
  243. }
  244. if r.channelID.Valid {
  245. id := r.channelID.Int64
  246. row.ChannelID = &id
  247. }
  248. if r.channelName.Valid && r.channelName.String != "" {
  249. row.ChannelName = r.channelName.String
  250. }
  251. if r.groupID.Valid {
  252. id := r.groupID.Int64
  253. row.GroupID = &id
  254. }
  255. if r.groupName.Valid && r.groupName.String != "" {
  256. row.GroupName = r.groupName.String
  257. }
  258. if s == nil || s.billingService == nil {
  259. return row
  260. }
  261. unit, err := s.billingService.GetModelPricing(r.model)
  262. if err != nil || unit == nil {
  263. return row
  264. }
  265. est := float64(r.inTok)*unit.InputPricePerToken +
  266. float64(r.outTok)*unit.OutputPricePerToken +
  267. float64(r.crTok)*unit.CacheReadPricePerToken +
  268. float64(r.cwTok)*unit.CacheCreationPricePerToken
  269. row.EstCost = &est
  270. margin := r.revenue - est
  271. row.Margin = &margin
  272. if r.revenue > 0 {
  273. rate := margin / r.revenue
  274. row.MarginRate = &rate
  275. }
  276. return row
  277. }
  278. // loadFused 从配置恢复熔断跟踪(重启后已 disabled 的渠道继续被跟踪)。
  279. func (s *MarginService) loadFused() {
  280. if s == nil || s.settingRepo == nil {
  281. return
  282. }
  283. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  284. defer cancel()
  285. raw, err := s.settingRepo.GetValue(ctx, SettingKeyMarginFusedChannels)
  286. if err != nil || strings.TrimSpace(raw) == "" {
  287. return
  288. }
  289. var m map[int64]time.Time
  290. if err := json.Unmarshal([]byte(raw), &m); err != nil {
  291. slog.Warn("margin.bad_fused", "error", err)
  292. return
  293. }
  294. s.mu.Lock()
  295. for id, at := range m {
  296. if id > 0 {
  297. s.fused[id] = at
  298. }
  299. }
  300. s.mu.Unlock()
  301. }
  302. func (s *MarginService) persistFused() {
  303. if s == nil || s.settingRepo == nil {
  304. return
  305. }
  306. s.mu.RLock()
  307. m := make(map[int64]time.Time, len(s.fused))
  308. for id, at := range s.fused {
  309. m[id] = at
  310. }
  311. s.mu.RUnlock()
  312. raw, err := json.Marshal(m)
  313. if err != nil {
  314. return
  315. }
  316. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  317. defer cancel()
  318. if err := s.settingRepo.Set(ctx, SettingKeyMarginFusedChannels, string(raw)); err != nil {
  319. slog.Warn("margin.persist_fused_failed", "error", err)
  320. }
  321. }
  322. func (s *MarginService) pushEvent(ev MarginFuseEvent) {
  323. s.mu.Lock()
  324. defer s.mu.Unlock()
  325. s.events = append([]MarginFuseEvent{ev}, s.events...)
  326. if len(s.events) > 100 {
  327. s.events = s.events[:100]
  328. }
  329. }
  330. func (s *MarginService) runOnce() {
  331. cfg := s.currentSettings()
  332. if !cfg.Enabled {
  333. return
  334. }
  335. ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
  336. defer cancel()
  337. rows, err := s.Summary(ctx, cfg.WindowHours, nil)
  338. if err != nil {
  339. log.Printf("[Margin] summary failed: %v", err)
  340. return
  341. }
  342. // 按渠道汇总
  343. type agg struct {
  344. name string
  345. revenue float64
  346. cost float64
  347. known bool
  348. }
  349. byChannel := make(map[int64]*agg)
  350. for _, r := range rows {
  351. if r.ChannelID == nil || r.EstCost == nil {
  352. continue
  353. }
  354. a := byChannel[*r.ChannelID]
  355. if a == nil {
  356. a = &agg{name: r.ChannelName}
  357. byChannel[*r.ChannelID] = a
  358. }
  359. a.revenue += r.Revenue
  360. a.cost += *r.EstCost
  361. a.known = true
  362. }
  363. now := time.Now()
  364. for id, a := range byChannel {
  365. if !a.known || a.cost < cfg.MinSpend {
  366. continue
  367. }
  368. rate := 0.0
  369. if a.revenue > 0 {
  370. rate = (a.revenue - a.cost) / a.revenue
  371. } else if a.cost > 0 {
  372. rate = -1
  373. }
  374. s.mu.RLock()
  375. _, fused := s.fused[id]
  376. s.mu.RUnlock()
  377. if fused {
  378. continue
  379. }
  380. if rate >= cfg.MinMarginRate {
  381. continue
  382. }
  383. // 熔断语义:停用渠道即摘掉它的自定义定价/映射/限制,
  384. // 分组回落目录价。若亏损本就来自渠道低价,此举直接止血;
  385. // 若亏损来自的分级/全站覆盖,面板仍可见,由管理员人工处理。
  386. ch, err := s.channels.GetByID(ctx, id)
  387. if err != nil || ch == nil || ch.Status != StatusActive {
  388. continue
  389. }
  390. if _, err := s.channels.Update(ctx, id, &UpdateChannelInput{Status: StatusDisabled}); err != nil {
  391. log.Printf("[Margin] fuse channel %d failed: %v", id, err)
  392. continue
  393. }
  394. s.mu.Lock()
  395. s.fused[id] = now
  396. s.mu.Unlock()
  397. s.persistFused()
  398. slog.Warn("margin.channel_fused", "channel_id", id, "name", a.name, "margin_rate", rate)
  399. s.pushEvent(MarginFuseEvent{At: now, ChannelID: id, Name: a.name,
  400. Action: "fused", Reason: "margin_rate below threshold"})
  401. }
  402. // 恢复扫描基于 DB 状态而非当窗口流量:停量渠道不在 byChannel 中,
  403. // 也必须能走出冷却。
  404. s.mu.RLock()
  405. fusedIDs := make([]int64, 0, len(s.fused))
  406. fusedAt := make(map[int64]time.Time, len(s.fused))
  407. for id, at := range s.fused {
  408. fusedIDs = append(fusedIDs, id)
  409. fusedAt[id] = at
  410. }
  411. s.mu.RUnlock()
  412. for _, id := range fusedIDs {
  413. if now.Sub(fusedAt[id]) < time.Duration(cfg.CooldownHours)*time.Hour {
  414. continue
  415. }
  416. ch, err := s.channels.GetByID(ctx, id)
  417. if err != nil || ch == nil {
  418. continue
  419. }
  420. if ch.Status != StatusDisabled {
  421. // 管理员已手动恢复:不再跟踪
  422. s.mu.Lock()
  423. delete(s.fused, id)
  424. s.mu.Unlock()
  425. s.persistFused()
  426. continue
  427. }
  428. if _, err := s.channels.Update(ctx, id, &UpdateChannelInput{Status: StatusActive}); err != nil {
  429. log.Printf("[Margin] recover channel %d failed: %v", id, err)
  430. continue
  431. }
  432. s.mu.Lock()
  433. delete(s.fused, id)
  434. s.mu.Unlock()
  435. s.persistFused()
  436. slog.Info("margin.channel_recovered", "channel_id", id)
  437. s.pushEvent(MarginFuseEvent{At: now, ChannelID: id,
  438. Action: "recovered", Reason: "cooldown elapsed, probing"})
  439. }
  440. }
  441. // Unfuse 手动恢复渠道并清除熔断跟踪。
  442. func (s *MarginService) Unfuse(ctx context.Context, id int64) error {
  443. if s == nil || s.channels == nil {
  444. return nil
  445. }
  446. if _, err := s.channels.Update(ctx, id, &UpdateChannelInput{Status: StatusActive}); err != nil {
  447. return err
  448. }
  449. s.mu.Lock()
  450. delete(s.fused, id)
  451. s.mu.Unlock()
  452. s.pushEvent(MarginFuseEvent{At: time.Now(), ChannelID: id, Action: "recovered", Reason: "manual"})
  453. return nil
  454. }