group_peak_rate_test.go 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. package service
  2. import (
  3. "context"
  4. "math"
  5. "testing"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  8. )
  9. func init() {
  10. // 测试固定全局时区为 UTC,确保判定可复现。
  11. _ = timezone.Init("UTC")
  12. }
  13. func newPeakGroup(enabled bool, start, end string, mult float64) *Group {
  14. return &Group{
  15. SubscriptionType: "subscription",
  16. PeakRateEnabled: enabled,
  17. PeakStart: start,
  18. PeakEnd: end,
  19. PeakRateMultiplier: mult,
  20. }
  21. }
  22. func at(hour, min int) time.Time {
  23. return time.Date(2026, 6, 29, hour, min, 0, 0, time.UTC)
  24. }
  25. func TestPeakMultiplierAt_DisabledOrUnconfigured(t *testing.T) {
  26. cases := []struct {
  27. name string
  28. g *Group
  29. }{
  30. {"disabled", newPeakGroup(false, "14:00", "18:00", 3.0)},
  31. {"empty start", newPeakGroup(true, "", "18:00", 3.0)},
  32. {"empty end", newPeakGroup(true, "14:00", "", 3.0)},
  33. {"invalid start>=end", newPeakGroup(true, "18:00", "14:00", 3.0)},
  34. {"equal start==end", newPeakGroup(true, "14:00", "14:00", 3.0)},
  35. {"malformed start", newPeakGroup(true, "99:99", "18:00", 3.0)},
  36. }
  37. for _, c := range cases {
  38. t.Run(c.name, func(t *testing.T) {
  39. if got := c.g.PeakMultiplierAt(at(15, 0)); got != 1.0 {
  40. t.Fatalf("expect 1.0, got %v", got)
  41. }
  42. })
  43. }
  44. }
  45. func TestPeakMultiplierAt_NilReceiver(t *testing.T) {
  46. var g *Group
  47. if got := g.PeakMultiplierAt(at(15, 0)); got != 1.0 {
  48. t.Fatalf("expect 1.0, got %v", got)
  49. }
  50. }
  51. func TestPeakMultiplierAt_Boundaries(t *testing.T) {
  52. g := newPeakGroup(true, "14:00", "18:00", 3.0)
  53. cases := []struct {
  54. t time.Time
  55. want float64
  56. }{
  57. {at(13, 59), 1.0},
  58. {at(14, 0), 3.0},
  59. {at(15, 30), 3.0},
  60. {at(17, 59), 3.0},
  61. {at(18, 0), 1.0},
  62. {at(23, 0), 1.0},
  63. }
  64. for _, c := range cases {
  65. t.Run(c.t.Format("15:04"), func(t *testing.T) {
  66. if got := g.PeakMultiplierAt(c.t); got != c.want {
  67. t.Fatalf("at %s: expect %v, got %v", c.t.Format("15:04"), c.want, got)
  68. }
  69. })
  70. }
  71. }
  72. func TestPeakMultiplierAt_RespectsTimezoneLocation(t *testing.T) {
  73. // 全局时区为 UTC。北京 15:00 = UTC 07:00,不在 [14:00,18:00)。
  74. nonUTC := time.Date(2026, 6, 29, 15, 0, 0, 0, mustLoad("Asia/Shanghai"))
  75. g := newPeakGroup(true, "14:00", "18:00", 3.0)
  76. if got := g.PeakMultiplierAt(nonUTC); got != 1.0 {
  77. t.Fatalf("expect 1.0 (converted to UTC 07:00), got %v", got)
  78. }
  79. }
  80. func mustLoad(name string) *time.Location {
  81. loc, err := time.LoadLocation(name)
  82. if err != nil {
  83. panic(err)
  84. }
  85. return loc
  86. }
  87. func TestValidatePeakRateConfig(t *testing.T) {
  88. cases := []struct {
  89. name string
  90. subType string
  91. enabled bool
  92. start string
  93. end string
  94. mult float64
  95. wantErr bool
  96. }{
  97. {"disabled passes through", "subscription", false, "", "", 0, false},
  98. {"subscription enabled valid", "subscription", true, "14:00", "18:00", 3.0, false},
  99. {"standard enabled rejected", "standard", true, "14:00", "18:00", 3.0, true},
  100. {"empty type treated as standard", "", true, "14:00", "18:00", 3.0, true},
  101. {"standard disabled passes", "standard", false, "", "", 0, false},
  102. {"enabled empty start", "subscription", true, "", "18:00", 1.0, true},
  103. {"enabled empty end", "subscription", true, "14:00", "", 1.0, true},
  104. {"enabled malformed start", "subscription", true, "99:99", "18:00", 1.0, true},
  105. {"enabled malformed end", "subscription", true, "14:00", "25:00", 1.0, true},
  106. {"enabled equal start==end", "subscription", true, "14:00", "14:00", 1.0, true},
  107. {"enabled cross-day rejected", "subscription", true, "22:00", "02:00", 1.0, true},
  108. {"enabled negative multiplier", "subscription", true, "14:00", "18:00", -0.5, true},
  109. {"enabled zero multiplier allowed", "subscription", true, "14:00", "18:00", 0, false},
  110. }
  111. for _, c := range cases {
  112. t.Run(c.name, func(t *testing.T) {
  113. err := ValidatePeakRateConfig(c.subType, c.enabled, c.start, c.end, c.mult)
  114. if c.wantErr && err == nil {
  115. t.Fatalf("expect error, got nil")
  116. }
  117. if !c.wantErr && err != nil {
  118. t.Fatalf("expect no error, got %v", err)
  119. }
  120. })
  121. }
  122. }
  123. func TestPeakMultiplierAt_StandardTypeDegradesToOne(t *testing.T) {
  124. g := newPeakGroup(true, "14:00", "18:00", 3.0)
  125. g.SubscriptionType = "standard"
  126. if got := g.PeakMultiplierAt(at(15, 30)); got != 1.0 {
  127. t.Fatalf("standard group must degrade to 1.0, got %v", got)
  128. }
  129. sub := newPeakGroup(true, "14:00", "18:00", 3.0)
  130. sub.SubscriptionType = "subscription"
  131. if got := sub.PeakMultiplierAt(at(15, 30)); got != 3.0 {
  132. t.Fatalf("subscription group peak multiplier: got %v, want 3.0", got)
  133. }
  134. }
  135. // TestPeakMultiplier_GatewayBillingSequence 调用 gateway_service.recordUsageCore 与
  136. // openai_gateway_service.RecordUsage 共用的 computePeakAwareMultipliers,验证计费叠加顺序:
  137. // 图片按次倍率基于基础倍率算出且不受高峰影响,高峰因子只乘入 token 倍率。
  138. // 若有人调换叠加顺序或把高峰并入 imageMultiplier,此测试会失败。
  139. func TestPeakMultiplier_GatewayBillingSequence(t *testing.T) {
  140. const baseMultiplier = 0.8
  141. apiKey := &APIKey{Group: newPeakGroup(true, "14:00", "18:00", 3.0)}
  142. approxEq := func(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
  143. t.Run("peak hour amplifies token multiplier only", func(t *testing.T) {
  144. now := at(15, 30) // 处于 [14:00, 18:00)
  145. tokenMultiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, now)
  146. if !approxEq(imageMultiplier, baseMultiplier) {
  147. t.Fatalf("image multiplier must not be affected by peak: got %v, want %v", imageMultiplier, baseMultiplier)
  148. }
  149. if want := baseMultiplier * 3.0; !approxEq(tokenMultiplier, want) {
  150. t.Fatalf("token multiplier should include peak factor: got %v, want %v", tokenMultiplier, want)
  151. }
  152. })
  153. t.Run("off-peak leaves both multipliers at base", func(t *testing.T) {
  154. now := at(20, 0)
  155. tokenMultiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, now)
  156. if !approxEq(imageMultiplier, baseMultiplier) {
  157. t.Fatalf("image multiplier: got %v, want %v", imageMultiplier, baseMultiplier)
  158. }
  159. if !approxEq(tokenMultiplier, baseMultiplier) {
  160. t.Fatalf("token multiplier should equal base off-peak: got %v, want %v", tokenMultiplier, baseMultiplier)
  161. }
  162. })
  163. t.Run("image independent mode decoupled from peak", func(t *testing.T) {
  164. indGroup := newPeakGroup(true, "14:00", "18:00", 3.0)
  165. indGroup.ImageRateIndependent = true
  166. indGroup.ImageRateMultiplier = 0.5
  167. indKey := &APIKey{Group: indGroup}
  168. now := at(15, 30)
  169. tokenMultiplier, imageMultiplier := computePeakAwareMultipliers(indKey, baseMultiplier, now)
  170. if !approxEq(imageMultiplier, 0.5) {
  171. t.Fatalf("independent image multiplier: got %v, want 0.5", imageMultiplier)
  172. }
  173. if want := baseMultiplier * 3.0; !approxEq(tokenMultiplier, want) {
  174. t.Fatalf("token multiplier should include peak factor: got %v, want %v", tokenMultiplier, want)
  175. }
  176. })
  177. t.Run("nil api key degrades to base multipliers", func(t *testing.T) {
  178. now := at(15, 30)
  179. tokenMultiplier, imageMultiplier := computePeakAwareMultipliers(nil, baseMultiplier, now)
  180. if !approxEq(tokenMultiplier, baseMultiplier) {
  181. t.Fatalf("nil group token multiplier: got %v, want %v", tokenMultiplier, baseMultiplier)
  182. }
  183. if !approxEq(imageMultiplier, baseMultiplier) {
  184. t.Fatalf("nil group image multiplier: got %v, want %v", imageMultiplier, baseMultiplier)
  185. }
  186. })
  187. }
  188. // TestPeakMultiplier_SnapshotRoundTrip 防回归:认证缓存快照(APIKeyAuthGroupSnapshot)
  189. // 必须携带高峰倍率 4 字段,否则扣费路径拿到的 apiKey.Group 会缺字段、PeakMultiplierAt 恒降级为 1.0。
  190. // 调用真实链路 snapshotFromAPIKey → snapshotToAPIKey,验证 peak 配置经快照往返后仍生效。
  191. func TestPeakMultiplier_SnapshotRoundTrip(t *testing.T) {
  192. apiKey := &APIKey{
  193. User: &User{ID: 1, Status: StatusActive, Role: RoleUser},
  194. Group: newPeakGroup(true, "14:00", "18:00", 3.0),
  195. }
  196. svc := &APIKeyService{}
  197. snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey)
  198. if snapshot == nil || snapshot.Group == nil {
  199. t.Fatalf("snapshot or snapshot.Group must not be nil")
  200. }
  201. restored := svc.snapshotToAPIKey("k", snapshot)
  202. if restored.Group == nil {
  203. t.Fatalf("restored.Group must not be nil")
  204. }
  205. if !restored.Group.PeakRateEnabled ||
  206. restored.Group.PeakStart != "14:00" ||
  207. restored.Group.PeakEnd != "18:00" ||
  208. restored.Group.PeakRateMultiplier != 3.0 {
  209. t.Fatalf("peak fields lost in snapshot round-trip: %+v", restored.Group)
  210. }
  211. if got := restored.Group.PeakMultiplierAt(at(15, 30)); got != 3.0 {
  212. t.Fatalf("peak hour multiplier after round-trip: got %v, want 3.0", got)
  213. }
  214. if got := restored.Group.PeakMultiplierAt(at(20, 0)); got != 1.0 {
  215. t.Fatalf("off-peak multiplier after round-trip: got %v, want 1.0", got)
  216. }
  217. }