group.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. package service
  2. import (
  3. "errors"
  4. "fmt"
  5. "math"
  6. "strings"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/domain"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  10. )
  11. type OpenAIMessagesDispatchModelConfig = domain.OpenAIMessagesDispatchModelConfig
  12. type GroupCodexModelsManifestConfig = domain.GroupCodexModelsManifestConfig
  13. type ReasoningEffortMapping = domain.ReasoningEffortMapping
  14. type Group struct {
  15. ID int64
  16. Name string
  17. Description string
  18. Platform string
  19. RateMultiplier float64
  20. // 高峰时段倍率:peak_rate_enabled 为 true 且当前时刻处于 [PeakStart, PeakEnd) 时,
  21. // token 计费倍率额外乘以 PeakRateMultiplier。详见 PeakMultiplierAt。
  22. PeakRateEnabled bool
  23. PeakStart string
  24. PeakEnd string
  25. PeakRateMultiplier float64
  26. IsExclusive bool
  27. SecurityPolicyEnabled bool
  28. SecurityPolicyMode string
  29. SecurityPolicyEmailEnabled bool
  30. Status string
  31. Hydrated bool // indicates the group was loaded from a trusted repository source
  32. // DuplicateOperationID is internal persistence metadata used only to recover
  33. // an already committed one-click copy. It must never be mapped to API DTOs.
  34. DuplicateOperationID string
  35. SubscriptionType string
  36. DailyLimitUSD *float64
  37. WeeklyLimitUSD *float64
  38. MonthlyLimitUSD *float64
  39. DefaultValidityDays int
  40. // 图片生成计费配置(antigravity 和 gemini 平台使用)
  41. AllowImageGeneration bool
  42. AllowBatchImageGeneration bool
  43. ImageRateIndependent bool
  44. ImageRateMultiplier float64
  45. ImagePrice1K *float64
  46. ImagePrice2K *float64
  47. ImagePrice4K *float64
  48. BatchImageDiscountMultiplier float64
  49. BatchImageHoldMultiplier float64
  50. VideoRateIndependent bool
  51. VideoRateMultiplier float64
  52. VideoPrice480P *float64
  53. VideoPrice720P *float64
  54. VideoPrice1080P *float64
  55. // VideoModelPrices is optional per-model-family per-second pricing
  56. // (groups.video_model_prices JSONB). Shape: family → resolution → USD/s.
  57. // When set for a model, overrides VideoPrice* for that model only.
  58. VideoModelPrices map[string]map[string]float64
  59. // Codex alpha/search 网页搜索单次价格(USD/次,仅 openai 平台使用);
  60. // nil 表示使用默认价 defaultWebSearchPricePerCall(官方 $10/1000 次)。
  61. WebSearchPricePerCall *float64
  62. // 搜索工具显式定价(per 1k calls)。
  63. SearchPricePer1k *float64
  64. // Grok Voice 显式定价(分组级,不按文本 RateMultiplier)。
  65. AudioRealtimePricePerMin *float64
  66. AudioTTSPricePerMillionChars *float64
  67. AudioSTTPricePerHour *float64
  68. // ModelPricing overrides channel and built-in prices for matching models.
  69. // Token intervals are selected only when LongContextPricingEnabled is true.
  70. LongContextPricingEnabled bool
  71. ModelPricing []ChannelModelPricing
  72. // Claude Code 客户端限制
  73. ClaudeCodeOnly bool
  74. FallbackGroupID *int64
  75. // 无效请求兜底分组(仅 anthropic 平台使用)
  76. FallbackGroupIDOnInvalidRequest *int64
  77. // 模型路由配置
  78. // key: 模型匹配模式(支持 * 通配符,如 "claude-opus-*")
  79. // value: 优先账号 ID 列表
  80. ModelRouting map[string][]int64
  81. ModelRoutingEnabled bool
  82. // MCP XML 协议注入开关(仅 antigravity 平台使用)
  83. MCPXMLInject bool
  84. // 支持的模型系列(仅 antigravity 平台使用)
  85. // 可选值: claude, gemini_text, gemini_image
  86. SupportedModelScopes []string
  87. // 分组排序
  88. SortOrder int
  89. // OpenAI Messages 调度配置(仅 openai 平台使用)
  90. AllowMessagesDispatch bool
  91. AllowLive bool
  92. ForceOpenAIFast bool // 强制 OpenAI 网关请求使用 service_tier=priority
  93. FreeOpenAIFast bool // OpenAI Fast 请求按 Standard 价格向用户计费
  94. RequireOAuthOnly bool // 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini)
  95. RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini)
  96. DefaultMappedModel string
  97. MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig
  98. ModelAllowlist GroupModelAllowlist
  99. // CodexModelsManifestConfig 开启后,普通模型列表与 Codex manifest 优先使用
  100. // 固定账号列表拉取并合并,不经过调度器(仅 openai 平台)。
  101. CodexModelsManifestConfig GroupCodexModelsManifestConfig
  102. // RPMLimit 分组级每分钟请求数上限(0 = 不限制)。
  103. // 一旦设置即接管该分组用户的限流(覆盖用户级 rpm_limit),可被 user-group rpm_override 进一步覆盖。
  104. RPMLimit int
  105. // MaxReasoningEffort limits the effective Anthropic/OpenAI reasoning effort.
  106. // Empty means unlimited; supported values are minimal/low/medium/high/xhigh/max.
  107. MaxReasoningEffort string
  108. // MaxReasoningEffortOverLimit is the access control when an explicit effort
  109. // exceeds the ceiling: downgrade (default) or deny.
  110. MaxReasoningEffortOverLimit string
  111. // ReasoningEffortMappings rewrites explicit request values before applying the ceiling.
  112. ReasoningEffortMappings []ReasoningEffortMapping
  113. // 分组利润控制(五个 token 计费平台可启用)。
  114. // 调度准入条件:账号倍率 U 满足 U <= D*(1-margin-buffer),
  115. // D 为请求用户当刻有效下游倍率(用户覆盖 ?? 分组默认,再乘高峰因子)。
  116. // 只过滤候选账号,不改变既有排序/评分/粘性/熔断。
  117. ProfitControlEnabled bool
  118. ProfitMinMargin float64 // 最低毛利率,小数存储(0.30=30%)
  119. ProfitSafetyBuffer float64 // 安全缓冲,小数,与 margin 相加后从 D 中扣除
  120. CreatedAt time.Time
  121. UpdatedAt time.Time
  122. AccountGroups []AccountGroup
  123. AccountCount int64
  124. ActiveAccountCount int64
  125. RateLimitedAccountCount int64
  126. }
  127. // IsGroupBindableInSimpleMode is the shared policy for groups that may be
  128. // surfaced and bound to accounts while running in simple mode.
  129. func IsGroupBindableInSimpleMode(group *Group) bool {
  130. return group != nil && group.Platform != PlatformComposite
  131. }
  132. func (g *Group) IsActive() bool {
  133. return g.Status == StatusActive
  134. }
  135. func (g *Group) IsSubscriptionType() bool {
  136. return g.SubscriptionType == SubscriptionTypeSubscription
  137. }
  138. func (g *Group) HasDailyLimit() bool {
  139. return g.DailyLimitUSD != nil && *g.DailyLimitUSD > 0
  140. }
  141. func (g *Group) HasWeeklyLimit() bool {
  142. return g.WeeklyLimitUSD != nil && *g.WeeklyLimitUSD > 0
  143. }
  144. func (g *Group) HasMonthlyLimit() bool {
  145. return g.MonthlyLimitUSD != nil && *g.MonthlyLimitUSD > 0
  146. }
  147. // GetImagePrice 根据 image_size 返回对应的图片生成价格
  148. // 如果分组未配置价格,返回 nil(调用方应使用默认值)
  149. func (g *Group) GetImagePrice(imageSize string) *float64 {
  150. switch imageSize {
  151. case "1K":
  152. return g.ImagePrice1K
  153. case "2K":
  154. return g.ImagePrice2K
  155. case "4K":
  156. return g.ImagePrice4K
  157. default:
  158. // 未知尺寸默认按 2K 计费
  159. return g.ImagePrice2K
  160. }
  161. }
  162. // GetVideoPrice 根据 resolution 返回对应的视频生成价格。
  163. // 如果分组未配置价格,返回 nil(调用方应使用默认值)。
  164. func (g *Group) GetVideoPrice(resolution string) *float64 {
  165. switch NormalizeVideoBillingResolutionOrDefault(resolution) {
  166. case VideoBillingResolution480P:
  167. return g.VideoPrice480P
  168. case VideoBillingResolution720P:
  169. return g.VideoPrice720P
  170. case VideoBillingResolution1080P:
  171. return g.VideoPrice1080P
  172. default:
  173. return g.VideoPrice480P
  174. }
  175. }
  176. // GetVideoPriceForModel prefers VideoModelPrices for the model family, then flat columns.
  177. func (g *Group) GetVideoPriceForModel(model, resolution string) *float64 {
  178. if g == nil {
  179. return nil
  180. }
  181. if price := LookupVideoModelPrice(g.VideoModelPrices, model, resolution); price != nil {
  182. return price
  183. }
  184. return g.GetVideoPrice(resolution)
  185. }
  186. // VideoPriceConfig builds billing config including optional per-model map.
  187. func (g *Group) VideoPriceConfig() *VideoPriceConfig {
  188. if g == nil {
  189. return nil
  190. }
  191. return &VideoPriceConfig{
  192. Price480P: g.VideoPrice480P,
  193. Price720P: g.VideoPrice720P,
  194. Price1080P: g.VideoPrice1080P,
  195. ModelPrices: NormalizeVideoModelPrices(g.VideoModelPrices),
  196. }
  197. }
  198. // IsGroupContextValid reports whether a group from context has the fields required for routing decisions.
  199. func IsGroupContextValid(group *Group) bool {
  200. if group == nil {
  201. return false
  202. }
  203. if group.ID <= 0 {
  204. return false
  205. }
  206. if !group.Hydrated {
  207. return false
  208. }
  209. if group.Platform == "" || group.Status == "" {
  210. return false
  211. }
  212. return true
  213. }
  214. // GetRoutingAccountIDs 根据请求模型获取路由账号 ID 列表
  215. // 返回匹配的优先账号 ID 列表,如果没有匹配规则则返回 nil
  216. func (g *Group) GetRoutingAccountIDs(requestedModel string) []int64 {
  217. if !g.ModelRoutingEnabled || len(g.ModelRouting) == 0 || requestedModel == "" {
  218. return nil
  219. }
  220. // 1. 精确匹配优先
  221. if accountIDs, ok := g.ModelRouting[requestedModel]; ok && len(accountIDs) > 0 {
  222. return accountIDs
  223. }
  224. // 2. 通配符匹配(前缀匹配)
  225. for pattern, accountIDs := range g.ModelRouting {
  226. if matchModelPattern(pattern, requestedModel) && len(accountIDs) > 0 {
  227. return accountIDs
  228. }
  229. }
  230. return nil
  231. }
  232. // matchModelPattern 检查模型是否匹配模式
  233. // 支持 * 通配符,如 "claude-opus-*" 匹配 "claude-opus-4-20250514"
  234. func matchModelPattern(pattern, model string) bool {
  235. if pattern == model {
  236. return true
  237. }
  238. // 处理 * 通配符(仅支持末尾通配符)
  239. if strings.HasSuffix(pattern, "*") {
  240. prefix := strings.TrimSuffix(pattern, "*")
  241. return strings.HasPrefix(model, prefix)
  242. }
  243. return false
  244. }
  245. // parseMinutes 把 "HH:MM" 解析为当日分钟数(0..1439),格式非法返回 (0,false)。
  246. // 手工解析而非 time.Parse:本函数位于每请求的计费热路径(PeakMultiplierAt),
  247. // 避免对静态配置字符串重复走 layout 解析与 time.Time 分配。
  248. // 接受集与 time.Parse("15:04", s) 完全一致(存量数据按旧解析写入,不得收窄):
  249. // 小时 1–2 位数字(0..23,允许不补零如 "1:30"),分钟固定 2 位数字(00..59)。
  250. func parseMinutes(hhmm string) (int, bool) {
  251. colon := strings.IndexByte(hhmm, ':')
  252. if (colon != 1 && colon != 2) || len(hhmm)-colon-1 != 2 {
  253. return 0, false
  254. }
  255. h := 0
  256. for i := 0; i < colon; i++ {
  257. d := hhmm[i] - '0'
  258. if d > 9 {
  259. return 0, false
  260. }
  261. h = h*10 + int(d)
  262. }
  263. m1, m2 := hhmm[colon+1]-'0', hhmm[colon+2]-'0'
  264. if m1 > 9 || m2 > 9 {
  265. return 0, false
  266. }
  267. m := int(m1)*10 + int(m2)
  268. if h > 23 || m > 59 {
  269. return 0, false
  270. }
  271. return h*60 + m, true
  272. }
  273. // PeakMultiplierAt 返回指定时刻 now 的高峰因子。
  274. // - 未启用 / 未配置 / 配置非法(start>=end 或格式错误) / 非高峰时段 → 返回 1.0(安全降级)
  275. // - 区间为左闭右开 [PeakStart, PeakEnd),仅支持当日区间,不支持跨天(如 22:00-次日02:00)
  276. // - 时刻基于全局系统时区(timezone.Location)判定
  277. //
  278. // 该方法是纯函数,不读取任何外部状态,便于单测。
  279. func (g *Group) PeakMultiplierAt(now time.Time) float64 {
  280. if g == nil || !g.IsSubscriptionType() || !g.PeakRateEnabled || g.PeakStart == "" || g.PeakEnd == "" {
  281. return 1.0
  282. }
  283. start, ok1 := parseMinutes(g.PeakStart)
  284. end, ok2 := parseMinutes(g.PeakEnd)
  285. if !ok1 || !ok2 || start >= end {
  286. return 1.0
  287. }
  288. t := now.In(timezone.Location())
  289. cur := t.Hour()*60 + t.Minute()
  290. if cur >= start && cur < end {
  291. return g.PeakRateMultiplier
  292. }
  293. return 1.0
  294. }
  295. // ValidatePeakRateConfig 是高峰倍率配置的唯一校验来源,供 handler 与 service 层共用。
  296. // enabled=true 时仅允许订阅类型分组;并要求 start/end 合法且 end>start(不支持跨天),multiplier>=0。
  297. // multiplier=0 是允许的,表示高峰 token 请求按 0 倍计费,可用于折扣/免费策略。
  298. // enabled=false 时放行(不关心类型)。subscriptionType 为空按 standard 处理。
  299. func ValidatePeakRateConfig(subscriptionType string, enabled bool, start, end string, multiplier float64) error {
  300. if !enabled {
  301. return nil
  302. }
  303. if subscriptionType != SubscriptionTypeSubscription {
  304. return errors.New("高峰时段倍率仅支持订阅类型分组")
  305. }
  306. if start == "" || end == "" {
  307. return errors.New("peak_rate_enabled 为 true 时 peak_start 与 peak_end 必填")
  308. }
  309. st, okStart := parseMinutes(start)
  310. if !okStart {
  311. return fmt.Errorf("peak_start 格式应为 HH:MM,got %q", start)
  312. }
  313. en, okEnd := parseMinutes(end)
  314. if !okEnd {
  315. return fmt.Errorf("peak_end 格式应为 HH:MM,got %q", end)
  316. }
  317. if st >= en {
  318. return errors.New("peak_end 必须大于 peak_start(不支持跨天区间,如 22:00-02:00)")
  319. }
  320. if multiplier < 0 {
  321. return errors.New("peak_rate_multiplier 不能为负")
  322. }
  323. return nil
  324. }
  325. // NormalizePeakRateConfig 归一化最终落库的高峰配置,CreateGroup 与 UpdateGroup 两条写路径共用(唯一收口):
  326. // - 非订阅类型分组不携带任何高峰配置,一律清空(enabled=false、窗口置空、倍率归 1.0);
  327. // - 订阅分组关闭高峰时保留已配置的合法窗口(便于临时停用后再启用),
  328. // 但清掉无法解析的脏字符串与负倍率,避免脏数据入库。
  329. //
  330. // 与 ValidatePeakRateConfig 的分工:enabled=true 时校验已保证各字段合法,本函数为无操作;
  331. // enabled=false 时校验放行,由本函数兜底清洗。调用顺序为先归一化、后校验,
  332. // 使"订阅转标准"这类更新能静默清空高峰配置而不是被校验拒绝。
  333. func NormalizePeakRateConfig(subscriptionType string, enabled bool, start, end string, multiplier float64) (bool, string, string, float64) {
  334. if subscriptionType != SubscriptionTypeSubscription {
  335. return false, "", "", 1.0
  336. }
  337. if !enabled {
  338. if _, ok := parseMinutes(start); !ok {
  339. start = ""
  340. }
  341. if _, ok := parseMinutes(end); !ok {
  342. end = ""
  343. }
  344. if multiplier < 0 {
  345. multiplier = 1.0
  346. }
  347. }
  348. return enabled, start, end, multiplier
  349. }
  350. // computePeakAwareMultipliers 把"基础 token 倍率 base"(已含系统/分组/用户级倍率,但不含高峰)
  351. // 拆分为最终 token 倍率与图片按次倍率:图片按次倍率基于 base 现算、不受高峰影响;token 倍率在 base 上叠加高峰因子。
  352. // gateway_service.recordUsageCore 与 openai_gateway_service.RecordUsage 共用此函数,
  353. // 锁死"高峰因子只乘入 token 倍率、图片按次倍率不受影响"这一叠加顺序——任何调换都会被 group_peak_rate_test 覆盖。
  354. func computePeakAwareMultipliers(apiKey *APIKey, base float64, now time.Time) (text, image float64) {
  355. image = resolveImageRateMultiplier(apiKey, base)
  356. peak := 1.0
  357. if apiKey != nil && apiKey.Group != nil {
  358. peak = apiKey.Group.PeakMultiplierAt(now)
  359. }
  360. text = base * peak
  361. return
  362. }
  363. // validProfitControlRatio 判定 margin/buffer 是否为可落库的合法小数:[0,1) 且非 NaN/Inf。
  364. func validProfitControlRatio(v float64) bool {
  365. return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v < 1
  366. }
  367. // NormalizeGroupPlatform 把创建分组时省略的 platform 归一化为默认平台。
  368. // handler 的入参预校验必须与 CreateGroup 落库时用同一个归一化结果,否则
  369. // 「省略 platform + 启用利润控制」会被 handler 以「平台不支持」400 掉,
  370. // 而该分组本会被建成受支持的 anthropic 分组。
  371. func NormalizeGroupPlatform(platform string) string {
  372. if platform == "" {
  373. return PlatformAnthropic
  374. }
  375. return platform
  376. }
  377. // ValidateProfitControlConfig 是分组利润控制配置的唯一校验来源,handler 与 service 层共用。
  378. // enabled=true 时仅允许五个可计费平台分组;margin/buffer 各自 ∈ [0,1),且 margin+buffer < 1
  379. // (相加 >=1 时阈值 <=0,所有可核价账号都会被排除,视为配置错误而不是静默全黑)。
  380. // enabled=false 时放行(不关心平台),由 Normalize 兜底清洗数值。
  381. func ValidateProfitControlConfig(platform string, enabled bool, minMargin, safetyBuffer float64) error {
  382. if !enabled {
  383. return nil
  384. }
  385. if !profitControlPlatformSupported(platform) {
  386. return errors.New("利润控制仅支持 openai、anthropic、gemini、grok、antigravity 平台分组")
  387. }
  388. if !validProfitControlRatio(minMargin) {
  389. return fmt.Errorf("profit_min_margin 应为 [0,1) 的小数,got %v", minMargin)
  390. }
  391. if !validProfitControlRatio(safetyBuffer) {
  392. return fmt.Errorf("profit_safety_buffer 应为 [0,1) 的小数,got %v", safetyBuffer)
  393. }
  394. if minMargin+safetyBuffer >= 1 {
  395. return errors.New("profit_min_margin 与 profit_safety_buffer 之和必须小于 1,否则将排除全部账号")
  396. }
  397. return nil
  398. }
  399. // NormalizeProfitControlConfig 归一化最终落库的利润控制配置,CreateGroup 与 UpdateGroup 共用(唯一收口):
  400. // - 非五个平台分组不携带利润控制,一律重置为默认(关、0、0);
  401. // - 支持平台关闭开关时保留合法数值(便于再次启用),清洗 NaN/Inf/越界脏值。
  402. //
  403. // 与 ValidateProfitControlConfig 的分工同高峰倍率:先归一化、后校验,
  404. // 使"openai 转其他平台"这类更新能静默清空利润配置而不是被校验拒绝。
  405. func NormalizeProfitControlConfig(platform string, enabled bool, minMargin, safetyBuffer float64) (bool, float64, float64) {
  406. if !profitControlPlatformSupported(platform) {
  407. return false, 0, 0
  408. }
  409. if !enabled {
  410. if !validProfitControlRatio(minMargin) {
  411. minMargin = 0
  412. }
  413. if !validProfitControlRatio(safetyBuffer) {
  414. safetyBuffer = 0
  415. }
  416. }
  417. return enabled, minMargin, safetyBuffer
  418. }
  419. func profitControlPlatformSupported(platform string) bool {
  420. switch platform {
  421. case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformGrok, PlatformAntigravity:
  422. return true
  423. default:
  424. return false
  425. }
  426. }
  427. // GetSearchPricePer1k returns explicit search/tool price per 1k calls if configured.
  428. func (g *Group) GetSearchPricePer1k() *float64 {
  429. if g == nil {
  430. return nil
  431. }
  432. return g.SearchPricePer1k
  433. }