Files
sub2api/backend/internal/service/video_billing.go
T
IanShaw027 07b46e93e4 fix(grok): 修正 voice 路由推导、视频价归一化与门禁缓存驻留
- custom-voices endpoint 改由匹配到的路由模板推导(c.FullPath())。
  原实现按请求 URL 字面后缀判 /audio,voice_id 恰为 "audio" 时
  GET /custom-voices/audio 会被改写成 custom-voices/audio/audio,
  把档案查询变成音频下载。补 voice_id="audio" 的路由推导测试。

- NormalizeVideoModelPrices 不再把无法识别的分辨率静默折算成 480p:
  新增 LookupVideoBillingResolution 报告未知档位,配置解析路径丢弃并告警,
  运行时计费仍走 OrDefault 兜底。model/tier 两层遍历改为排序遍历,
  多个别名收敛到同一 family 时结果不再随 Go map 顺序漂移,冲突单价告警。

- grok free quota 软门禁缓存新增过期淘汰。条目按 account_id 键控且只写不删,
  账号下线后会驻留至进程结束;淘汰只挂在已受 TTL 约束的查询路径上,
  不影响缓存命中热路径。

- 迁移 220 清空非 Grok 分组视频价前先落快照表 groups_video_price_backup_220,
  原 UPDATE 不可回滚。

- 简化 grok_search_count 两处等价冗余的事件类型分支。
2026-08-08 11:02:22 +08:00

156 lines
4.4 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 (
"log/slog"
"sort"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
)
// Canonical video price family keys used in groups.video_model_prices JSONB.
const (
VideoPriceFamilyGrokImagineVideo = "grok-imagine-video"
VideoPriceFamilyGrokImagineVideo15 = "grok-imagine-video-1.5"
)
// CanonicalGrokImagineVideoPriceFamily normalizes model aliases / preview / legacy
// IDs onto the price-family keys stored in video_model_prices.
func CanonicalGrokImagineVideoPriceFamily(model string) string {
if model == "" {
return ""
}
// Prefer shared xAI helper for known aliases. Keep future native Imagine
// models distinct so operators can assign them independent prices.
if c := xai.CanonicalImagineVideoModel(model); c != "" {
switch c {
case xai.DefaultImagineVideo15Model:
return VideoPriceFamilyGrokImagineVideo15
case xai.DefaultImagineVideoModel:
return VideoPriceFamilyGrokImagineVideo
}
if strings.HasPrefix(c, "grok-imagine-video-") {
return c
}
}
m := strings.ToLower(strings.TrimSpace(model))
for _, prefix := range []string{"xai/", "x-ai/", "grok/"} {
if strings.HasPrefix(m, prefix) {
m = strings.TrimPrefix(m, prefix)
break
}
}
switch {
case m == "grok-imagine-video-1.5" || m == "grok-imagine-video-1.5-preview" ||
m == "grok-video-1.5" || strings.Contains(m, "video-1.5"):
return VideoPriceFamilyGrokImagineVideo15
case m == "grok-imagine-video" || m == "grok-imagine-video-preview" ||
m == "grok-video" || m == "grok-video-latest":
return VideoPriceFamilyGrokImagineVideo
default:
return ""
}
}
// NormalizeVideoModelPrices cleans and canonicalizes a per-model resolution map.
// Keys become price families; tiers use 480p/720p/1080p. Negative prices dropped.
//
// Model keys are walked in sorted order rather than in Go map order: several
// aliases can canonicalize onto the same family, and an unordered walk would
// make the winning price for a conflicting tier vary between processes.
// Unrecognized tiers are dropped with a warning instead of silently collapsing
// into the 480p bucket.
func NormalizeVideoModelPrices(in map[string]map[string]float64) map[string]map[string]float64 {
if len(in) == 0 {
return nil
}
modelKeys := make([]string, 0, len(in))
for modelKey := range in {
modelKeys = append(modelKeys, modelKey)
}
sort.Strings(modelKeys)
out := make(map[string]map[string]float64)
for _, modelKey := range modelKeys {
tierPrices := in[modelKey]
if len(tierPrices) == 0 {
continue
}
family := CanonicalGrokImagineVideoPriceFamily(modelKey)
if family == "" {
key := strings.ToLower(strings.TrimSpace(modelKey))
switch key {
case VideoPriceFamilyGrokImagineVideo, VideoPriceFamilyGrokImagineVideo15:
family = key
default:
if key == "" {
continue
}
family = key
}
}
normalizedTiers := out[family]
if normalizedTiers == nil {
normalizedTiers = make(map[string]float64)
}
tierKeys := make([]string, 0, len(tierPrices))
for tierKey := range tierPrices {
tierKeys = append(tierKeys, tierKey)
}
sort.Strings(tierKeys)
for _, tierKey := range tierKeys {
price := tierPrices[tierKey]
if price < 0 {
continue
}
tier, ok := LookupVideoBillingResolution(tierKey)
if !ok {
slog.Warn("video_model_prices_unknown_resolution_dropped",
"model_key", modelKey,
"family", family,
"resolution", tierKey)
continue
}
if existing, exists := normalizedTiers[tier]; exists && existing != price {
slog.Warn("video_model_prices_conflicting_tier_price",
"model_key", modelKey,
"family", family,
"resolution", tier,
"previous_price", existing,
"price", price)
}
normalizedTiers[tier] = price
}
if len(normalizedTiers) > 0 {
out[family] = normalizedTiers
}
}
if len(out) == 0 {
return nil
}
return out
}
// LookupVideoModelPrice returns a per-second price from a model×resolution map, or nil.
func LookupVideoModelPrice(prices map[string]map[string]float64, model, resolution string) *float64 {
if len(prices) == 0 {
return nil
}
family := CanonicalGrokImagineVideoPriceFamily(model)
if family == "" {
family = strings.ToLower(strings.TrimSpace(model))
}
if family == "" {
return nil
}
tierPrices, ok := prices[family]
if !ok || len(tierPrices) == 0 {
return nil
}
tier := NormalizeVideoBillingResolutionOrDefault(resolution)
if price, ok := tierPrices[tier]; ok {
p := price
return &p
}
return nil
}