package sms import ( "context" "encoding/json" "strings" "time" "hfb_sys/backend/internal/model" "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Repository struct { db *gorm.DB } func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } // Entry 描述一条待发送的节点短信。 // 去重口径为 (Node, BizType, BizID, UserID): // - 审核未通过:同一账号只发一次; // - 被租用需辅助:每个订单发一次,同一账号被反复租用会分别提醒; // - 上架未成交:同一账号只发一次。 type Entry struct { UserID uint64 Phone string Node string BizType string BizID uint64 DistributionSiteID *uint64 Params map[string]string } // Enqueue 在业务事务内写入待发送记录,返回是否真正插入了新记录。 // 与站内信 notification.Append 相同的用法:只落库不发送, // 短信通道异常不会影响业务提交;重复入队由唯一索引静默忽略(返回 false, nil)。 func Enqueue(tx *gorm.DB, entry Entry) (bool, error) { if tx == nil { return false, nil } node := strings.TrimSpace(entry.Node) if node == "" || entry.UserID == 0 || !IsKnownNode(node) { return false, nil } phone := strings.TrimSpace(entry.Phone) if phone == "" { var user model.User if err := tx.Select("id", "phone").First(&user, entry.UserID).Error; err == nil { phone = strings.TrimSpace(user.Phone) } } var params datatypes.JSON if len(entry.Params) > 0 { raw, err := json.Marshal(entry.Params) if err != nil { return false, err } params = datatypes.JSON(raw) } row := model.SmsMessage{ UserID: entry.UserID, Phone: phone, Node: node, BizType: strings.TrimSpace(entry.BizType), BizID: entry.BizID, DistributionSiteID: entry.DistributionSiteID, TemplateParams: params, Status: model.SmsStatusPending, } result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row) if result.Error != nil { return false, result.Error } return result.RowsAffected > 0, nil } // ListPending 拉取待发送记录。重试过的记录要等到 retryAfter 之后才再次尝试,避免密集重试。 func (r *Repository) ListPending(ctx context.Context, limit int, retryAfter time.Time) ([]model.SmsMessage, error) { if r == nil || r.db == nil { return nil, gorm.ErrInvalidDB } if limit <= 0 { limit = 100 } var rows []model.SmsMessage err := r.db.WithContext(ctx). Where("status = ? AND (retry_count = 0 OR updated_at <= ?)", model.SmsStatusPending, retryAfter). Order("id ASC"). Limit(limit). Find(&rows).Error return rows, err } // MarkSent 记录发送成功,并回写实际使用的模板 CODE 与签名。 func (r *Repository) MarkSent(ctx context.Context, id uint64, templateCode string, signName string, sentAt time.Time) error { return r.update(ctx, id, map[string]any{ "status": model.SmsStatusSent, "template_code": strings.TrimSpace(templateCode), "sign_name": strings.TrimSpace(signName), "last_error": "", "skip_reason": "", "sent_at": sentAt, }) } // MarkRetry 记录一次失败并留在待发送状态,由下一轮任务重试。 func (r *Repository) MarkRetry(ctx context.Context, id uint64, retryCount int, lastError string) error { return r.update(ctx, id, map[string]any{ "status": model.SmsStatusPending, "retry_count": retryCount, "last_error": truncateError(lastError), }) } // MarkFailed 重试次数用尽后标记为失败。 func (r *Repository) MarkFailed(ctx context.Context, id uint64, retryCount int, lastError string) error { return r.update(ctx, id, map[string]any{ "status": model.SmsStatusFailed, "retry_count": retryCount, "last_error": truncateError(lastError), }) } // MarkSkipped 记录因配置缺失(总开关关闭、模板未配置等)未发送。 func (r *Repository) MarkSkipped(ctx context.Context, id uint64, reason string) error { return r.update(ctx, id, map[string]any{ "status": model.SmsStatusSkipped, "skip_reason": truncate(reason, 128), }) } func (r *Repository) update(ctx context.Context, id uint64, fields map[string]any) error { if r == nil || r.db == nil { return gorm.ErrInvalidDB } if id == 0 { return nil } return r.db.WithContext(ctx). Model(&model.SmsMessage{}). Where("id = ?", id). Updates(fields).Error } func truncateError(value string) string { return truncate(value, 512) } func truncate(value string, limit int) string { value = strings.TrimSpace(value) if limit <= 0 { return value } runes := []rune(value) if len(runes) <= limit { return value } return string(runes[:limit]) }