Files
hfb_sys/backend/internal/modules/chat/qrcode.go
T

544 lines
15 KiB
Go

package chat
import (
"context"
"errors"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chathub"
)
// QrCodeStatus 二维码状态常量
const (
QrCodeStatusUnused = "unused"
QrCodeStatusUsed = "used"
QrCodeStatusDisabled = "disabled"
qrCodeOCRTokenConfigKey = "integration.paddle_ocr_token"
qrCodeOCRJobURLConfigKey = "integration.paddle_ocr_job_url"
qrCodeOCRModelConfigKey = "integration.paddle_ocr_model"
defaultQrCodeOCRJobURL = "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs"
defaultQrCodeOCRModel = "PaddleOCR-VL-1.6"
)
var (
ErrQrCodeNotFound = errors.New("二维码不存在")
ErrQrCodeCannotReenable = errors.New("已发放的二维码不能改回待用")
)
// CreateQrCodeRequest 创建二维码请求
type CreateQrCodeRequest struct {
ImageURL string `json:"image_url" binding:"required"`
GroupName string `json:"group_name"`
Note string `json:"note"`
ExpiresAt *time.Time `json:"expires_at"`
}
// BatchCreateQrCodeRequest 批量创建二维码请求
type BatchCreateQrCodeRequest struct {
Items []CreateQrCodeRequest `json:"items" binding:"required,min=1,max=20"`
}
// BatchDeleteQrCodeRequest 批量删除二维码请求
type BatchDeleteQrCodeRequest struct {
IDs []uint64 `json:"ids" binding:"required,min=1,max=100"`
}
// UpdateQrCodeRequest 更新二维码请求
type UpdateQrCodeRequest struct {
ImageURL *string `json:"image_url"`
GroupName *string `json:"group_name"`
Note *string `json:"note"`
Status *string `json:"status"`
WecomRenamed *bool `json:"wecom_renamed"`
ExpiresAt *time.Time `json:"expires_at"`
ClearExpiresAt bool `json:"clear_expires_at"`
}
// QrCodeListRequest 列表查询请求
type QrCodeListRequest struct {
Status string `form:"status"`
Keyword string `form:"keyword"`
WecomRenamed string `form:"wecom_renamed"`
Bound string `form:"bound"`
ExpireStatus string `form:"expire_status"`
CreatedStart string `form:"created_start"`
CreatedEnd string `form:"created_end"`
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"`
}
// QrCodeListItem 二维码列表项
type QrCodeListItem struct {
model.ChatQrCode
BoundConversationTitle string `json:"bound_conversation_title"`
}
// QrCodeOCRConfig 二维码 OCR 调用配置
type QrCodeOCRConfig struct {
Token string `json:"token"`
JobURL string `json:"job_url"`
Model string `json:"model"`
}
// CreateQrCode 创建二维码
func (r *Repository) CreateQrCode(ctx context.Context, adminID uint64, req CreateQrCodeRequest) (*model.ChatQrCode, error) {
var qrcode model.ChatQrCode
var deliveredMessages []model.ChatMessage
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
qrcode = model.ChatQrCode{
ImageURL: req.ImageURL,
GroupName: req.GroupName,
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 := tx.Create(&qrcode).Error; err != nil {
return err
}
var err error
deliveredMessages, err = deliverPendingQrCodes(tx)
return err
})
if err != nil {
return nil, err
}
r.notifyDeliveredQrCodeMessages(deliveredMessages)
return &qrcode, nil
}
// BatchCreateQrCode 批量创建二维码
func (r *Repository) BatchCreateQrCode(ctx context.Context, adminID uint64, req BatchCreateQrCodeRequest) ([]model.ChatQrCode, error) {
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,
Status: QrCodeStatusUnused,
CreatedBy: adminID,
Note: item.Note,
ExpiresAt: item.ExpiresAt,
}
if qr.ExpiresAt == nil {
qr.ExpiresAt = &defaultExpires
}
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
}
// ListQrCodes 列表查询二维码
func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]QrCodeListItem, int64, error) {
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"),
req,
now,
)
}
// 统计总数
var total int64
if err := baseQuery().Count(&total).Error; err != nil {
return nil, 0, err
}
// 查询列表
var qrcodes []QrCodeListItem
offset := (req.Page - 1) * req.Limit
if err := baseQuery().
Select("q.*, COALESCE(c.title, '') AS bound_conversation_title").
Order("q.id DESC").
Offset(offset).
Limit(req.Limit).
Scan(&qrcodes).Error; err != nil {
return nil, 0, err
}
return qrcodes, total, nil
}
func applyQrCodeListFilters(query *gorm.DB, req QrCodeListRequest, now time.Time) *gorm.DB {
if req.Status != "" {
query = query.Where("q.status = ?", req.Status)
}
if keyword := strings.TrimSpace(req.Keyword); keyword != "" {
like := "%" + keyword + "%"
conditions := "(q.group_name LIKE ? OR q.note LIKE ? OR COALESCE(c.title, '') LIKE ?"
args := []interface{}{like, like, like}
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
conditions += " OR q.id = ? OR q.conversation_id = ?"
args = append(args, id, id)
}
conditions += ")"
query = query.Where(conditions, args...)
}
if value, ok := parseQrCodeBoolFilter(req.WecomRenamed); ok {
query = query.Where("q.wecom_renamed = ?", value)
}
if value, ok := parseQrCodeBoolFilter(req.Bound); ok {
if value {
query = query.Where("q.conversation_id IS NOT NULL")
} else {
query = query.Where("q.conversation_id IS NULL")
}
}
switch req.ExpireStatus {
case "valid":
query = query.Where("(q.expires_at IS NULL OR q.expires_at > ?)", now)
case "expired":
query = query.Where("q.expires_at IS NOT NULL AND q.expires_at <= ?", now)
case "permanent":
query = query.Where("q.expires_at IS NULL")
case "expiring_soon":
query = query.Where("q.expires_at > ? AND q.expires_at <= ?", now, now.Add(72*time.Hour))
}
if start, ok := parseQrCodeFilterTime(req.CreatedStart); ok {
query = query.Where("q.created_at >= ?", start)
}
if end, ok := parseQrCodeFilterTime(req.CreatedEnd); ok {
query = query.Where("q.created_at <= ?", end)
}
return query
}
func parseQrCodeBoolFilter(value string) (bool, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "true", "1", "yes":
return true, true
case "false", "0", "no":
return false, true
default:
return false, false
}
}
func parseQrCodeFilterTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
layouts := []string{
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006-01-02",
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
// GetQrCodeStats 获取二维码统计信息
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 {
return nil, err
}
if err := r.db.WithContext(ctx).
Model(&model.ChatQrCode{}).
Where("status = ?", QrCodeStatusUsed).
Count(&stats.UsedCount).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).
Model(&model.ChatQrCode{}).
Where("status = ?", QrCodeStatusDisabled).
Count(&stats.DisabledCount).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).
Model(&model.ChatQrCode{}).
Where("status = ?", QrCodeStatusUnused).
Where("expires_at IS NULL OR expires_at > ?", now).
Count(&stats.UnusedCount).Error; err != nil {
return nil, err
}
return stats, nil
}
// GetQrCodeOCRConfig 获取前端直连 PaddleOCR 所需配置
func (r *Repository) GetQrCodeOCRConfig(ctx context.Context) (*QrCodeOCRConfig, error) {
config := &QrCodeOCRConfig{
JobURL: defaultQrCodeOCRJobURL,
Model: defaultQrCodeOCRModel,
}
var rows []model.SystemConfig
keys := []string{qrCodeOCRTokenConfigKey, qrCodeOCRJobURLConfigKey, qrCodeOCRModelConfigKey}
if err := r.db.WithContext(ctx).Where("`key` IN ?", keys).Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
switch row.Key {
case qrCodeOCRTokenConfigKey:
config.Token = row.Value
case qrCodeOCRJobURLConfigKey:
if row.Value != "" {
config.JobURL = row.Value
}
case qrCodeOCRModelConfigKey:
if row.Value != "" {
config.Model = row.Value
}
}
}
return config, nil
}
// 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 errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQrCodeNotFound
}
return err
}
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
}
if err := r.db.WithContext(ctx).Model(&qrcode).Updates(updates).Error; err != nil {
return err
}
return nil
}
func qrcodeWasIssued(qrcode model.ChatQrCode) bool {
return qrcode.Status == QrCodeStatusUsed || qrcode.ConversationID != nil || qrcode.UsedAt != 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
}
return r.db.WithContext(ctx).Delete(&qrcode).Error
}
// BatchDeleteQrCodes 批量删除二维码
func (r *Repository) BatchDeleteQrCodes(ctx context.Context, ids []uint64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
result := r.db.WithContext(ctx).Where("id IN ?", ids).Delete(&model.ChatQrCode{})
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
// 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
}
func deliverPendingQrCodes(tx *gorm.DB) ([]model.ChatMessage, error) {
deliveredMessages := make([]model.ChatMessage, 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 {
if errors.Is(err, gorm.ErrRecordNotFound) {
return deliveredMessages, nil
}
return nil, err
}
qrcode, err := fetchUnusedQrCode(tx)
if err != nil {
return nil, err
}
if qrcode == nil {
return deliveredMessages, nil
}
if err := markQrCodeAsUsed(tx, qrcode.ID, task.ConversationID); err != nil {
return nil, err
}
message, err := sendQrCodeImage(tx, task.ConversationID, qrcode.ImageURL)
if err != nil {
now := time.Now()
if updateErr := tx.Model(&task).Updates(map[string]interface{}{
"status": "failed",
"qrcode_id": qrcode.ID,
"error_message": err.Error(),
"updated_at": now,
}).Error; updateErr != nil {
return nil, updateErr
}
continue
}
deliveredMessages = append(deliveredMessages, *message)
now := time.Now()
if err := tx.Model(&task).Updates(map[string]interface{}{
"status": "sent",
"qrcode_id": qrcode.ID,
"error_message": "",
"sent_at": now,
"updated_at": now,
}).Error; err != nil {
return nil, err
}
}
}
func (r *Repository) notifyDeliveredQrCodeMessages(messages []model.ChatMessage) {
if r.hub == nil {
return
}
for _, msg := range messages {
event := &chathub.ChatEvent{
Type: "new_message",
ConversationID: msg.ConversationID,
Message: &chathub.MessageData{
ID: msg.ID,
ConversationID: msg.ConversationID,
SenderType: msg.SenderType,
SenderID: msg.SenderID,
SenderRole: msg.SenderRole,
SenderName: "系统",
ContentType: msg.ContentType,
Content: msg.Content,
AttachmentURLS: decodeStringList(msg.AttachmentURLS),
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
},
}
r.hub.NotifyConversation(msg.ConversationID, event)
r.hub.NotifyAllAdmins(event)
}
}