diff --git a/backend/internal/model/distribution.go b/backend/internal/model/distribution.go index 1e760a3..cd1c99a 100644 --- a/backend/internal/model/distribution.go +++ b/backend/internal/model/distribution.go @@ -16,6 +16,7 @@ type DistributionSite struct { AnnouncementsJSON string `gorm:"type:text" json:"announcements_json"` FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"` DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"` + ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"` TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"` TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"` SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"` diff --git a/backend/internal/modules/chat/listing_group.go b/backend/internal/modules/chat/listing_group.go index 623a6be..e0265fd 100644 --- a/backend/internal/modules/chat/listing_group.go +++ b/backend/internal/modules/chat/listing_group.go @@ -38,6 +38,11 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferr return nil, err } + siteID, err := resolveListingDistributionSiteID(tx, listing) + if err != nil { + return nil, err + } + // 2. 建会话 now := time.Now() conversation := model.ChatConversation{ @@ -46,6 +51,9 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferr Title: listingConversationTitle(listing), Status: "active", } + if siteID > 0 { + conversation.DistributionSiteID = &siteID + } if err := tx.Create(&conversation).Error; err != nil { return nil, err } @@ -88,17 +96,12 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferr } } - // 4. 取二维码(带行锁) - var qrcode *model.ChatQrCode - var qrcodeImageURL string - qrcodeObj, err := fetchUnusedQrCodeForListing(tx, listing.ID) + // 4. 取二维码:分站优先用站点固定码(可复用);否则消耗对应码池。 + qrcode, qrcodeImageURL, err := resolveListingQrCode(tx, siteID) if err != nil { return nil, err } - if qrcodeObj != nil { - qrcode = qrcodeObj - qrcodeImageURL = qrcode.ImageURL - // 标记二维码为已使用 + if qrcode != nil { if err := markQrCodeAsUsed(tx, qrcode.ID, conversation.ID); err != nil { return nil, err } @@ -309,22 +312,60 @@ func fetchUnusedQrCode(tx *gorm.DB) (*model.ChatQrCode, error) { return fetchUnusedQrCodeForSite(tx, nil) } -func fetchUnusedQrCodeForListing(tx *gorm.DB, listingID uint64) (*model.ChatQrCode, error) { +func resolveListingDistributionSiteID(tx *gorm.DB, listing model.RentalListing) (uint64, error) { + if listing.DistributionSiteID != nil && *listing.DistributionSiteID > 0 { + return *listing.DistributionSiteID, nil + } + if listing.ID == 0 { + return 0, nil + } var relation model.DistributionSiteAccount // 仅匹配启用中的分销站点,真实 DB 错误直接返回,避免误回退到全局二维码池。 err := tx.Table("distribution_site_accounts AS relation"). Select("relation.*"). Joins("JOIN distribution_sites AS site ON site.id = relation.site_id AND site.status = 'active'"). - Where("relation.listing_id = ? AND relation.display_status = 'enabled'", listingID). + Where("relation.listing_id = ? AND relation.display_status = 'enabled'", listing.ID). Order("relation.id ASC"). + Limit(1). Scan(&relation).Error if err != nil { - return nil, err + return 0, err } if relation.ID != 0 { - return fetchUnusedQrCodeForSite(tx, &relation.SiteID) + return relation.SiteID, nil } - return fetchUnusedQrCodeForSite(tx, nil) + return 0, nil +} + +func resolveListingQrCode(tx *gorm.DB, siteID uint64) (*model.ChatQrCode, string, error) { + if siteID > 0 { + var site model.DistributionSite + err := tx.Select("id", "listing_qrcode_url").First(&site, siteID).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, "", err + } + if err == nil { + if url := strings.TrimSpace(site.ListingQrcodeURL); url != "" { + return nil, url, nil + } + } + qrcode, err := fetchUnusedQrCodeForSite(tx, &siteID) + if err != nil { + return nil, "", err + } + if qrcode != nil { + return qrcode, qrcode.ImageURL, nil + } + return nil, "", nil + } + qrcode, err := fetchUnusedQrCodeForSite(tx, nil) + if err != nil { + return nil, "", err + } + if qrcode != nil { + return qrcode, qrcode.ImageURL, nil + } + return nil, "", nil } func fetchUnusedQrCodeForSite(tx *gorm.DB, siteID *uint64) (*model.ChatQrCode, error) { diff --git a/backend/internal/modules/chat/qrcode_test.go b/backend/internal/modules/chat/qrcode_test.go index 1a17266..915e3a6 100644 --- a/backend/internal/modules/chat/qrcode_test.go +++ b/backend/internal/modules/chat/qrcode_test.go @@ -479,3 +479,145 @@ func TestBatchCreateQrCodeDeliversPendingTask(t *testing.T) { t.Fatalf("未生成二维码图片消息: %v", err) } } + +func TestEnsureListingConversationUsesSiteFixedQrcode(t *testing.T) { + db := setupQrCodeTestDB(t) + site := model.DistributionSite{ + SiteCode: "fx_qr_fixed", + SiteName: "固定码分站", + Domain: "fixed-qr.example.com", + Status: "active", + ListingQrcodeURL: "/api/files/object?key=qrcode/site-fixed.png", + } + if err := db.Create(&site).Error; err != nil { + t.Fatalf("创建分销站点失败: %v", err) + } + siteID := site.ID + pool := []model.ChatQrCode{ + {ImageURL: "/api/files/object?key=qrcode/main-pool.png", Status: QrCodeStatusUnused}, + {ImageURL: "/api/files/object?key=qrcode/site-pool.png", Status: QrCodeStatusUnused, DistributionSiteID: &siteID}, + } + if err := db.Create(&pool).Error; err != nil { + t.Fatalf("创建码池失败: %v", err) + } + + listing := model.RentalListing{ + ID: 202, + ListingNo: "L202609150001", + OwnerID: 10, + DistributionSiteID: &siteID, + } + var conv *model.ChatConversation + if err := db.Transaction(func(tx *gorm.DB) error { + created, err := EnsureListingConversation(tx, listing, 0) + conv = created + return err + }); err != nil { + t.Fatalf("建发布群失败: %v", err) + } + if conv == nil || conv.DistributionSiteID == nil || *conv.DistributionSiteID != siteID { + t.Fatalf("发布群站点 = %v, want %d", conv.DistributionSiteID, siteID) + } + + var message model.ChatMessage + if err := db.Where("conversation_id = ? AND content_type = ?", conv.ID, "image").First(&message).Error; err != nil { + t.Fatalf("未发送固定二维码: %v", err) + } + if !strings.Contains(string(message.AttachmentURLS), "site-fixed.png") { + t.Fatalf("二维码附件 = %s, want 分站固定码", message.AttachmentURLS) + } + + var unusedCount int64 + if err := db.Model(&model.ChatQrCode{}).Where("status = ?", QrCodeStatusUnused).Count(&unusedCount).Error; err != nil { + t.Fatalf("统计码池失败: %v", err) + } + if unusedCount != 2 { + t.Fatalf("固定码仍应保留码池库存, unused = %d, want 2", unusedCount) + } + + var taskCount int64 + if err := db.Model(&model.ChatQrCodeDeliveryTask{}).Count(&taskCount).Error; err != nil { + t.Fatalf("统计补发任务失败: %v", err) + } + if taskCount != 0 { + t.Fatalf("固定码场景不应创建补发任务, got %d", taskCount) + } + + listing2 := model.RentalListing{ + ID: 203, + ListingNo: "L202609150002", + OwnerID: 11, + DistributionSiteID: &siteID, + } + if err := db.Transaction(func(tx *gorm.DB) error { + _, err := EnsureListingConversation(tx, listing2, 0) + return err + }); err != nil { + t.Fatalf("第二次建发布群失败: %v", err) + } + if err := db.Model(&model.ChatQrCode{}).Where("status = ?", QrCodeStatusUnused).Count(&unusedCount).Error; err != nil { + t.Fatalf("再次统计码池失败: %v", err) + } + if unusedCount != 2 { + t.Fatalf("同一分站固定码应可复用, unused = %d, want 2", unusedCount) + } +} + +func TestEnsureListingConversationSiteWithoutFixedQrUsesSitePool(t *testing.T) { + db := setupQrCodeTestDB(t) + site := model.DistributionSite{ + SiteCode: "fx_qr_pool", + SiteName: "码池分站", + Domain: "pool-qr.example.com", + Status: "active", + } + if err := db.Create(&site).Error; err != nil { + t.Fatalf("创建分销站点失败: %v", err) + } + siteID := site.ID + pool := []model.ChatQrCode{ + {ImageURL: "/api/files/object?key=qrcode/main-pool.png", Status: QrCodeStatusUnused}, + {ImageURL: "/api/files/object?key=qrcode/site-pool.png", Status: QrCodeStatusUnused, DistributionSiteID: &siteID}, + } + if err := db.Create(&pool).Error; err != nil { + t.Fatalf("创建码池失败: %v", err) + } + + listing := model.RentalListing{ + ID: 204, + ListingNo: "L202609150003", + OwnerID: 12, + DistributionSiteID: &siteID, + } + var conv *model.ChatConversation + if err := db.Transaction(func(tx *gorm.DB) error { + created, err := EnsureListingConversation(tx, listing, 0) + conv = created + return err + }); err != nil { + t.Fatalf("建发布群失败: %v", err) + } + + var message model.ChatMessage + if err := db.Where("conversation_id = ? AND content_type = ?", conv.ID, "image").First(&message).Error; err != nil { + t.Fatalf("未发送分站池二维码: %v", err) + } + if !strings.Contains(string(message.AttachmentURLS), "site-pool.png") { + t.Fatalf("二维码附件 = %s, want 分站码池", message.AttachmentURLS) + } + + var siteQR model.ChatQrCode + if err := db.First(&siteQR, pool[1].ID).Error; err != nil { + t.Fatalf("查询分站码失败: %v", err) + } + if siteQR.Status != QrCodeStatusUsed { + t.Fatalf("未配置固定码时应消耗分站码池, status = %q", siteQR.Status) + } + var mainQR model.ChatQrCode + if err := db.First(&mainQR, pool[0].ID).Error; err != nil { + t.Fatalf("查询主站码失败: %v", err) + } + if mainQR.Status != QrCodeStatusUnused { + t.Fatalf("分站上架不得消耗主站码池, status = %q", mainQR.Status) + } +} diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index 6fa566a..e325755 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -15,6 +15,7 @@ type SiteDTO struct { AnnouncementsJSON string `json:"announcements_json"` FooterInfoJSON string `json:"footer_info_json"` DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` + ListingQrcodeURL string `json:"listing_qrcode_url"` TaxEnabled bool `json:"tax_enabled"` TaxRateBPS int64 `json:"tax_rate_bps"` SettlementHoldDays int `json:"settlement_hold_days"` @@ -28,6 +29,7 @@ type CreateSiteRequest struct { Domain string `json:"domain" binding:"required,max=255"` DisplayAccountEnabled *bool `json:"display_account_enabled"` DefaultSupportGroupID *uint64 `json:"default_support_group_id"` + ListingQrcodeURL *string `json:"listing_qrcode_url"` TaxEnabled *bool `json:"tax_enabled"` TaxRateBPS *int64 `json:"tax_rate_bps"` SettlementHoldDays *int `json:"settlement_hold_days"` @@ -39,6 +41,7 @@ type UpdateSiteRequest struct { Status *string `json:"status"` DisplayAccountEnabled *bool `json:"display_account_enabled"` DefaultSupportGroupID *uint64 `json:"default_support_group_id"` + ListingQrcodeURL *string `json:"listing_qrcode_url"` TaxEnabled *bool `json:"tax_enabled"` TaxRateBPS *int64 `json:"tax_rate_bps"` SettlementHoldDays *int `json:"settlement_hold_days"` diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 5964501..4123c24 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -253,6 +253,9 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si if req.DefaultSupportGroupID != nil { row.DefaultSupportGroupID = req.DefaultSupportGroupID } + if req.ListingQrcodeURL != nil { + row.ListingQrcodeURL = strings.TrimSpace(*req.ListingQrcodeURL) + } if req.TaxEnabled != nil { row.TaxEnabled = *req.TaxEnabled } @@ -305,6 +308,9 @@ func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req Upd if req.DefaultSupportGroupID != nil { row.DefaultSupportGroupID = req.DefaultSupportGroupID } + if req.ListingQrcodeURL != nil { + row.ListingQrcodeURL = strings.TrimSpace(*req.ListingQrcodeURL) + } if req.TaxEnabled != nil { row.TaxEnabled = *req.TaxEnabled } @@ -467,6 +473,7 @@ func siteDTO(row model.DistributionSite) SiteDTO { Status: row.Status, DisplayAccountEnabled: row.DisplayAccountEnabled, DefaultSupportGroupID: row.DefaultSupportGroupID, + ListingQrcodeURL: row.ListingQrcodeURL, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, SettlementHoldDays: row.SettlementHoldDays, diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index 6099922..61739b4 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -649,6 +649,23 @@ func TestDistributionBrandingAndMarkup(t *testing.T) { t.Fatalf("unexpected branding: %+v", updated) } + qrURL := "/api/files/object?key=qrcode/brand-site.png" + qrUpdated, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{ListingQrcodeURL: &qrURL}) + if err != nil { + t.Fatalf("UpdateSite listing qrcode failed: %v", err) + } + if qrUpdated.ListingQrcodeURL != qrURL { + t.Fatalf("listing_qrcode_url = %q, want %q", qrUpdated.ListingQrcodeURL, qrURL) + } + cleared := "" + qrCleared, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{ListingQrcodeURL: &cleared}) + if err != nil { + t.Fatalf("clear listing qrcode failed: %v", err) + } + if qrCleared.ListingQrcodeURL != "" { + t.Fatalf("cleared listing_qrcode_url = %q, want empty", qrCleared.ListingQrcodeURL) + } + // Verify PublicSiteInfo contains branding pubInfo, err := repo.GetPublicSiteInfo(t.Context(), "brand_site", "") if err != nil { diff --git a/backend/migrations/000069_distribution_site_listing_qrcode.sql b/backend/migrations/000069_distribution_site_listing_qrcode.sql new file mode 100644 index 0000000..f1ad91a --- /dev/null +++ b/backend/migrations/000069_distribution_site_listing_qrcode.sql @@ -0,0 +1,10 @@ +-- +goose Up + +-- 分销站点发布群固定二维码:号主在该站上传账号时发到对应发布群,可重复使用,不消耗主站二维码池。 +ALTER TABLE distribution_sites + ADD COLUMN listing_qrcode_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '分站发布群固定企微二维码'; + +-- +goose Down + +ALTER TABLE distribution_sites + DROP COLUMN listing_qrcode_url; diff --git a/frontend/src/features/admin/api/adminDistribution.ts b/frontend/src/features/admin/api/adminDistribution.ts index 897c20c..85166ea 100644 --- a/frontend/src/features/admin/api/adminDistribution.ts +++ b/frontend/src/features/admin/api/adminDistribution.ts @@ -11,6 +11,7 @@ export interface DistributionSite { tax_enabled: boolean tax_rate_bps: number settlement_hold_days: number + listing_qrcode_url?: string created_at: string } @@ -150,6 +151,7 @@ export async function updateDistributionSite( | 'tax_enabled' | 'tax_rate_bps' | 'settlement_hold_days' + | 'listing_qrcode_url' > > ) { diff --git a/frontend/src/features/admin/views/AdminDistributionSitesView.vue b/frontend/src/features/admin/views/AdminDistributionSitesView.vue index 5aa437b..679267e 100644 --- a/frontend/src/features/admin/views/AdminDistributionSitesView.vue +++ b/frontend/src/features/admin/views/AdminDistributionSitesView.vue @@ -1,7 +1,16 @@