feat(distribution): 分站发布群只使用自己的二维码池

主站单独维护分站码池,池空时不再借用主站或其他分站,并去掉已停用的固定二维码入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-10-04 18:35:34 +08:00
co-authored by Cursor
parent 951d428364
commit 1d21b3f548
11 changed files with 1123 additions and 250 deletions
@@ -258,3 +258,168 @@ func (h *Handler) DeleteQrCodeHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
func (h *Handler) siteIDParam(c *gin.Context) (uint64, bool) {
siteID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || siteID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的分站"})
return 0, false
}
return siteID, true
}
func writeSiteQrCodeError(c *gin.Context, err error) bool {
switch {
case errors.Is(err, ErrQrCodeSiteNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "分销站点不存在"})
case errors.Is(err, ErrQrCodeNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "二维码不存在"})
case errors.Is(err, ErrQrCodeCannotReenable):
c.JSON(http.StatusBadRequest, gin.H{"error": "已发放的二维码不能改回待用"})
default:
if err != nil && err.Error() == "无效的状态值" {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return true
}
return false
}
return true
}
func (h *Handler) ListSiteQrCodesHandler(c *gin.Context) {
siteID, ok := h.siteIDParam(c)
if !ok {
return
}
var req QrCodeListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
qrcodes, total, err := h.service.repo.ListSiteQrCodes(c.Request.Context(), siteID, req)
if err != nil {
if writeSiteQrCodeError(c, err) {
return
}
response.RecordError(c, err)
response.InternalServerError(c, "聊天二维码服务暂时不可用")
return
}
c.JSON(http.StatusOK, gin.H{
"data": PaginatedResult{
Items: qrcodes,
Total: total,
Page: req.Page,
PageSize: req.Limit,
},
})
}
func (h *Handler) GetSiteQrCodeStatsHandler(c *gin.Context) {
siteID, ok := h.siteIDParam(c)
if !ok {
return
}
stats, err := h.service.repo.GetSiteQrCodeStats(c.Request.Context(), siteID)
if err != nil {
if writeSiteQrCodeError(c, err) {
return
}
response.RecordError(c, err)
response.InternalServerError(c, "聊天二维码服务暂时不可用")
return
}
c.JSON(http.StatusOK, gin.H{"data": stats})
}
func (h *Handler) BatchCreateSiteQrCodeHandler(c *gin.Context) {
siteID, ok := h.siteIDParam(c)
if !ok {
return
}
var req BatchCreateQrCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
adminID := c.GetUint64("admin_id")
qrcodes, err := h.service.repo.BatchCreateSiteQrCode(c.Request.Context(), adminID, siteID, req)
if err != nil {
if writeSiteQrCodeError(c, err) {
return
}
response.RecordError(c, err)
response.InternalServerError(c, "聊天二维码服务暂时不可用")
return
}
c.JSON(http.StatusOK, gin.H{"data": qrcodes})
}
func (h *Handler) UpdateSiteQrCodeHandler(c *gin.Context) {
siteID, ok := h.siteIDParam(c)
if !ok {
return
}
id, err := strconv.ParseUint(c.Param("qrcodeId"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
var req UpdateQrCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.repo.UpdateSiteQrCode(c.Request.Context(), siteID, id, req); err != nil {
if writeSiteQrCodeError(c, err) {
return
}
response.RecordError(c, err)
response.InternalServerError(c, "聊天二维码服务暂时不可用")
return
}
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
}
func (h *Handler) DeleteSiteQrCodeHandler(c *gin.Context) {
siteID, ok := h.siteIDParam(c)
if !ok {
return
}
id, err := strconv.ParseUint(c.Param("qrcodeId"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
if err := h.service.repo.DeleteSiteQrCode(c.Request.Context(), siteID, id); err != nil {
if writeSiteQrCodeError(c, err) {
return
}
response.RecordError(c, err)
response.InternalServerError(c, "聊天二维码服务暂时不可用")
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
func (h *Handler) BatchDeleteSiteQrCodeHandler(c *gin.Context) {
siteID, ok := h.siteIDParam(c)
if !ok {
return
}
var req BatchDeleteQrCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
deleted, err := h.service.repo.BatchDeleteSiteQrCodes(c.Request.Context(), siteID, req.IDs)
if err != nil {
if writeSiteQrCodeError(c, err) {
return
}
response.RecordError(c, err)
response.InternalServerError(c, "聊天二维码服务暂时不可用")
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"deleted": deleted}})
}
+36 -35
View File
@@ -96,7 +96,7 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferr
}
}
// 4. 取二维码:分站优先用站点固定码(可复用);否则消耗对应码池。
// 4. 取二维码:主站只用主站池,分站只用自己的池。池空则稍后补发,不跨池借用。
qrcode, qrcodeImageURL, err := resolveListingQrCode(tx, siteID)
if err != nil {
return nil, err
@@ -338,39 +338,15 @@ func resolveListingDistributionSiteID(tx *gorm.DB, listing model.RentalListing)
}
func resolveListingQrCode(tx *gorm.DB, siteID uint64) (*model.ChatQrCode, string, error) {
var scope *uint64
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
}
// 分站没有固定码、也没有自己的码池时,沿用主站二维码池。
mainQR, err := fetchUnusedQrCode(tx)
if err != nil || mainQR == nil {
return nil, "", err
}
return mainQR, mainQR.ImageURL, nil
scope = &siteID
}
qrcode, err := fetchUnusedQrCodeForSite(tx, nil)
if err != nil {
qrcode, err := fetchUnusedQrCodeForSite(tx, scope)
if err != nil || qrcode == nil {
return nil, "", err
}
if qrcode != nil {
return qrcode, qrcode.ImageURL, nil
}
return nil, "", nil
return qrcode, qrcode.ImageURL, nil
}
func fetchUnusedQrCodeForSite(tx *gorm.DB, siteID *uint64) (*model.ChatQrCode, error) {
@@ -399,6 +375,18 @@ func fetchUnusedQrCodeForSite(tx *gorm.DB, siteID *uint64) (*model.ChatQrCode, e
return &qrcode, nil
}
func fetchUnusedQrCodeForConversation(tx *gorm.DB, conversationID uint64) (*model.ChatQrCode, error) {
var conversation model.ChatConversation
if err := tx.Select("id", "distribution_site_id").First(&conversation, conversationID).Error; err != nil {
return nil, err
}
var siteID *uint64
if conversation.DistributionSiteID != nil && *conversation.DistributionSiteID > 0 {
siteID = conversation.DistributionSiteID
}
return fetchUnusedQrCodeForSite(tx, siteID)
}
func markQrCodeAsUsed(tx *gorm.DB, qrcodeID uint64, conversationID uint64) error {
now := time.Now()
return tx.Model(&model.ChatQrCode{}).
@@ -446,12 +434,22 @@ func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation)
return nil, nil
}
// 统计未使用的二维码数量
var count int64
if err := tx.Model(&model.ChatQrCode{}).
// 只统计当前会话所属池子,分站库存不影响主站预警。
countQuery := tx.Model(&model.ChatQrCode{}).
Where("status = ?", QrCodeStatusUnused).
Where("expires_at IS NULL OR expires_at > ?", time.Now()).
Count(&count).Error; err != nil {
Where("expires_at IS NULL OR expires_at > ?", time.Now())
poolLabel := ""
if conversation != nil && conversation.DistributionSiteID != nil && *conversation.DistributionSiteID > 0 {
countQuery = countQuery.Where("distribution_site_id = ?", *conversation.DistributionSiteID)
var site model.DistributionSite
if err := tx.Select("site_name").First(&site, *conversation.DistributionSiteID).Error; err == nil {
poolLabel = strings.TrimSpace(site.SiteName)
}
} else {
countQuery = countQuery.Where("distribution_site_id IS NULL")
}
var count int64
if err := countQuery.Count(&count).Error; err != nil {
return nil, err
}
@@ -471,6 +469,9 @@ func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation)
}
alertContent := strings.ReplaceAll(messageTemplate, "{{.Count}}", fmt.Sprintf("%d", count))
if poolLabel != "" {
alertContent = "分站「" + poolLabel + "」" + alertContent
}
entries := make([]map[string]interface{}, 0, len(csAdmins))
now := time.Now()
+216 -17
View File
@@ -29,6 +29,7 @@ const (
var (
ErrQrCodeNotFound = errors.New("二维码不存在")
ErrQrCodeCannotReenable = errors.New("已发放的二维码不能改回待用")
ErrQrCodeSiteNotFound = errors.New("分销站点不存在")
)
// CreateQrCodeRequest 创建二维码请求
@@ -180,7 +181,8 @@ func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]
return applyQrCodeListFilters(
r.db.WithContext(ctx).
Table("chat_qrcode_pool AS q").
Joins("LEFT JOIN chat_conversations AS c ON c.id = q.conversation_id"),
Joins("LEFT JOIN chat_conversations AS c ON c.id = q.conversation_id").
Where("q.distribution_site_id IS NULL"),
req,
now,
)
@@ -294,23 +296,24 @@ func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) {
stats := &QrCodeStats{}
now := time.Now()
if err := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).Count(&stats.TotalCount).Error; err != nil {
mainPool := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).Where("distribution_site_id IS NULL")
if err := mainPool.Count(&stats.TotalCount).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).
Model(&model.ChatQrCode{}).
if err := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).
Where("distribution_site_id IS NULL").
Where("status = ?", QrCodeStatusUsed).
Count(&stats.UsedCount).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).
Model(&model.ChatQrCode{}).
if err := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).
Where("distribution_site_id IS NULL").
Where("status = ?", QrCodeStatusDisabled).
Count(&stats.DisabledCount).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).
Model(&model.ChatQrCode{}).
if err := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).
Where("distribution_site_id IS NULL").
Where("status = ?", QrCodeStatusUnused).
Where("expires_at IS NULL OR expires_at > ?", now).
Count(&stats.UnusedCount).Error; err != nil {
@@ -351,7 +354,7 @@ func (r *Repository) GetQrCodeOCRConfig(ctx context.Context) (*QrCodeOCRConfig,
// UpdateQrCode 更新二维码
func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCodeRequest) error {
var qrcode model.ChatQrCode
if err := r.db.WithContext(ctx).First(&qrcode, id).Error; err != nil {
if err := r.db.WithContext(ctx).Where("distribution_site_id IS NULL").First(&qrcode, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQrCodeNotFound
}
@@ -406,7 +409,7 @@ func qrcodeWasIssued(qrcode model.ChatQrCode) bool {
// DeleteQrCode 删除二维码
func (r *Repository) DeleteQrCode(ctx context.Context, id uint64) error {
var qrcode model.ChatQrCode
if err := r.db.WithContext(ctx).First(&qrcode, id).Error; err != nil {
if err := r.db.WithContext(ctx).Where("distribution_site_id IS NULL").First(&qrcode, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQrCodeNotFound
}
@@ -422,7 +425,7 @@ func (r *Repository) BatchDeleteQrCodes(ctx context.Context, ids []uint64) (int6
return 0, nil
}
result := r.db.WithContext(ctx).Where("id IN ?", ids).Delete(&model.ChatQrCode{})
result := r.db.WithContext(ctx).Where("id IN ? AND distribution_site_id IS NULL", ids).Delete(&model.ChatQrCode{})
if result.Error != nil {
return 0, result.Error
}
@@ -465,24 +468,28 @@ func (r *Repository) markQrCodeAsUsed(tx *gorm.DB, qrcodeID uint64, conversation
func deliverPendingQrCodes(tx *gorm.DB) ([]model.ChatMessage, error) {
deliveredMessages := make([]model.ChatMessage, 0)
skipped := make([]uint64, 0)
for {
var task model.ChatQrCodeDeliveryTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("status = ?", "pending").
Order("created_at ASC, id ASC").
First(&task).Error; err != nil {
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("status = ?", "pending")
if len(skipped) > 0 {
query = query.Where("id NOT IN ?", skipped)
}
if err := query.Order("created_at ASC, id ASC").First(&task).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return deliveredMessages, nil
}
return nil, err
}
qrcode, err := fetchUnusedQrCode(tx)
qrcode, err := fetchUnusedQrCodeForConversation(tx, task.ConversationID)
if err != nil {
return nil, err
}
if qrcode == nil {
return deliveredMessages, nil
skipped = append(skipped, task.ID)
continue
}
if err := markQrCodeAsUsed(tx, qrcode.ID, task.ConversationID); err != nil {
@@ -516,6 +523,198 @@ func deliverPendingQrCodes(tx *gorm.DB) ([]model.ChatMessage, error) {
}
}
func (r *Repository) ensureDistributionSite(ctx context.Context, siteID uint64) error {
if siteID == 0 {
return ErrQrCodeSiteNotFound
}
var count int64
if err := r.db.WithContext(ctx).Model(&model.DistributionSite{}).Where("id = ?", siteID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return ErrQrCodeSiteNotFound
}
return nil
}
func (r *Repository) ListSiteQrCodes(ctx context.Context, siteID uint64, req QrCodeListRequest) ([]QrCodeListItem, int64, error) {
if err := r.ensureDistributionSite(ctx, siteID); err != nil {
return nil, 0, err
}
if req.Page < 1 {
req.Page = 1
}
if req.Limit < 1 || req.Limit > 100 {
req.Limit = 20
}
now := time.Now()
baseQuery := func() *gorm.DB {
return applyQrCodeListFilters(
r.db.WithContext(ctx).
Table("chat_qrcode_pool AS q").
Joins("LEFT JOIN chat_conversations AS c ON c.id = q.conversation_id").
Where("q.distribution_site_id = ?", siteID),
req,
now,
)
}
var total int64
if err := baseQuery().Count(&total).Error; err != nil {
return nil, 0, err
}
var qrcodes []QrCodeListItem
if err := baseQuery().
Select("q.*, COALESCE(c.title, '') AS bound_conversation_title").
Order("q.id DESC").
Offset((req.Page - 1) * req.Limit).
Limit(req.Limit).
Scan(&qrcodes).Error; err != nil {
return nil, 0, err
}
return qrcodes, total, nil
}
func (r *Repository) GetSiteQrCodeStats(ctx context.Context, siteID uint64) (*QrCodeStats, error) {
if err := r.ensureDistributionSite(ctx, siteID); err != nil {
return nil, err
}
stats := &QrCodeStats{}
now := time.Now()
base := func() *gorm.DB {
return r.db.WithContext(ctx).Model(&model.ChatQrCode{}).Where("distribution_site_id = ?", siteID)
}
if err := base().Count(&stats.TotalCount).Error; err != nil {
return nil, err
}
if err := base().Where("status = ?", QrCodeStatusUsed).Count(&stats.UsedCount).Error; err != nil {
return nil, err
}
if err := base().Where("status = ?", QrCodeStatusDisabled).Count(&stats.DisabledCount).Error; err != nil {
return nil, err
}
if err := base().
Where("status = ?", QrCodeStatusUnused).
Where("expires_at IS NULL OR expires_at > ?", now).
Count(&stats.UnusedCount).Error; err != nil {
return nil, err
}
return stats, nil
}
func (r *Repository) BatchCreateSiteQrCode(ctx context.Context, adminID, siteID uint64, req BatchCreateQrCodeRequest) ([]model.ChatQrCode, error) {
if err := r.ensureDistributionSite(ctx, siteID); err != nil {
return nil, err
}
var qrcodes []model.ChatQrCode
var deliveredMessages []model.ChatMessage
defaultExpires := time.Now().Add(7 * 24 * time.Hour)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, item := range req.Items {
qr := model.ChatQrCode{
ImageURL: item.ImageURL,
GroupName: item.GroupName,
DistributionSiteID: &siteID,
Status: QrCodeStatusUnused,
CreatedBy: adminID,
Note: item.Note,
ExpiresAt: item.ExpiresAt,
}
if qr.ExpiresAt == nil {
expires := defaultExpires
qr.ExpiresAt = &expires
}
qrcodes = append(qrcodes, qr)
}
if err := tx.Create(&qrcodes).Error; err != nil {
return err
}
var err error
deliveredMessages, err = deliverPendingQrCodes(tx)
return err
})
if err != nil {
return nil, err
}
r.notifyDeliveredQrCodeMessages(deliveredMessages)
return qrcodes, nil
}
func (r *Repository) UpdateSiteQrCode(ctx context.Context, siteID, id uint64, req UpdateQrCodeRequest) error {
if err := r.ensureDistributionSite(ctx, siteID); err != nil {
return err
}
var qrcode model.ChatQrCode
if err := r.db.WithContext(ctx).Where("distribution_site_id = ?", siteID).First(&qrcode, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQrCodeNotFound
}
return err
}
return r.applyQrCodeUpdates(ctx, &qrcode, req)
}
func (r *Repository) applyQrCodeUpdates(ctx context.Context, qrcode *model.ChatQrCode, req UpdateQrCodeRequest) error {
updates := make(map[string]interface{})
if req.ImageURL != nil {
updates["image_url"] = *req.ImageURL
}
if req.GroupName != nil {
updates["group_name"] = *req.GroupName
}
if req.Note != nil {
updates["note"] = *req.Note
}
if req.WecomRenamed != nil {
updates["wecom_renamed"] = *req.WecomRenamed
}
if req.Status != nil {
if *req.Status != QrCodeStatusUnused && *req.Status != QrCodeStatusUsed && *req.Status != QrCodeStatusDisabled {
return errors.New("无效的状态值")
}
if *req.Status == QrCodeStatusUnused && qrcodeWasIssued(*qrcode) {
return ErrQrCodeCannotReenable
}
updates["status"] = *req.Status
}
if req.ExpiresAt != nil {
updates["expires_at"] = req.ExpiresAt
} else if req.ClearExpiresAt {
updates["expires_at"] = nil
}
if len(updates) == 0 {
return nil
}
return r.db.WithContext(ctx).Model(qrcode).Updates(updates).Error
}
func (r *Repository) DeleteSiteQrCode(ctx context.Context, siteID, id uint64) error {
if err := r.ensureDistributionSite(ctx, siteID); err != nil {
return err
}
result := r.db.WithContext(ctx).Where("id = ? AND distribution_site_id = ?", id, siteID).Delete(&model.ChatQrCode{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrQrCodeNotFound
}
return nil
}
func (r *Repository) BatchDeleteSiteQrCodes(ctx context.Context, siteID uint64, ids []uint64) (int64, error) {
if err := r.ensureDistributionSite(ctx, siteID); err != nil {
return 0, err
}
if len(ids) == 0 {
return 0, nil
}
result := r.db.WithContext(ctx).Where("id IN ? AND distribution_site_id = ?", ids, siteID).Delete(&model.ChatQrCode{})
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
func (r *Repository) notifyDeliveredQrCodeMessages(messages []model.ChatMessage) {
if r.hub == nil {
return
+115 -19
View File
@@ -480,7 +480,7 @@ func TestBatchCreateQrCodeDeliversPendingTask(t *testing.T) {
}
}
func TestEnsureListingConversationUsesSiteFixedQrcode(t *testing.T) {
func TestEnsureListingConversationIgnoresFixedQrAndUsesSitePool(t *testing.T) {
db := setupQrCodeTestDB(t)
site := model.DistributionSite{
SiteCode: "fx_qr_fixed",
@@ -521,26 +521,25 @@ func TestEnsureListingConversationUsesSiteFixedQrcode(t *testing.T) {
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)
t.Fatalf("未发送分站池二维码: %v", err)
}
if !strings.Contains(string(message.AttachmentURLS), "site-fixed.png") {
t.Fatalf("二维码附件 = %s, want 分站固定码", message.AttachmentURLS)
if strings.Contains(string(message.AttachmentURLS), "site-fixed.png") || !strings.Contains(string(message.AttachmentURLS), "site-pool.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)
var siteQR model.ChatQrCode
if err := db.Where("distribution_site_id = ?", siteID).First(&siteQR).Error; err != nil {
t.Fatalf("查询分站码失败: %v", err)
}
if unusedCount != 2 {
t.Fatalf("固定码仍应保留码池库存, unused = %d, want 2", unusedCount)
if siteQR.Status != QrCodeStatusUsed {
t.Fatalf("分站码应被消耗, status = %q", siteQR.Status)
}
var taskCount int64
if err := db.Model(&model.ChatQrCodeDeliveryTask{}).Count(&taskCount).Error; err != nil {
t.Fatalf("统计补发任务失败: %v", err)
var mainQR model.ChatQrCode
if err := db.Where("distribution_site_id IS NULL").First(&mainQR).Error; err != nil {
t.Fatalf("查询主站码失败: %v", err)
}
if taskCount != 0 {
t.Fatalf("固定码场景不应创建补发任务, got %d", taskCount)
if mainQR.Status != QrCodeStatusUnused {
t.Fatalf("分站建群不得消耗主站码, status = %q", mainQR.Status)
}
listing2 := model.RentalListing{
@@ -555,11 +554,18 @@ func TestEnsureListingConversationUsesSiteFixedQrcode(t *testing.T) {
}); 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 err := db.First(&mainQR, mainQR.ID).Error; err != nil {
t.Fatalf("再次查询主站码失败: %v", err)
}
if unusedCount != 2 {
t.Fatalf("同一分站固定码应可复用, unused = %d, want 2", unusedCount)
if mainQR.Status != QrCodeStatusUnused {
t.Fatalf("分站池空了仍不得借用主站码, status = %q", mainQR.Status)
}
var taskCount int64
if err := db.Model(&model.ChatQrCodeDeliveryTask{}).Where("status = ?", "pending").Count(&taskCount).Error; err != nil {
t.Fatalf("统计补发任务失败: %v", err)
}
if taskCount != 1 {
t.Fatalf("分站池空时应留下补发任务, got %d", taskCount)
}
}
@@ -621,3 +627,93 @@ func TestEnsureListingConversationSiteWithoutFixedQrUsesSitePool(t *testing.T) {
t.Fatalf("分站上架不得消耗主站码池, status = %q", mainQR.Status)
}
}
func TestListQrCodesExcludesSitePool(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil, nil)
site := model.DistributionSite{
SiteCode: "fx_qr_hidden",
SiteName: "隐藏码池站",
Domain: "hidden-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-only.png", Status: QrCodeStatusUnused, GroupName: "主站群"},
{ImageURL: "/api/files/object?key=qrcode/site-only.png", Status: QrCodeStatusUnused, GroupName: "分站群", DistributionSiteID: &siteID},
}
if err := db.Create(&pool).Error; err != nil {
t.Fatalf("创建码池失败: %v", err)
}
items, total, err := repo.ListQrCodes(t.Context(), QrCodeListRequest{Page: 1, Limit: 20})
if err != nil {
t.Fatalf("查询主站码池失败: %v", err)
}
if total != 1 || len(items) != 1 || items[0].GroupName != "主站群" {
t.Fatalf("主站码池 = %+v total %d, want 只有主站群", items, total)
}
siteItems, siteTotal, err := repo.ListSiteQrCodes(t.Context(), siteID, QrCodeListRequest{Page: 1, Limit: 20})
if err != nil {
t.Fatalf("查询分站码池失败: %v", err)
}
if siteTotal != 1 || len(siteItems) != 1 || siteItems[0].GroupName != "分站群" {
t.Fatalf("分站码池 = %+v total %d, want 只有分站群", siteItems, siteTotal)
}
}
func TestDeliverPendingUsesOwnSitePool(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil, nil)
site := model.DistributionSite{
SiteCode: "fx_qr_pending",
SiteName: "待补发站",
Domain: "pending-qr.example.com",
Status: "active",
}
if err := db.Create(&site).Error; err != nil {
t.Fatalf("创建分销站点失败: %v", err)
}
siteID := site.ID
conversation := model.ChatConversation{
Type: ConversationTypeListingGroup,
Title: "分站发布群",
Status: "active",
DistributionSiteID: &siteID,
}
if err := db.Create(&conversation).Error; err != nil {
t.Fatalf("创建群聊失败: %v", err)
}
task := model.ChatQrCodeDeliveryTask{ConversationID: conversation.ID, Status: "pending"}
if err := db.Create(&task).Error; err != nil {
t.Fatalf("创建补发任务失败: %v", err)
}
mainQR := model.ChatQrCode{ImageURL: "/api/files/object?key=qrcode/main-pending.png", Status: QrCodeStatusUnused}
if err := db.Create(&mainQR).Error; err != nil {
t.Fatalf("创建主站码失败: %v", err)
}
if _, err := repo.BatchCreateSiteQrCode(t.Context(), 1, siteID, BatchCreateQrCodeRequest{
Items: []CreateQrCodeRequest{{ImageURL: "/api/files/object?key=qrcode/site-pending.png", GroupName: "分站补发"}},
}); err != nil {
t.Fatalf("补充分站码失败: %v", err)
}
var savedTask model.ChatQrCodeDeliveryTask
if err := db.First(&savedTask, task.ID).Error; err != nil {
t.Fatalf("查询补发任务失败: %v", err)
}
if savedTask.Status != "sent" {
t.Fatalf("分站补发任务状态 = %q, want sent", savedTask.Status)
}
if err := db.First(&mainQR, mainQR.ID).Error; err != nil {
t.Fatalf("查询主站码失败: %v", err)
}
if mainQR.Status != QrCodeStatusUnused {
t.Fatalf("补发分站群不得消耗主站码, status = %q", mainQR.Status)
}
}