提交1: 数据层改造 - 发布群与企业微信群二维码
- 新增数据库迁移 000008: chat_conversations 增加 listing_id, 新建 chat_qrcode_pool 表 - 更新 GORM 模型: ChatConversation 新增 ListingID 字段, 新增 ChatQrCode 模型 - 实现二维码池管理后端 API: 创建/列表/统计/更新/删除 - 新增管理后台路由: /api/admin/chats/qrcodes - 新增系统配置: chat.listing_group_welcome, chat.qrcode_low_stock_threshold - 更新优化方案文档: 废弃双群方案, 采用单群方案 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
type ChatConversation struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"`
|
||||
ListingID *uint64 `gorm:"index" json:"listing_id"`
|
||||
Type string `gorm:"size:32;not null;default:'order_group'" json:"type"`
|
||||
Title string `gorm:"size:128;not null" json:"title"`
|
||||
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
||||
@@ -55,3 +56,20 @@ type ChatMessage struct {
|
||||
func (ChatMessage) TableName() string {
|
||||
return "chat_messages"
|
||||
}
|
||||
|
||||
type ChatQrCode struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
ImageURL string `gorm:"size:512;not null" json:"image_url"`
|
||||
Status string `gorm:"size:16;not null;default:'unused'" json:"status"`
|
||||
ConversationID *uint64 `gorm:"index" json:"conversation_id"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
CreatedBy uint64 `gorm:"not null" json:"created_by"`
|
||||
Note string `gorm:"size:255;not null;default:''" json:"note"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (ChatQrCode) TableName() string {
|
||||
return "chat_qrcode_pool"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CreateQrCodeHandler 创建二维码
|
||||
func (h *Handler) CreateQrCodeHandler(c *gin.Context) {
|
||||
var req CreateQrCodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
adminID := c.GetUint64("admin_id")
|
||||
qrcode, err := h.service.repo.CreateQrCode(c.Request.Context(), adminID, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": qrcode})
|
||||
}
|
||||
|
||||
// ListQrCodesHandler 列表查询二维码
|
||||
func (h *Handler) ListQrCodesHandler(c *gin.Context) {
|
||||
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.ListQrCodes(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": qrcodes,
|
||||
"pagination": gin.H{
|
||||
"total": total,
|
||||
"page": req.Page,
|
||||
"limit": req.Limit,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetQrCodeStatsHandler 获取二维码统计
|
||||
func (h *Handler) GetQrCodeStatsHandler(c *gin.Context) {
|
||||
stats, err := h.service.repo.GetQrCodeStats(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": stats})
|
||||
}
|
||||
|
||||
// UpdateQrCodeHandler 更新二维码
|
||||
func (h *Handler) UpdateQrCodeHandler(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 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.UpdateQrCode(c.Request.Context(), id, req); err != nil {
|
||||
if err == ErrQrCodeNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "二维码不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
|
||||
}
|
||||
|
||||
// DeleteQrCodeHandler 删除二维码
|
||||
func (h *Handler) DeleteQrCodeHandler(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.repo.DeleteQrCode(c.Request.Context(), id); err != nil {
|
||||
if err == ErrQrCodeNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "二维码不存在"})
|
||||
return
|
||||
}
|
||||
if err == ErrQrCodeCannotDelete {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "已使用的二维码不能删除"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"hfb_sys/backend/internal/model"
|
||||
)
|
||||
|
||||
// QrCodeStatus 二维码状态常量
|
||||
const (
|
||||
QrCodeStatusUnused = "unused"
|
||||
QrCodeStatusUsed = "used"
|
||||
QrCodeStatusDisabled = "disabled"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrQrCodeNotFound = errors.New("二维码不存在")
|
||||
ErrQrCodeCannotDelete = errors.New("已使用的二维码不能删除")
|
||||
)
|
||||
|
||||
// CreateQrCodeRequest 创建二维码请求
|
||||
type CreateQrCodeRequest struct {
|
||||
ImageURL string `json:"image_url" binding:"required"`
|
||||
Note string `json:"note"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// UpdateQrCodeRequest 更新二维码请求
|
||||
type UpdateQrCodeRequest struct {
|
||||
Note *string `json:"note"`
|
||||
Status *string `json:"status"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// QrCodeListRequest 列表查询请求
|
||||
type QrCodeListRequest struct {
|
||||
Status string `form:"status"`
|
||||
Page int `form:"page"`
|
||||
Limit int `form:"limit"`
|
||||
}
|
||||
|
||||
// QrCodeStats 二维码统计
|
||||
type QrCodeStats struct {
|
||||
UnusedCount int64 `json:"unused_count"`
|
||||
UsedCount int64 `json:"used_count"`
|
||||
DisabledCount int64 `json:"disabled_count"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
}
|
||||
|
||||
// CreateQrCode 创建二维码
|
||||
func (r *Repository) CreateQrCode(ctx context.Context, adminID uint64, req CreateQrCodeRequest) (*model.ChatQrCode, error) {
|
||||
qrcode := model.ChatQrCode{
|
||||
ImageURL: req.ImageURL,
|
||||
Status: QrCodeStatusUnused,
|
||||
CreatedBy: adminID,
|
||||
Note: req.Note,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
}
|
||||
|
||||
// 如果未指定过期时间,默认7天后过期
|
||||
if qrcode.ExpiresAt == nil {
|
||||
expires := time.Now().Add(7 * 24 * time.Hour)
|
||||
qrcode.ExpiresAt = &expires
|
||||
}
|
||||
|
||||
if err := r.db.WithContext(ctx).Create(&qrcode).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &qrcode, nil
|
||||
}
|
||||
|
||||
// ListQrCodes 列表查询二维码
|
||||
func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]model.ChatQrCode, int64, error) {
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.Limit < 1 || req.Limit > 100 {
|
||||
req.Limit = 20
|
||||
}
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&model.ChatQrCode{})
|
||||
|
||||
// 状态过滤
|
||||
if req.Status != "" {
|
||||
query = query.Where("status = ?", req.Status)
|
||||
}
|
||||
|
||||
// 统计总数
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 查询列表
|
||||
var qrcodes []model.ChatQrCode
|
||||
offset := (req.Page - 1) * req.Limit
|
||||
if err := query.Order("id DESC").Offset(offset).Limit(req.Limit).Find(&qrcodes).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return qrcodes, total, nil
|
||||
}
|
||||
|
||||
// GetQrCodeStats 获取二维码统计信息
|
||||
func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) {
|
||||
stats := &QrCodeStats{}
|
||||
|
||||
// 统计各状态数量
|
||||
type CountResult struct {
|
||||
Status string
|
||||
Count int64
|
||||
}
|
||||
var results []CountResult
|
||||
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.ChatQrCode{}).
|
||||
Select("status, COUNT(*) as count").
|
||||
Group("status").
|
||||
Find(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
stats.TotalCount += r.Count
|
||||
switch r.Status {
|
||||
case QrCodeStatusUnused:
|
||||
stats.UnusedCount = r.Count
|
||||
case QrCodeStatusUsed:
|
||||
stats.UsedCount = r.Count
|
||||
case QrCodeStatusDisabled:
|
||||
stats.DisabledCount = r.Count
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// UpdateQrCode 更新二维码
|
||||
func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCodeRequest) error {
|
||||
updates := make(map[string]interface{})
|
||||
|
||||
if req.Note != nil {
|
||||
updates["note"] = *req.Note
|
||||
}
|
||||
if req.Status != nil {
|
||||
// 校验状态值
|
||||
if *req.Status != QrCodeStatusUnused && *req.Status != QrCodeStatusUsed && *req.Status != QrCodeStatusDisabled {
|
||||
return errors.New("无效的状态值")
|
||||
}
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
if req.ExpiresAt != nil {
|
||||
updates["expires_at"] = req.ExpiresAt
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).Where("id = ?", id).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrQrCodeNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrQrCodeNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 已使用的不能删除
|
||||
if qrcode.Status == QrCodeStatusUsed {
|
||||
return ErrQrCodeCannotDelete
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Delete(&qrcode).Error
|
||||
}
|
||||
|
||||
// fetchUnusedQrCode 获取一个未使用且未过期的二维码(带行锁)
|
||||
func (r *Repository) fetchUnusedQrCode(tx *gorm.DB) (*model.ChatQrCode, error) {
|
||||
var qrcode model.ChatQrCode
|
||||
now := time.Now()
|
||||
|
||||
err := tx.Where("status = ?", QrCodeStatusUnused).
|
||||
Where("expires_at IS NULL OR expires_at > ?", now).
|
||||
Order("id ASC").
|
||||
Limit(1).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&qrcode).Error
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil // 无可用二维码,返回 nil 而非错误
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &qrcode, nil
|
||||
}
|
||||
|
||||
// markQrCodeAsUsed 标记二维码为已使用
|
||||
func (r *Repository) markQrCodeAsUsed(tx *gorm.DB, qrcodeID uint64, conversationID uint64) error {
|
||||
now := time.Now()
|
||||
return tx.Model(&model.ChatQrCode{}).
|
||||
Where("id = ?", qrcodeID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": QrCodeStatusUsed,
|
||||
"conversation_id": conversationID,
|
||||
"used_at": now,
|
||||
}).Error
|
||||
}
|
||||
@@ -546,6 +546,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.GET("/chats/auto-welcome", requirePerm("system_config:view"), chatHandler.AdminGetAutoWelcome)
|
||||
adminRoutes.PUT("/chats/auto-welcome", requirePerm("system_config:update"), chatHandler.AdminUpdateAutoWelcome)
|
||||
|
||||
// 二维码池管理
|
||||
adminRoutes.POST("/chats/qrcodes", requirePerm("chat:manage"), chatHandler.CreateQrCodeHandler)
|
||||
adminRoutes.GET("/chats/qrcodes", requirePerm("chat:view"), chatHandler.ListQrCodesHandler)
|
||||
adminRoutes.GET("/chats/qrcodes/stats", requirePerm("chat:view"), chatHandler.GetQrCodeStatsHandler)
|
||||
adminRoutes.PATCH("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.UpdateQrCodeHandler)
|
||||
adminRoutes.DELETE("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.DeleteQrCodeHandler)
|
||||
|
||||
// 角色管理
|
||||
adminRoutes.GET("/roles", requirePerm("role:manage"), adminRoleHandler.List)
|
||||
adminRoutes.GET("/roles/:id", requirePerm("role:manage"), adminRoleHandler.FindByID)
|
||||
|
||||
Reference in New Issue
Block a user