feat(distribution): 分站发布群只使用自己的二维码池
主站单独维护分站码池,池空时不再借用主站或其他分站,并去掉已停用的固定二维码入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user