fix(*): 画布生成与邮件 - 合并 PR #423 并修复恢复关联和规格报价

保留 PR 原始提交,合并待测试文档双方内容。补齐摘要 nodeId 消费端,修复缺省质量的 <nil> 匹配,并移除报价面板按价格档篡改自动质量的行为。

验证:前端完整测试脚本 1139 项通过,生产构建通过;后端相关专项通过。全量后端存在 4 项基线可复现失败;另一次资源清理测试出现 SQLite 表锁,合并分支与基线单独重跑通过。未执行真实 SMTP、浏览器端到端或收费生成。
This commit is contained in:
ddCat
2026-09-06 04:44:16 +08:00
16 changed files with 441 additions and 34 deletions

View File

@@ -195,9 +195,8 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error {
if err := s.repo.Create(&record); err != nil {
return err
}
brandName := s.appearanceBrandName()
setting = resolveEmailSender(setting, brandName)
if err := s.deliverEmail(setting, email, brandName+"注册验证码", registrationEmailBody(brandName, code)); err != nil {
setting = resolveEmailSender(setting, s.appearanceBrandName())
if err := s.deliverEmail(setting, email, setting.FromName+"注册验证码", registrationEmailBody(setting.FromName, code)); err != nil {
cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID)
if cleanupErr != nil {
return errors.Join(

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)
@@ -730,8 +737,22 @@ 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"
}
rawQuality, _ := intent.Options["quality"].(string)
rawSize, _ := intent.Options["size"].(string)
if quality := normalizeImagePriceQuality(rawQuality, rawSize); quality != "" {
selector["quality"] = quality
}
for _, key := range []string{"quality", "size"} {
if value := strings.ToLower(strings.TrimSpace(fmt.Sprint(intent.Options[key]))); value != "" && value != "auto" && value != "any" {
if key == "quality" && selector["quality"] != "" {
continue
}
text, _ := intent.Options[key].(string)
if value := strings.ToLower(strings.TrimSpace(text)); value != "" && value != "auto" && value != "any" {
selector[key] = value
}
}
@@ -739,6 +760,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,55 @@ 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 TestSKUSelectorHandlesMissingImageOptions(t *testing.T) {
for _, quality := range []any{nil, "", "auto", "any"} {
options := map[string]any{"size": "2048x2048"}
if quality != nil {
options["quality"] = quality
}
selector := skuSelectorForIntent(ModelRequestIntent{Capability: "image", Options: options})
if selector["quality"] != "2k" {
t.Errorf("quality=%#v: selector=%#v, want 2k", quality, selector)
}
}
for _, options := range []map[string]any{nil, {}, {"quality": nil, "size": nil}} {
selector := skuSelectorForIntent(ModelRequestIntent{Capability: "image", Options: options})
if len(selector) != 1 || selector["operation"] != "text_to_image" {
t.Errorf("missing options produced synthetic specification: %#v", selector)
}
}
}
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" {

View File

@@ -74,9 +74,8 @@ func (s *Service) SendPasswordResetEmailCode(rawEmail string) error {
if err := s.repo.Create(&record); err != nil {
return err
}
brandName := s.appearanceBrandName()
setting = resolveEmailSender(setting, brandName)
if err := s.deliverEmail(setting, email, brandName+"密码重置验证码", passwordResetEmailBody(brandName, code)); err != nil {
setting = resolveEmailSender(setting, s.appearanceBrandName())
if err := s.deliverEmail(setting, email, setting.FromName+"密码重置验证码", passwordResetEmailBody(setting.FromName, code)); err != nil {
if cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID); cleanupErr != nil {
log.Printf("password reset email cleanup failed: recipient=%s error=%v", maskedEmail(email), cleanupErr)
}

View File

@@ -188,8 +188,38 @@ func TestPasswordResetDeliveryFailureRemovesUnusableCode(t *testing.T) {
}
}
func TestCustomAppearanceBrandsRegistrationAndPasswordResetEmails(t *testing.T) {
func TestSenderNameBrandsRegistrationAndPasswordResetEmails(t *testing.T) {
for _, tc := range []struct {
name string
fromName string
wantName string
}{
{name: "default inherits appearance", fromName: defaultAppearanceBrandName, wantName: "HIMA Studio"},
{name: "empty inherits appearance", fromName: "", wantName: "HIMA Studio"},
{name: "custom sender overrides appearance", fromName: "Custom Canvas", wantName: "Custom Canvas"},
{name: "custom sender is trimmed", fromName: " 自定义工作台 ", wantName: "自定义工作台"},
} {
t.Run(tc.name, func(t *testing.T) {
assertRegistrationAndPasswordResetEmailBrand(t, tc.fromName, tc.wantName)
})
}
}
func assertRegistrationAndPasswordResetEmailBrand(t *testing.T, fromName, wantName string) {
t.Helper()
svc, db := newPasswordResetTestService(t)
_, emailSetting, err := svc.readEmailSetting()
if err != nil {
t.Fatal(err)
}
emailSetting.FromName = fromName
settingJSON, err := json.Marshal(emailSetting)
if err != nil {
t.Fatal(err)
}
if err := db.Model(&model.SystemSetting{}).Where("key = ?", emailSettingKey).Update("value_json", string(settingJSON)).Error; err != nil {
t.Fatal(err)
}
appearanceJSON, err := json.Marshal(AppearanceSetting{
SchemaVersion: appearanceSchemaVersion,
BrandName: "HIMA Studio",
@@ -238,13 +268,16 @@ func TestCustomAppearanceBrandsRegistrationAndPasswordResetEmails(t *testing.T)
t.Fatalf("deliveries = %d, want 2", len(deliveries))
}
for _, delivered := range deliveries {
if delivered.fromName != "HIMA Studio" || !strings.Contains(delivered.subject, "HIMA Studio") || !strings.Contains(delivered.body, "HIMA Studio") || strings.Contains(delivered.subject, defaultAppearanceBrandName) || strings.Contains(delivered.body, defaultAppearanceBrandName) {
t.Fatalf("email did not inherit custom appearance: %#v", delivered)
if delivered.fromName != wantName || !strings.Contains(delivered.subject, wantName) || !strings.Contains(delivered.body, wantName) || strings.Contains(delivered.subject, defaultAppearanceBrandName) || strings.Contains(delivered.body, defaultAppearanceBrandName) {
t.Fatalf("email did not use resolved sender name %q: %#v", wantName, delivered)
}
}
if deliveries[0].subject != "HIMA Studio注册验证码" || deliveries[1].subject != "HIMA Studio密码重置验证码" {
if deliveries[0].subject != wantName+"注册验证码" || deliveries[1].subject != wantName+"密码重置验证码" {
t.Fatalf("unexpected branded subjects: %#v", deliveries)
}
if !strings.Contains(deliveries[0].body, "你正在注册"+wantName+"。") {
t.Fatalf("registration body did not use resolved sender name: %q", deliveries[0].body)
}
}
func newPasswordResetTestService(t *testing.T) (*Service, *gorm.DB) {

View File

@@ -42,6 +42,7 @@ type TaskSummary struct {
}
type TaskClientContext struct {
NodeID string `json:"nodeId,omitempty"`
ConversationID string `json:"conversationId,omitempty"`
MessageID string `json:"messageId,omitempty"`
BatchIndex int `json:"batchIndex,omitempty"`
@@ -136,6 +137,7 @@ func taskClientContext(raw string) *TaskClientContext {
var input struct {
Metadata struct {
Source string `json:"source"`
NodeID string `json:"nodeId"`
ConversationID string `json:"conversationId"`
MessageID string `json:"messageId"`
BatchIndex int `json:"batchIndex"`
@@ -152,16 +154,20 @@ func taskClientContext(raw string) *TaskClientContext {
return nil
}
metadata := input.Metadata
context := &TaskClientContext{NodeID: metadata.NodeID}
if metadata.Source == "create-page" && metadata.ConversationID != "" && metadata.MessageID != "" {
return &TaskClientContext{
ConversationID: metadata.ConversationID,
MessageID: metadata.MessageID,
BatchIndex: metadata.BatchIndex,
BatchCount: metadata.BatchCount,
}
context.ConversationID = metadata.ConversationID
context.MessageID = metadata.MessageID
context.BatchIndex = metadata.BatchIndex
context.BatchCount = metadata.BatchCount
return context
}
if metadata.ShotID != "" && metadata.WorkflowStepID != "" {
return &TaskClientContext{DomainProjectID: metadata.DomainProjectID, ShotID: metadata.ShotID, WorkflowStepID: metadata.WorkflowStepID, ArtifactType: metadata.ArtifactType}
context.DomainProjectID = metadata.DomainProjectID
context.ShotID = metadata.ShotID
context.WorkflowStepID = metadata.WorkflowStepID
context.ArtifactType = metadata.ArtifactType
return context
}
chapterOperation := ""
if metadata.Operation == "chapter_character_breakdown" {
@@ -170,13 +176,15 @@ func taskClientContext(raw string) *TaskClientContext {
chapterOperation = "storyboard"
}
if chapterOperation == "" || metadata.DomainProjectID == "" || metadata.ChapterID == "" {
return nil
}
return &TaskClientContext{
DomainProjectID: metadata.DomainProjectID,
ChapterID: metadata.ChapterID,
ChapterOperation: chapterOperation,
if context.NodeID == "" {
return nil
}
return context
}
context.DomainProjectID = metadata.DomainProjectID
context.ChapterID = metadata.ChapterID
context.ChapterOperation = chapterOperation
return context
}
// 列表只暴露首个可访问媒体地址,避免把完整生成结果和内嵌数据带回前端。

View File

@@ -61,6 +61,37 @@ func TestTaskClientContextRequiresCreatePageMetadata(t *testing.T) {
}
}
func TestTaskClientContextPreservesCanvasNodeID(t *testing.T) {
context := taskClientContext(`{"mode":"image","metadata":{"nodeId":"canvas-node-1","source":"canvas"}}`)
if context == nil || context.NodeID != "canvas-node-1" {
t.Fatalf("canvas node context was not preserved: %+v", context)
}
}
func TestTaskSummaryPreservesCanvasNodeWithoutExposingInput(t *testing.T) {
summary := taskSummaryForOutput(model.Task{
ID: "task-1", ProjectID: "project-1", Type: "canvas_image",
InputJSON: `{"metadata":{"nodeId":"node-1"},"config":{"apiKey":"private-test-value"}}`,
})
encoded, err := json.Marshal(summary)
if err != nil {
t.Fatal(err)
}
var output map[string]any
if err := json.Unmarshal(encoded, &output); err != nil {
t.Fatal(err)
}
context, ok := output["clientContext"].(map[string]any)
if !ok || context["nodeId"] != "node-1" || len(context) != 1 {
t.Fatalf("summary lost safe canvas association: %#v", output)
}
for _, key := range []string{"inputJson", "config", "apiKey"} {
if _, exists := output[key]; exists {
t.Fatalf("summary leaked %s", key)
}
}
}
func TestTaskClientContextProjectsChapterOperations(t *testing.T) {
characters := taskClientContext(`{"metadata":{"domainProjectId":"project-1","chapterId":"chapter-1","operation":"chapter_character_breakdown"}}`)
if characters == nil || characters.DomainProjectID != "project-1" || characters.ChapterID != "chapter-1" || characters.ChapterOperation != "characters" {