fix(pricing): 图片规格积分 - 按分辨率匹配画布价格

This commit is contained in:
Rou
2026-09-05 17:19:01 +08:00
parent 447cac9eeb
commit 61f13d8d4a
7 changed files with 300 additions and 8 deletions

View File

@@ -1,7 +1,10 @@
package service
import (
"fmt"
"strings"
"testing"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
@@ -10,6 +13,72 @@ import (
"gorm.io/gorm"
)
func TestImageSpecificationQuoteAgreesWithTaskBilling(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.SystemSetting{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}); err != nil {
t.Fatal(err)
}
channelModel := model.ChannelModel{
ID: "image-channel-model", ChannelID: "image-channel", ModelKey: "image-model", Capability: "image",
Protocol: model.ChannelInterfaceOpenAIImage, Enabled: true, PriceConfigured: true,
}
for operationIndex, operation := range []string{"text_to_image", "image_to_image"} {
for index, quality := range []string{"1k", "2k", "4k"} {
selector := fmt.Sprintf(`{"operation":%q,"quality":%q}`, operation, quality)
channelModel.PriceTiers = append(channelModel.PriceTiers, model.ChannelModelPriceTier{
ID: operation + "-" + quality, ChannelModelID: channelModel.ID, SelectorKey: selector, SelectorJSON: selector,
Resolution: "*", BillingMode: "fixed_request", UnitPriceMicrocredits: int64(index+1+operationIndex*3) * 1_000_000,
Enabled: true, PriceConfigured: true,
})
}
}
if err := db.Create(&channelModel).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&channelModel.PriceTiers).Error; err != nil {
t.Fatal(err)
}
spec := CapabilitySpec{Version: 1, Capability: "image", Inputs: map[string]InputConstraint{"image": {Max: 9}}, Options: map[string]OptionConstraint{"quality": {Values: []any{"1k", "2k", "4k"}}}}
cached := cachedLogicalModel{
Model: model.LogicalModel{ID: "logical-image", PricePolicy: "channel", Capability: "image"},
ProductSpec: spec, Defaults: map[string]any{"quality": "2k"},
Routes: []cachedLogicalRoute{{Route: model.LogicalModelRoute{ID: "image-route", Enabled: true, Weight: 1}, CapabilitySpec: spec, ChannelModel: channelModel}},
}
svc := &Service{repo: repository.New(db), routeCatalogTTL: time.Hour, routeCatalog: &routeCatalogSnapshot{LoadedAt: time.Now(), Models: map[string]cachedLogicalModel{"logical-image": cached}}}
for _, tier := range channelModel.PriceTiers {
selector := model.DecodeSKUSelector(tier.SelectorJSON)
imageCount := 0
if selector["operation"] == "image_to_image" {
imageCount = 1
}
input := map[string]any{"mode": "image", "referenceImages": make([]any, imageCount), "config": map[string]any{
"channelId": channelModel.ChannelID, "model": channelModel.ModelKey, "quality": strings.ToUpper(selector["quality"]),
}}
intent := ModelRequestIntentFromTaskInput(input, "canvas_image", "image")
quote, err := svc.QuoteLogicalModel("logical-image", intent)
if err != nil {
t.Fatalf("quote %s: %v", tier.ID, err)
}
order, err := svc.taskBillingOrder("user", &model.Task{ID: "task", Type: "canvas_image", Operation: "image"}, input)
if err != nil {
t.Fatalf("billing %s: %v", tier.ID, err)
}
if quote.AmountMicrocredits != tier.UnitPriceMicrocredits || order.AmountMicrocredits != quote.AmountMicrocredits || order.PriceTierID != tier.ID {
t.Fatalf("tier %s: quote=%#v order=%#v", tier.ID, quote, order)
}
}
quote, err := svc.QuoteLogicalModel("logical-image", ModelRequestIntent{Capability: "image"})
if err != nil || quote.AmountMicrocredits != 2_000_000 {
t.Fatalf("default resolution quote=%#v error=%v", quote, err)
}
if _, err := svc.QuoteLogicalModel("logical-image", ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "8k"}}); err == nil {
t.Fatal("unconfigured resolution must not be quoted")
}
}
func TestTaskBillingOrderMatchesSystemImagePriceTierFromRequestedSpec(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {

View File

@@ -85,7 +85,14 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper
}
func normalizeModelRequestOption(name string, value any) any {
if canonicalCapabilityOptionName(name) != "vquality" {
canonicalName := canonicalCapabilityOptionName(name)
if canonicalName == "quality" || canonicalName == "size" {
if text, ok := value.(string); ok {
return strings.ToLower(strings.TrimSpace(text))
}
return value
}
if canonicalName != "vquality" {
return value
}
resolution, ok := value.(string)
@@ -702,7 +709,18 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string {
selector["videoSeconds"] = strconv.Itoa(seconds)
}
case "image":
if intent.Inputs["image"] > 0 {
selector["operation"] = "image_to_image"
} else {
selector["operation"] = "text_to_image"
}
if quality := normalizeImagePriceQuality(fmt.Sprint(intent.Options["quality"]), fmt.Sprint(intent.Options["size"])); quality != "" {
selector["quality"] = quality
}
for _, key := range []string{"quality", "size"} {
if key == "quality" && selector["quality"] != "" {
continue
}
if value := strings.ToLower(strings.TrimSpace(fmt.Sprint(intent.Options[key]))); value != "" && value != "auto" && value != "any" {
selector[key] = value
}
@@ -711,6 +729,33 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string {
return selector
}
func normalizeImagePriceQuality(rawQuality string, rawSize string) string {
quality := strings.ToLower(strings.TrimSpace(rawQuality))
if quality != "" && quality != "auto" && quality != "any" {
return quality
}
parts := strings.Split(strings.ToLower(strings.TrimSpace(rawSize)), "x")
if len(parts) != 2 {
return ""
}
width, widthErr := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
height, heightErr := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 || width > (1<<32)/height {
return ""
}
pixels := width * height
switch {
case pixels <= 2_000_000:
return "1k"
case pixels <= 4_300_000:
return "2k"
case pixels <= 8_294_400:
return "4k"
default:
return ""
}
}
func skuSelectorForTier(tier model.ChannelModelPriceTier) map[string]string {
selector := model.DecodeSKUSelector(tier.SelectorJSON)
if len(selector) == 0 {

View File

@@ -1,11 +1,43 @@
package service
import (
"fmt"
"testing"
"infinite-canvas/backend/internal/model"
)
func TestImagePriceTiersMatchResolutionAndActualReferences(t *testing.T) {
channelModel := model.ChannelModel{}
for _, operation := range []string{"text_to_image", "image_to_image"} {
for _, quality := range []string{"1k", "2k", "4k"} {
channelModel.PriceTiers = append(channelModel.PriceTiers, model.ChannelModelPriceTier{
ID: operation + "-" + quality, SelectorJSON: fmt.Sprintf(`{"operation":%q,"quality":%q}`, operation, quality), Enabled: true, PriceConfigured: true,
})
}
}
for _, operation := range []string{"", "image", "text_to_image", "image_to_image"} {
for _, quality := range []string{"1K", "2K", "4K"} {
for _, imageCount := range []int{0, 1, 3} {
intent := ModelRequestIntentFromTaskInput(map[string]any{
"mode": "image", "referenceImages": make([]any, imageCount), "config": map[string]any{"quality": quality},
}, "canvas_image", operation)
wantOperation := "text_to_image"
if imageCount > 0 {
wantOperation = "image_to_image"
}
tier := channelModelPriceTierForIntent(channelModel, intent)
if tier == nil || tier.ID != wantOperation+"-"+skuSelectorForIntent(intent)["quality"] {
t.Fatalf("operation=%q quality=%q imageCount=%d: tier=%#v", operation, quality, imageCount, tier)
}
}
}
}
if tier := channelModelPriceTierForIntent(channelModel, ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "8k"}}); tier != nil {
t.Fatalf("unconfigured resolution matched tier: %#v", tier)
}
}
func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
input := map[string]any{
"mode": "video",
@@ -17,6 +49,36 @@ func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
}
}
func TestModelRequestIntentNormalizesImageSpecificationValues(t *testing.T) {
input := map[string]any{
"mode": "image",
"config": map[string]any{"quality": "1K", "size": "3:2"},
}
intent := ModelRequestIntentFromTaskInput(input, "canvas_image", "image")
if got := intent.Options["quality"]; got != "1k" {
t.Fatalf("quality = %#v, want 1k", got)
}
if got := intent.Options["size"]; got != "3:2" {
t.Fatalf("size = %#v, want 3:2", got)
}
}
func TestSKUSelectorInfersImageResolutionFromSizeWhenQualityIsAutomatic(t *testing.T) {
for _, test := range []struct {
size string
want string
}{
{size: "1024x1024", want: "1k"},
{size: "2048x2048", want: "2k"},
{size: "2880x2880", want: "4k"},
} {
selector := skuSelectorForIntent(ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "auto", "size": test.size}})
if selector["quality"] != test.want {
t.Fatalf("size %s quality = %q, want %q", test.size, selector["quality"], test.want)
}
}
}
func TestSKUSelectorIncludesVideoReferenceImageCount(t *testing.T) {
selector := skuSelectorForIntent(ModelRequestIntent{Capability: "video", Inputs: map[string]int{"image": 5}, Options: map[string]any{"vquality": "720p"}})
if selector["imageCount"] != "5" || selector["vquality"] != "720p" {