345 lines
9.2 KiB
Go
345 lines
9.2 KiB
Go
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"
|
|
|
|
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("二维码不存在")
|
|
ErrQrCodeCannotDelete = 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"`
|
|
}
|
|
|
|
// 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"`
|
|
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) {
|
|
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 := r.db.WithContext(ctx).Create(&qrcode).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &qrcode, nil
|
|
}
|
|
|
|
// BatchCreateQrCode 批量创建二维码
|
|
func (r *Repository) BatchCreateQrCode(ctx context.Context, adminID uint64, req BatchCreateQrCodeRequest) ([]model.ChatQrCode, error) {
|
|
var qrcodes []model.ChatQrCode
|
|
defaultExpires := time.Now().Add(7 * 24 * time.Hour)
|
|
|
|
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 := r.db.WithContext(ctx).Create(&qrcodes).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 []QrCodeListItem
|
|
offset := (req.Page - 1) * req.Limit
|
|
if err := r.db.WithContext(ctx).
|
|
Table("chat_qrcode_pool AS q").
|
|
Select("q.*, COALESCE(c.title, '') AS bound_conversation_title").
|
|
Joins("LEFT JOIN chat_conversations AS c ON c.id = q.conversation_id").
|
|
Scopes(func(db *gorm.DB) *gorm.DB {
|
|
if req.Status != "" {
|
|
return db.Where("q.status = ?", req.Status)
|
|
}
|
|
return db
|
|
}).
|
|
Order("q.id DESC").
|
|
Offset(offset).
|
|
Limit(req.Limit).
|
|
Scan(&qrcodes).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return qrcodes, total, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 已使用的不能删除
|
|
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
|
|
}
|