Files
sub2api/backend/internal/service/group.go
T
shaw 7cbb36f278 feat(billing): Codex alpha/search 网页搜索按次计费
- alpha/search 成功请求(上游 2xx)按次计费落 usage_logs(billing_mode=per_request),
  上游错误透传/failover 不计费;使用 mandatory 池提交,池满同步兜底不丢扣费
- 单价默认 0.01 USD/次(官方 $10/1000 次),分组新增 web_search_price_per_call
  覆盖价(0=免费,负数/留空=默认价),实际扣费叠加分组费率倍数(与 token 口径一致)
- 分组字段全链路:ent schema + 迁移 174 + 端口快照 v15 + admin 创建/更新 + DTO
- 前端分组表单(openai 平台)新增单次价格配置,实时预览应用当前倍率后的单次价格
- 该端点鉴权维持仅 OpenAI 分组(非 OpenAI 分组 404)
2026-07-13 09:54:51 +08:00

328 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"errors"
"fmt"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
)
type OpenAIMessagesDispatchModelConfig = domain.OpenAIMessagesDispatchModelConfig
type GroupModelsListConfig = domain.GroupModelsListConfig
type Group struct {
ID int64
Name string
Description string
Platform string
RateMultiplier float64
// 高峰时段倍率:peak_rate_enabled 为 true 且当前时刻处于 [PeakStart, PeakEnd) 时,
// token 计费倍率额外乘以 PeakRateMultiplier。详见 PeakMultiplierAt。
PeakRateEnabled bool
PeakStart string
PeakEnd string
PeakRateMultiplier float64
IsExclusive bool
Status string
Hydrated bool // indicates the group was loaded from a trusted repository source
SubscriptionType string
DailyLimitUSD *float64
WeeklyLimitUSD *float64
MonthlyLimitUSD *float64
DefaultValidityDays int
// 图片生成计费配置(antigravity 和 gemini 平台使用)
AllowImageGeneration bool
AllowBatchImageGeneration bool
ImageRateIndependent bool
ImageRateMultiplier float64
ImagePrice1K *float64
ImagePrice2K *float64
ImagePrice4K *float64
BatchImageDiscountMultiplier float64
BatchImageHoldMultiplier float64
VideoRateIndependent bool
VideoRateMultiplier float64
VideoPrice480P *float64
VideoPrice720P *float64
VideoPrice1080P *float64
// Codex alpha/search 网页搜索单次价格(USD/次,仅 openai 平台使用);
// nil 表示使用默认价 defaultWebSearchPricePerCall(官方 $10/1000 次)。
WebSearchPricePerCall *float64
// Claude Code 客户端限制
ClaudeCodeOnly bool
FallbackGroupID *int64
// 无效请求兜底分组(仅 anthropic 平台使用)
FallbackGroupIDOnInvalidRequest *int64
// 模型路由配置
// key: 模型匹配模式(支持 * 通配符,如 "claude-opus-*")
// value: 优先账号 ID 列表
ModelRouting map[string][]int64
ModelRoutingEnabled bool
// MCP XML 协议注入开关(仅 antigravity 平台使用)
MCPXMLInject bool
// 支持的模型系列(仅 antigravity 平台使用)
// 可选值: claude, gemini_text, gemini_image
SupportedModelScopes []string
// 分组排序
SortOrder int
// OpenAI Messages 调度配置(仅 openai 平台使用)
AllowMessagesDispatch bool
RequireOAuthOnly bool // 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini)
RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini)
DefaultMappedModel string
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig
ModelsListConfig GroupModelsListConfig
// RPMLimit 分组级每分钟请求数上限(0 = 不限制)。
// 一旦设置即接管该分组用户的限流(覆盖用户级 rpm_limit),可被 user-group rpm_override 进一步覆盖。
RPMLimit int
CreatedAt time.Time
UpdatedAt time.Time
AccountGroups []AccountGroup
AccountCount int64
ActiveAccountCount int64
RateLimitedAccountCount int64
}
func (g *Group) IsActive() bool {
return g.Status == StatusActive
}
func (g *Group) IsSubscriptionType() bool {
return g.SubscriptionType == SubscriptionTypeSubscription
}
func (g *Group) HasDailyLimit() bool {
return g.DailyLimitUSD != nil && *g.DailyLimitUSD > 0
}
func (g *Group) HasWeeklyLimit() bool {
return g.WeeklyLimitUSD != nil && *g.WeeklyLimitUSD > 0
}
func (g *Group) HasMonthlyLimit() bool {
return g.MonthlyLimitUSD != nil && *g.MonthlyLimitUSD > 0
}
// GetImagePrice 根据 image_size 返回对应的图片生成价格
// 如果分组未配置价格,返回 nil(调用方应使用默认值)
func (g *Group) GetImagePrice(imageSize string) *float64 {
switch imageSize {
case "1K":
return g.ImagePrice1K
case "2K":
return g.ImagePrice2K
case "4K":
return g.ImagePrice4K
default:
// 未知尺寸默认按 2K 计费
return g.ImagePrice2K
}
}
// GetVideoPrice 根据 resolution 返回对应的视频生成价格。
// 如果分组未配置价格,返回 nil(调用方应使用默认值)。
func (g *Group) GetVideoPrice(resolution string) *float64 {
switch NormalizeVideoBillingResolutionOrDefault(resolution) {
case VideoBillingResolution480P:
return g.VideoPrice480P
case VideoBillingResolution720P:
return g.VideoPrice720P
case VideoBillingResolution1080P:
return g.VideoPrice1080P
default:
return g.VideoPrice480P
}
}
// IsGroupContextValid reports whether a group from context has the fields required for routing decisions.
func IsGroupContextValid(group *Group) bool {
if group == nil {
return false
}
if group.ID <= 0 {
return false
}
if !group.Hydrated {
return false
}
if group.Platform == "" || group.Status == "" {
return false
}
return true
}
// GetRoutingAccountIDs 根据请求模型获取路由账号 ID 列表
// 返回匹配的优先账号 ID 列表,如果没有匹配规则则返回 nil
func (g *Group) GetRoutingAccountIDs(requestedModel string) []int64 {
if !g.ModelRoutingEnabled || len(g.ModelRouting) == 0 || requestedModel == "" {
return nil
}
// 1. 精确匹配优先
if accountIDs, ok := g.ModelRouting[requestedModel]; ok && len(accountIDs) > 0 {
return accountIDs
}
// 2. 通配符匹配(前缀匹配)
for pattern, accountIDs := range g.ModelRouting {
if matchModelPattern(pattern, requestedModel) && len(accountIDs) > 0 {
return accountIDs
}
}
return nil
}
// matchModelPattern 检查模型是否匹配模式
// 支持 * 通配符,如 "claude-opus-*" 匹配 "claude-opus-4-20250514"
func matchModelPattern(pattern, model string) bool {
if pattern == model {
return true
}
// 处理 * 通配符(仅支持末尾通配符)
if strings.HasSuffix(pattern, "*") {
prefix := strings.TrimSuffix(pattern, "*")
return strings.HasPrefix(model, prefix)
}
return false
}
// parseMinutes 把 "HH:MM" 解析为当日分钟数(0..1439),格式非法返回 (0,false)。
// 手工解析而非 time.Parse:本函数位于每请求的计费热路径(PeakMultiplierAt),
// 避免对静态配置字符串重复走 layout 解析与 time.Time 分配。
// 接受集与 time.Parse("15:04", s) 完全一致(存量数据按旧解析写入,不得收窄):
// 小时 1–2 位数字(0..23,允许不补零如 "1:30"),分钟固定 2 位数字(00..59)。
func parseMinutes(hhmm string) (int, bool) {
colon := strings.IndexByte(hhmm, ':')
if (colon != 1 && colon != 2) || len(hhmm)-colon-1 != 2 {
return 0, false
}
h := 0
for i := 0; i < colon; i++ {
d := hhmm[i] - '0'
if d > 9 {
return 0, false
}
h = h*10 + int(d)
}
m1, m2 := hhmm[colon+1]-'0', hhmm[colon+2]-'0'
if m1 > 9 || m2 > 9 {
return 0, false
}
m := int(m1)*10 + int(m2)
if h > 23 || m > 59 {
return 0, false
}
return h*60 + m, true
}
// PeakMultiplierAt 返回指定时刻 now 的高峰因子。
// - 未启用 / 未配置 / 配置非法(start>=end 或格式错误) / 非高峰时段 → 返回 1.0(安全降级)
// - 区间为左闭右开 [PeakStart, PeakEnd),仅支持当日区间,不支持跨天(如 22:00-次日02:00)
// - 时刻基于全局系统时区(timezone.Location)判定
//
// 该方法是纯函数,不读取任何外部状态,便于单测。
func (g *Group) PeakMultiplierAt(now time.Time) float64 {
if g == nil || !g.IsSubscriptionType() || !g.PeakRateEnabled || g.PeakStart == "" || g.PeakEnd == "" {
return 1.0
}
start, ok1 := parseMinutes(g.PeakStart)
end, ok2 := parseMinutes(g.PeakEnd)
if !ok1 || !ok2 || start >= end {
return 1.0
}
t := now.In(timezone.Location())
cur := t.Hour()*60 + t.Minute()
if cur >= start && cur < end {
return g.PeakRateMultiplier
}
return 1.0
}
// ValidatePeakRateConfig 是高峰倍率配置的唯一校验来源,供 handler 与 service 层共用。
// enabled=true 时仅允许订阅类型分组;并要求 start/end 合法且 end>start(不支持跨天),multiplier>=0。
// multiplier=0 是允许的,表示高峰 token 请求按 0 倍计费,可用于折扣/免费策略。
// enabled=false 时放行(不关心类型)。subscriptionType 为空按 standard 处理。
func ValidatePeakRateConfig(subscriptionType string, enabled bool, start, end string, multiplier float64) error {
if !enabled {
return nil
}
if subscriptionType != SubscriptionTypeSubscription {
return errors.New("高峰时段倍率仅支持订阅类型分组")
}
if start == "" || end == "" {
return errors.New("peak_rate_enabled 为 true 时 peak_start 与 peak_end 必填")
}
st, okStart := parseMinutes(start)
if !okStart {
return fmt.Errorf("peak_start 格式应为 HH:MM,got %q", start)
}
en, okEnd := parseMinutes(end)
if !okEnd {
return fmt.Errorf("peak_end 格式应为 HH:MM,got %q", end)
}
if st >= en {
return errors.New("peak_end 必须大于 peak_start(不支持跨天区间,如 22:00-02:00)")
}
if multiplier < 0 {
return errors.New("peak_rate_multiplier 不能为负")
}
return nil
}
// NormalizePeakRateConfig 归一化最终落库的高峰配置,CreateGroup 与 UpdateGroup 两条写路径共用(唯一收口):
// - 非订阅类型分组不携带任何高峰配置,一律清空(enabled=false、窗口置空、倍率归 1.0);
// - 订阅分组关闭高峰时保留已配置的合法窗口(便于临时停用后再启用),
// 但清掉无法解析的脏字符串与负倍率,避免脏数据入库。
//
// 与 ValidatePeakRateConfig 的分工:enabled=true 时校验已保证各字段合法,本函数为无操作;
// enabled=false 时校验放行,由本函数兜底清洗。调用顺序为先归一化、后校验,
// 使"订阅转标准"这类更新能静默清空高峰配置而不是被校验拒绝。
func NormalizePeakRateConfig(subscriptionType string, enabled bool, start, end string, multiplier float64) (bool, string, string, float64) {
if subscriptionType != SubscriptionTypeSubscription {
return false, "", "", 1.0
}
if !enabled {
if _, ok := parseMinutes(start); !ok {
start = ""
}
if _, ok := parseMinutes(end); !ok {
end = ""
}
if multiplier < 0 {
multiplier = 1.0
}
}
return enabled, start, end, multiplier
}
// computePeakAwareMultipliers 把"基础 token 倍率 base"(已含系统/分组/用户级倍率,但不含高峰)
// 拆分为最终 token 倍率与图片按次倍率:图片按次倍率基于 base 现算、不受高峰影响;token 倍率在 base 上叠加高峰因子。
// gateway_service.recordUsageCore 与 openai_gateway_service.RecordUsage 共用此函数,
// 锁死"高峰因子只乘入 token 倍率、图片按次倍率不受影响"这一叠加顺序——任何调换都会被 group_peak_rate_test 覆盖。
func computePeakAwareMultipliers(apiKey *APIKey, base float64, now time.Time) (text, image float64) {
image = resolveImageRateMultiplier(apiKey, base)
peak := 1.0
if apiKey != nil && apiKey.Group != nil {
peak = apiKey.Group.PeakMultiplierAt(now)
}
text = base * peak
return
}