feat(sms): 新增节点短信提醒(审核未通过/被租用需辅助/上架未成交)
This commit is contained in:
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user