feat(sms): 新增节点短信提醒(审核未通过/被租用需辅助/上架未成交)

This commit is contained in:
yml2213
2026-10-14 14:57:52 +08:00
parent efa0aa4ffd
commit 93e102b7c0
38 changed files with 3228 additions and 70 deletions
+167
View File
@@ -0,0 +1,167 @@
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])
}