168 lines
4.7 KiB
Go
168 lines
4.7 KiB
Go
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])
|
||
}
|