From 1d21b3f5480ffce1ab3ad21c64ecfa546c8950c4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 4 Oct 2026 18:35:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(distribution):=20=E5=88=86=E7=AB=99?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E7=BE=A4=E5=8F=AA=E4=BD=BF=E7=94=A8=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E7=9A=84=E4=BA=8C=E7=BB=B4=E7=A0=81=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主站单独维护分站码池,池空时不再借用主站或其他分站,并去掉已停用的固定二维码入口。 Co-authored-by: Cursor --- .../internal/modules/chat/handler_qrcode.go | 165 ++++++ .../internal/modules/chat/listing_group.go | 71 +-- backend/internal/modules/chat/qrcode.go | 233 +++++++- backend/internal/modules/chat/qrcode_test.go | 134 ++++- backend/internal/router/router.go | 6 + distribution-admin/src/pages/SiteSettings.tsx | 29 +- .../src/features/admin/api/adminQrCodes.ts | 49 ++ .../views/AdminDistributionQrCodePoolView.vue | 520 ++++++++++++++++++ .../views/AdminDistributionSitesView.vue | 154 +----- frontend/src/layouts/AdminLayout.vue | 6 + frontend/src/router/adminRoutes.ts | 6 + 11 files changed, 1123 insertions(+), 250 deletions(-) create mode 100644 frontend/src/features/admin/views/AdminDistributionQrCodePoolView.vue diff --git a/backend/internal/modules/chat/handler_qrcode.go b/backend/internal/modules/chat/handler_qrcode.go index a21a8ca..a57ac3a 100644 --- a/backend/internal/modules/chat/handler_qrcode.go +++ b/backend/internal/modules/chat/handler_qrcode.go @@ -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}}) +} diff --git a/backend/internal/modules/chat/listing_group.go b/backend/internal/modules/chat/listing_group.go index d898550..aa832da 100644 --- a/backend/internal/modules/chat/listing_group.go +++ b/backend/internal/modules/chat/listing_group.go @@ -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() diff --git a/backend/internal/modules/chat/qrcode.go b/backend/internal/modules/chat/qrcode.go index 610a87b..5f0a382 100644 --- a/backend/internal/modules/chat/qrcode.go +++ b/backend/internal/modules/chat/qrcode.go @@ -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 diff --git a/backend/internal/modules/chat/qrcode_test.go b/backend/internal/modules/chat/qrcode_test.go index 915e3a6..47e4322 100644 --- a/backend/internal/modules/chat/qrcode_test.go +++ b/backend/internal/modules/chat/qrcode_test.go @@ -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) + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a0f4dfd..09483ce 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -687,6 +687,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_manage"), distributionHandler.ApplySiteWithdrawal) adminRoutes.GET("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_view"), distributionHandler.ListSiteWithdrawals) adminRoutes.GET("/distribution/sites/:id/analytics", requirePerm("distribution:site_view"), distributionHandler.GetDashboardAnalytics) + adminRoutes.GET("/distribution/sites/:id/qrcodes", requirePerm("qrcode:view"), chatHandler.ListSiteQrCodesHandler) + adminRoutes.GET("/distribution/sites/:id/qrcodes/stats", requirePerm("qrcode:view"), chatHandler.GetSiteQrCodeStatsHandler) + adminRoutes.POST("/distribution/sites/:id/qrcodes/batch", requirePerm("qrcode:manage"), chatHandler.BatchCreateSiteQrCodeHandler) + adminRoutes.POST("/distribution/sites/:id/qrcodes/batch-delete", requirePerm("qrcode:manage"), chatHandler.BatchDeleteSiteQrCodeHandler) + adminRoutes.PATCH("/distribution/sites/:id/qrcodes/:qrcodeId", requirePerm("qrcode:manage"), chatHandler.UpdateSiteQrCodeHandler) + adminRoutes.DELETE("/distribution/sites/:id/qrcodes/:qrcodeId", requirePerm("qrcode:manage"), chatHandler.DeleteSiteQrCodeHandler) adminRoutes.GET("/distribution/received-listings", requirePerm("distribution:account_view"), distributionHandler.ListReceivedListings) adminRoutes.PUT("/distribution/received-listings/:id/status", requirePerm("distribution:account_display"), distributionHandler.UpdateReceivedListingStatus) adminRoutes.GET("/distribution/announcements", requirePerm("distribution:announcement_view"), distributionHandler.ListSiteAnnouncements) diff --git a/distribution-admin/src/pages/SiteSettings.tsx b/distribution-admin/src/pages/SiteSettings.tsx index 62d6bb9..a47eca7 100644 --- a/distribution-admin/src/pages/SiteSettings.tsx +++ b/distribution-admin/src/pages/SiteSettings.tsx @@ -149,7 +149,6 @@ export default function SiteSettings() { const [awEntry, setAwEntry] = useState(defaultAWEntry) const [coopUseMain, setCoopUseMain] = useState(true) const [coopEntry, setCoopEntry] = useState(defaultCoopEntry) - const [listingQrcode, setListingQrcode] = useState('') const [savingRatio, setSavingRatio] = useState(false) // SMS config state @@ -226,7 +225,6 @@ export default function SiteSettings() { const coop = parseHomeEntry(found.cooperation_feedback_json, defaultCoopEntry) setCoopUseMain(coop.useMain) setCoopEntry(coop.value) - setListingQrcode(found.listing_qrcode_url || '') try { const sms = await fetchSiteSMSConfig(id) @@ -524,7 +522,6 @@ export default function SiteSettings() { aw_recycle_json: awUseMain ? '' : JSON.stringify(awEntry), cooperation_feedback_json: coopUseMain ? '' : JSON.stringify(coopEntry), storefront_text_json: storefrontTextFilled(storefrontText), - listing_qrcode_url: listingQrcode.trim(), } const updated = await updateSiteBranding(siteID, payload) setCurrentSite(updated) @@ -994,13 +991,13 @@ export default function SiteSettings() { }, { key: 'entries', - label: '首页入口与客服二维码', + label: '首页入口', children: (
AW炫彩回收 @@ -1020,28 +1017,6 @@ export default function SiteSettings() { )}
-
- 客服二维码 - - {listingQrcode ? ( - - ) : ( - 未配置,发布群使用主站二维码池。 - )} - - uploadEntryImage(file, 'support-qrcode', setListingQrcode)} - > - - - - - -
), }, diff --git a/frontend/src/features/admin/api/adminQrCodes.ts b/frontend/src/features/admin/api/adminQrCodes.ts index da6494b..de7f4dc 100644 --- a/frontend/src/features/admin/api/adminQrCodes.ts +++ b/frontend/src/features/admin/api/adminQrCodes.ts @@ -170,3 +170,52 @@ export async function batchDeleteQrCodes(ids: number[]) { ) return data.data } + +function siteQrCodeBase(siteId: number) { + return `/admin/distribution/sites/${siteId}/qrcodes` +} + +export async function fetchSiteQrCodes(siteId: number, query: QrCodeListQuery = {}) { + const { data } = await apiClient.get>>( + siteQrCodeBase(siteId), + { + params: { + status: query.status || undefined, + keyword: query.keyword?.trim() || undefined, + page: query.page || 1, + limit: query.limit || 20, + }, + silent: true, + } + ) + return data.data +} + +export async function fetchSiteQrCodeStats(siteId: number) { + const { data } = await apiClient.get>( + `${siteQrCodeBase(siteId)}/stats`, + { silent: true } + ) + return data.data +} + +export async function batchCreateSiteQrCodes(siteId: number, items: CreateQrCodePayload[]) { + const { data } = await apiClient.post>( + `${siteQrCodeBase(siteId)}/batch`, + { items } + ) + return data.data +} + +export async function updateSiteQrCode(siteId: number, id: number, payload: UpdateQrCodePayload) { + const { data } = await apiClient.patch>( + `${siteQrCodeBase(siteId)}/${id}`, + payload + ) + return data.data +} + +export async function deleteSiteQrCode(siteId: number, id: number) { + const { data } = await apiClient.delete>(`${siteQrCodeBase(siteId)}/${id}`) + return data.data +} diff --git a/frontend/src/features/admin/views/AdminDistributionQrCodePoolView.vue b/frontend/src/features/admin/views/AdminDistributionQrCodePoolView.vue new file mode 100644 index 0000000..a145a7e --- /dev/null +++ b/frontend/src/features/admin/views/AdminDistributionQrCodePoolView.vue @@ -0,0 +1,520 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminDistributionSitesView.vue b/frontend/src/features/admin/views/AdminDistributionSitesView.vue index 1b813ba..2bab2e8 100644 --- a/frontend/src/features/admin/views/AdminDistributionSitesView.vue +++ b/frontend/src/features/admin/views/AdminDistributionSitesView.vue @@ -8,8 +8,6 @@ import { DocumentCopy, Refresh, Clock, - Picture, - Upload, Coin, Message, Edit, @@ -33,8 +31,6 @@ import { type OnboardDistributionSitePayload, type OnboardDistributionSiteResult, } from '@/features/admin/api/adminDistribution' -import { uploadAdminFile } from '@/shared/api/files' -import AuthImage from '@/shared/components/business/AuthImage.vue' import { formatDateTime } from '@/shared/utils/time' import { readError } from '@/shared/utils/error' import { @@ -110,15 +106,6 @@ const receiveRatioForm = reactive({ receive_cost_delta: 0, }) -const qrDialogVisible = ref(false) -const qrSubmitting = ref(false) -const qrUploading = ref(false) -const qrForm = reactive({ - site_id: 0, - site_name: '', - listing_qrcode_url: '', -}) - const smsDialogVisible = ref(false) const smsLoading = ref(false) const smsSubmitting = ref(false) @@ -204,51 +191,6 @@ async function handleReceiveRatioSubmit() { } } -function handleOpenQrcode(row: DistributionSite) { - qrForm.site_id = row.id - qrForm.site_name = row.site_name - qrForm.listing_qrcode_url = row.listing_qrcode_url || '' - qrDialogVisible.value = true -} - -async function uploadSiteQrcode(file: File) { - qrUploading.value = true - try { - const uploaded = await uploadAdminFile(file, 'qrcode') - qrForm.listing_qrcode_url = uploaded.url - ElMessage.success('二维码已上传,保存后生效') - } catch (err) { - ElMessage.error(readError(err, '上传二维码失败')) - } finally { - qrUploading.value = false - } - return false -} - -function clearSiteQrcode() { - qrForm.listing_qrcode_url = '' -} - -async function handleQrcodeSubmit() { - qrSubmitting.value = true - try { - await updateDistributionSite(qrForm.site_id, { - listing_qrcode_url: qrForm.listing_qrcode_url.trim(), - }) - ElMessage.success( - qrForm.listing_qrcode_url.trim() - ? `【${qrForm.site_name}】发布群固定二维码已更新` - : `【${qrForm.site_name}】已清空发布群固定二维码` - ) - qrDialogVisible.value = false - await loadSites() - } catch (err) { - ElMessage.error(readError(err, '更新发布群二维码失败')) - } finally { - qrSubmitting.value = false - } -} - async function handleOpenSMS(row: DistributionSite) { smsForm.site_id = row.id smsForm.site_name = row.site_name @@ -676,8 +618,8 @@ onMounted(() => {

分销站点管理

- 开通分销站、配置结算冷却期、收号比例上限与发布群固定二维码。利润按 35% 收号 / 40% 卖出 / - 25% 服务费切分。 + 开通分销站、配置结算冷却期与收号比例上限。发布群进群码在「分站二维码池」按站点维护。利润按 + 35% 收号 / 40% 卖出 / 25% 服务费切分。

@@ -811,18 +753,6 @@ onMounted(() => { - - - - - - - - - - - -
- -
尚未配置
-
- - 上传二维码 - - - 清空 - -
-
-
-
- -
- { color: #6b7280; font-size: 13px; } - -.qr-row { - display: flex; - flex-wrap: wrap; - gap: 12px; - align-items: flex-start; -} - -.qr-empty { - width: 160px; - height: 160px; - border-radius: 12px; - background: #f7f9fc; - color: #9ca3af; - display: flex; - align-items: center; - justify-content: center; - font-size: 13px; -} - -.qr-actions { - display: flex; - flex-direction: column; - gap: 8px; -} diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index d8211f2..df98a6e 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -145,6 +145,12 @@ const allNavGroups: NavGroup[] = [ icon: Shop, permission: 'distribution:site_view', }, + { + label: '分站二维码池', + to: adminPath('distribution/qrcode-pool'), + icon: Picture, + permission: 'qrcode:view', + }, { label: '分销规则', to: adminPath('distribution/rules'), diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index 271be74..43ef5ee 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -222,6 +222,12 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminDistributionSitesView.vue'), meta: adminMeta, }, + { + path: adminPath('distribution/qrcode-pool'), + name: 'admin-distribution-qrcode-pool', + component: () => import('@/features/admin/views/AdminDistributionQrCodePoolView.vue'), + meta: adminMeta, + }, { path: adminPath('distribution/rules'), name: 'admin-distribution-rules',