feat(sms): 新增节点短信提醒(审核未通过/被租用需辅助/上架未成交)
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ConfigKey 主站节点短信配置项,与分销站点的 business_templates 同构。
|
||||
const ConfigKey = "sms.business_templates"
|
||||
|
||||
// DefaultStaleDays 上架未成交提醒的默认天数阈值。
|
||||
const DefaultStaleDays = 3
|
||||
|
||||
// ConfigReader 读取系统配置文本,由 systemconfig.Repository 实现。
|
||||
type ConfigReader interface {
|
||||
FindValue(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
// LoadConfig 从系统配置读取并解析节点短信配置。
|
||||
func LoadConfig(ctx context.Context, reader ConfigReader) (Config, error) {
|
||||
if reader == nil {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
value, err := reader.FindValue(ctx, ConfigKey)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return ParseConfigValue(value)
|
||||
}
|
||||
|
||||
// NodeConfig 是单个节点的开关与模板 CODE。
|
||||
type NodeConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
TemplateCode string `json:"template_code"`
|
||||
// StaleDays 仅上架未成交提醒使用:上架满多少天仍未成交才提醒,0 表示取默认值。
|
||||
StaleDays int `json:"stale_days,omitempty"`
|
||||
}
|
||||
|
||||
// Config 是主站节点短信配置(系统配置 sms.business_templates 的 JSON 结构)。
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Nodes map[string]NodeConfig `json:"nodes"`
|
||||
}
|
||||
|
||||
// DefaultConfig 返回默认配置:总开关关闭(避免误发产生短信费用),
|
||||
// 三个节点默认各自开启,模板 CODE 留空由运营在后台填写。
|
||||
func DefaultConfig() Config {
|
||||
nodes := make(map[string]NodeConfig, len(nodeMetas))
|
||||
for _, meta := range nodeMetas {
|
||||
node := NodeConfig{Enabled: true}
|
||||
if meta.SupportsStaleDays {
|
||||
node.StaleDays = DefaultStaleDays
|
||||
}
|
||||
nodes[meta.Code] = node
|
||||
}
|
||||
return Config{Enabled: false, Nodes: nodes}
|
||||
}
|
||||
|
||||
// DefaultConfigValue 返回默认配置的 JSON 文本。
|
||||
func DefaultConfigValue() string {
|
||||
raw, err := MarshalConfig(DefaultConfig())
|
||||
if err != nil {
|
||||
return `{"enabled":false,"nodes":{}}`
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// ParseConfigValue 解析系统配置里的 JSON 文本,缺失字段回落到默认值。
|
||||
func ParseConfigValue(value string) (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
var raw struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Nodes map[string]NodeConfig `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(value), &raw); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if raw.Enabled != nil {
|
||||
cfg.Enabled = *raw.Enabled
|
||||
}
|
||||
if raw.Nodes != nil {
|
||||
// 以已登记节点为基准合并,避免后台配置里出现未知节点。
|
||||
for code, node := range raw.Nodes {
|
||||
if !IsKnownNode(code) {
|
||||
continue
|
||||
}
|
||||
node.TemplateCode = strings.TrimSpace(node.TemplateCode)
|
||||
if node.StaleDays < 0 {
|
||||
node.StaleDays = 0
|
||||
}
|
||||
cfg.Nodes[code] = node
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// MarshalConfig 规范化并序列化配置。
|
||||
func MarshalConfig(cfg Config) (string, error) {
|
||||
normalized := DefaultConfig()
|
||||
normalized.Enabled = cfg.Enabled
|
||||
for code, node := range cfg.Nodes {
|
||||
if !IsKnownNode(code) {
|
||||
continue
|
||||
}
|
||||
node.TemplateCode = strings.TrimSpace(node.TemplateCode)
|
||||
if node.StaleDays < 0 {
|
||||
node.StaleDays = 0
|
||||
}
|
||||
normalized.Nodes[code] = node
|
||||
}
|
||||
raw, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// Node 返回指定节点的配置。
|
||||
func (c Config) Node(code string) (NodeConfig, bool) {
|
||||
node, ok := c.Nodes[code]
|
||||
return node, ok
|
||||
}
|
||||
|
||||
// NodeActive 判断节点是否可发送:总开关、节点开关与模板 CODE 都必须就绪。
|
||||
func (c Config) NodeActive(code string) bool {
|
||||
if !c.Enabled {
|
||||
return false
|
||||
}
|
||||
node, ok := c.Node(code)
|
||||
if !ok || !node.Enabled {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(node.TemplateCode) != ""
|
||||
}
|
||||
|
||||
// StaleDaysFor 返回上架未成交提醒的天数阈值,未配置时取默认值。
|
||||
func (c Config) StaleDaysFor(code string) int {
|
||||
node, ok := c.Node(code)
|
||||
if !ok || node.StaleDays <= 0 {
|
||||
return DefaultStaleDays
|
||||
}
|
||||
return node.StaleDays
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfigCoversRegisteredNodes(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.Enabled {
|
||||
t.Fatal("默认配置的总开关必须关闭,避免未配置模板就产生短信费用")
|
||||
}
|
||||
for _, meta := range NodeMetas() {
|
||||
node, ok := cfg.Node(meta.Code)
|
||||
if !ok {
|
||||
t.Fatalf("默认配置缺少节点 %s", meta.Code)
|
||||
}
|
||||
if !node.Enabled {
|
||||
t.Fatalf("默认配置节点 %s 应默认启用", meta.Code)
|
||||
}
|
||||
if node.TemplateCode != "" {
|
||||
t.Fatalf("默认配置节点 %s 的模板 CODE 应为空,实际 %q", meta.Code, node.TemplateCode)
|
||||
}
|
||||
}
|
||||
if days := cfg.StaleDaysFor(NodeListingStalePriceSuggestion); days != DefaultStaleDays {
|
||||
t.Fatalf("StaleDaysFor() = %d, want %d", days, DefaultStaleDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigValueMergesAndFiltersUnknownNodes(t *testing.T) {
|
||||
cfg, err := ParseConfigValue(`{
|
||||
"enabled": true,
|
||||
"nodes": {
|
||||
"order_handoff_assist": {"enabled": true, "template_code": " SMS_512540945 "},
|
||||
"listing_stale_price_suggestion": {"enabled": false, "template_code": "SMS_1", "stale_days": 5},
|
||||
"not_registered_node": {"enabled": true, "template_code": "SMS_BAD"}
|
||||
}
|
||||
}`)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfigValue() error = %v", err)
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
t.Fatal("ParseConfigValue() 应保留总开关")
|
||||
}
|
||||
if _, ok := cfg.Node("not_registered_node"); ok {
|
||||
t.Fatal("ParseConfigValue() 不应保留未登记节点")
|
||||
}
|
||||
node, ok := cfg.Node(NodeOrderHandoffAssist)
|
||||
if !ok || node.TemplateCode != "SMS_512540945" {
|
||||
t.Fatalf("handoff node = %+v, want template trimmed to SMS_512540945", node)
|
||||
}
|
||||
// 未在 JSON 中出现的已登记节点应保留默认值。
|
||||
rejected, ok := cfg.Node(NodeListingReviewRejected)
|
||||
if !ok || !rejected.Enabled {
|
||||
t.Fatalf("未配置节点应保留默认值, got %+v ok=%v", rejected, ok)
|
||||
}
|
||||
stale, _ := cfg.Node(NodeListingStalePriceSuggestion)
|
||||
if stale.StaleDays != 5 {
|
||||
t.Fatalf("stale_days = %d, want 5", stale.StaleDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigValueEmptyAndInvalid(t *testing.T) {
|
||||
cfg, err := ParseConfigValue(" ")
|
||||
if err != nil {
|
||||
t.Fatalf("空配置不应报错: %v", err)
|
||||
}
|
||||
if cfg.Enabled {
|
||||
t.Fatal("空配置应回落到默认(总开关关闭)")
|
||||
}
|
||||
|
||||
if _, err := ParseConfigValue("{not json}"); err == nil {
|
||||
t.Fatal("非法 JSON 应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalConfigNormalizes(t *testing.T) {
|
||||
raw, err := MarshalConfig(Config{
|
||||
Enabled: true,
|
||||
Nodes: map[string]NodeConfig{
|
||||
NodeListingReviewRejected: {Enabled: true, TemplateCode: " SMS_512520993 ", StaleDays: -3},
|
||||
"unknown_node": {Enabled: true, TemplateCode: "SMS_X"},
|
||||
NodeListingStalePriceSuggestion: {Enabled: true, TemplateCode: "SMS_512620985", StaleDays: 0},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalConfig() error = %v", err)
|
||||
}
|
||||
if strings.Contains(raw, "unknown_node") {
|
||||
t.Fatalf("MarshalConfig() 不应输出未登记节点: %s", raw)
|
||||
}
|
||||
parsed, err := ParseConfigValue(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfigValue() error = %v", err)
|
||||
}
|
||||
rejected, _ := parsed.Node(NodeListingReviewRejected)
|
||||
if rejected.TemplateCode != "SMS_512520993" {
|
||||
t.Fatalf("template_code = %q, want SMS_512520993", rejected.TemplateCode)
|
||||
}
|
||||
if rejected.StaleDays != 0 {
|
||||
t.Fatalf("负的 stale_days 应被归零, got %d", rejected.StaleDays)
|
||||
}
|
||||
if days := parsed.StaleDaysFor(NodeListingStalePriceSuggestion); days != DefaultStaleDays {
|
||||
t.Fatalf("stale_days=0 时应回落默认值 %d, got %d", DefaultStaleDays, days)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigNodeActive(t *testing.T) {
|
||||
base := Config{
|
||||
Enabled: true,
|
||||
Nodes: map[string]NodeConfig{
|
||||
NodeListingReviewRejected: {Enabled: true, TemplateCode: "SMS_512520993"},
|
||||
NodeOrderHandoffAssist: {Enabled: false, TemplateCode: "SMS_512540945"},
|
||||
NodeListingStalePriceSuggestion: {Enabled: true, TemplateCode: ""},
|
||||
},
|
||||
}
|
||||
if !base.NodeActive(NodeListingReviewRejected) {
|
||||
t.Fatal("总开关与节点开关都开且模板非空时应可发送")
|
||||
}
|
||||
if base.NodeActive(NodeOrderHandoffAssist) {
|
||||
t.Fatal("节点关闭时不应发送")
|
||||
}
|
||||
if base.NodeActive(NodeListingStalePriceSuggestion) {
|
||||
t.Fatal("模板 CODE 为空时不应发送")
|
||||
}
|
||||
if base.NodeActive("unknown_node") {
|
||||
t.Fatal("未登记节点不应发送")
|
||||
}
|
||||
|
||||
disabled := base
|
||||
disabled.Enabled = false
|
||||
if disabled.NodeActive(NodeListingReviewRejected) {
|
||||
t.Fatal("总开关关闭时不应发送")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsKnownNode(t *testing.T) {
|
||||
for _, code := range []string{
|
||||
NodeListingReviewRejected,
|
||||
NodeOrderHandoffAssist,
|
||||
NodeListingStalePriceSuggestion,
|
||||
} {
|
||||
if !IsKnownNode(code) {
|
||||
t.Fatalf("IsKnownNode(%q) = false, want true", code)
|
||||
}
|
||||
}
|
||||
if IsKnownNode("") || IsKnownNode("nope") {
|
||||
t.Fatal("未登记节点应为 false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package sms
|
||||
|
||||
// 节点标识:与系统配置 sms.business_templates 中 nodes 的 key 一一对应,
|
||||
// 也与分销站点短信配置 business_templates 的 key 一一对应。
|
||||
const (
|
||||
// NodeListingReviewRejected 账号审核未通过,提醒号主修改后重新上架。
|
||||
NodeListingReviewRejected = "listing_review_rejected"
|
||||
// NodeOrderHandoffAssist 账号被租用需要号主辅助登录,提醒号主及时处理。
|
||||
NodeOrderHandoffAssist = "order_handoff_assist"
|
||||
// NodeListingStalePriceSuggestion 账号上架多日仍未成交,建议号主调整比例。
|
||||
NodeListingStalePriceSuggestion = "listing_stale_price_suggestion"
|
||||
)
|
||||
|
||||
// NodeMeta 描述一个节点短信,用于后台配置页展示与文案预览。
|
||||
type NodeMeta struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Recipient string `json:"recipient"`
|
||||
Description string `json:"description"`
|
||||
// TemplateCode 是该节点在阿里云报备的模板 CODE,
|
||||
// 这里只是后台首次配置时的参考值,真实值以系统配置/站点配置为准。
|
||||
TemplateCode string `json:"template_code"`
|
||||
// Sample 是报备模板的参考文案,仅供后台预览;真实发送以供应商模板为准。
|
||||
Sample string `json:"sample"`
|
||||
// SupportsStaleDays 标记该节点是否需要「上架天数」阈值(仅闲置降价提醒使用)。
|
||||
SupportsStaleDays bool `json:"supports_stale_days"`
|
||||
}
|
||||
|
||||
var nodeMetas = []NodeMeta{
|
||||
{
|
||||
Code: NodeListingReviewRejected,
|
||||
Name: "账号审核未通过",
|
||||
Recipient: "号主",
|
||||
Description: "号主上架的账号被后台审核拒绝时,提醒登录网站修改后重新上架。",
|
||||
TemplateCode: "SMS_512520993",
|
||||
Sample: "板板,您在我们商行上架的账号审核未通过,请登录网站进行修改重新上架",
|
||||
},
|
||||
{
|
||||
Code: NodeOrderHandoffAssist,
|
||||
Name: "账号被租用需辅助登录",
|
||||
Recipient: "号主",
|
||||
Description: "订单支付成功进入待交接后,提醒号主及时辅助登录,避免订单被强制结束。",
|
||||
TemplateCode: "SMS_512540945",
|
||||
Sample: "板板,您的订单已有人下单,下单方需要辅助登录,请及时留意平台信息!长时间未辅助平台会强制结束订单",
|
||||
},
|
||||
{
|
||||
Code: NodeListingStalePriceSuggestion,
|
||||
Name: "上架多日未成交降价建议",
|
||||
Recipient: "号主",
|
||||
Description: "账号上架满指定天数仍未被租用,建议号主适当调整比例,加快出租。",
|
||||
TemplateCode: "SMS_512620985",
|
||||
Sample: "板板,您的三角洲账号上架三天还未成功出售,建议您比例适当让一点,比例越高,出租越快、匹配越顺。具体调多少按自己心理价位定",
|
||||
SupportsStaleDays: true,
|
||||
},
|
||||
}
|
||||
|
||||
// NodeMetas 返回全部节点短信定义(副本,调用方不可修改内部数据)。
|
||||
func NodeMetas() []NodeMeta {
|
||||
out := make([]NodeMeta, len(nodeMetas))
|
||||
copy(out, nodeMetas)
|
||||
return out
|
||||
}
|
||||
|
||||
// NodeMetaOf 返回指定节点的定义。
|
||||
func NodeMetaOf(code string) (NodeMeta, bool) {
|
||||
for _, meta := range nodeMetas {
|
||||
if meta.Code == code {
|
||||
return meta, true
|
||||
}
|
||||
}
|
||||
return NodeMeta{}, false
|
||||
}
|
||||
|
||||
// IsKnownNode 判断节点标识是否在已登记的节点清单内。
|
||||
func IsKnownNode(code string) bool {
|
||||
_, ok := NodeMetaOf(code)
|
||||
return ok
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newSmsTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db := database.NewTestDB()
|
||||
if err := database.AutoMigrateIfMissing(db, &model.SmsMessage{}); err != nil {
|
||||
t.Fatalf("AutoMigrate() error = %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestEnqueueResolvesPhoneAndDeduplicates(t *testing.T) {
|
||||
db := newSmsTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user := model.User{Phone: "13800001111", Nickname: "号主"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
UserID: user.ID,
|
||||
Node: NodeListingReviewRejected,
|
||||
BizType: "listing",
|
||||
BizID: 9001,
|
||||
}
|
||||
if inserted, err := Enqueue(db.WithContext(ctx), entry); err != nil {
|
||||
t.Fatalf("Enqueue() error = %v", err)
|
||||
} else if !inserted {
|
||||
t.Fatal("Enqueue() 首次入队应插入新记录")
|
||||
}
|
||||
// 同一业务对象重复入队应被唯一索引静默忽略。
|
||||
if inserted, err := Enqueue(db.WithContext(ctx), entry); err != nil {
|
||||
t.Fatalf("Enqueue() 重复调用 error = %v", err)
|
||||
} else if inserted {
|
||||
t.Fatal("Enqueue() 重复入队不应插入新记录")
|
||||
}
|
||||
|
||||
var rows []model.SmsMessage
|
||||
if err := db.Where("node = ? AND biz_id = ?", NodeListingReviewRejected, 9001).Find(&rows).Error; err != nil {
|
||||
t.Fatalf("查询短信记录失败: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("记录数 = %d, want 1(唯一索引应保证幂等)", len(rows))
|
||||
}
|
||||
if rows[0].Phone != user.Phone {
|
||||
t.Fatalf("phone = %q, want %q(应从用户表补齐手机号)", rows[0].Phone, user.Phone)
|
||||
}
|
||||
if rows[0].Status != model.SmsStatusPending {
|
||||
t.Fatalf("status = %q, want %q", rows[0].Status, model.SmsStatusPending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueIgnoresUnknownNodeAndMissingUser(t *testing.T) {
|
||||
db := newSmsTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := Enqueue(db.WithContext(ctx), Entry{UserID: 1, Node: "not_registered", BizType: "listing", BizID: 1}); err != nil {
|
||||
t.Fatalf("未登记节点不应报错: %v", err)
|
||||
}
|
||||
if _, err := Enqueue(db.WithContext(ctx), Entry{UserID: 0, Node: NodeOrderHandoffAssist, BizType: "order", BizID: 1}); err != nil {
|
||||
t.Fatalf("缺少用户不应报错: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&model.SmsMessage{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("统计失败: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("记录数 = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryStatusTransitions(t *testing.T) {
|
||||
db := newSmsTestDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewRepository(db)
|
||||
|
||||
row := model.SmsMessage{
|
||||
UserID: 11,
|
||||
Phone: "13800002222",
|
||||
Node: NodeOrderHandoffAssist,
|
||||
BizType: "order",
|
||||
BizID: 7001,
|
||||
Status: model.SmsStatusPending,
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("创建记录失败: %v", err)
|
||||
}
|
||||
|
||||
// 首次拉取应命中,重试退避窗口内的记录应被排除。
|
||||
pending, err := repo.ListPending(ctx, 10, time.Now().Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("ListPending() error = %v", err)
|
||||
}
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("ListPending() = %d 条, want 1", len(pending))
|
||||
}
|
||||
|
||||
if err := repo.MarkRetry(ctx, row.ID, 1, "渠道限流"); err != nil {
|
||||
t.Fatalf("MarkRetry() error = %v", err)
|
||||
}
|
||||
var retried model.SmsMessage
|
||||
if err := db.First(&retried, row.ID).Error; err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if retried.Status != model.SmsStatusPending || retried.RetryCount != 1 || retried.LastError != "渠道限流" {
|
||||
t.Fatalf("重试后记录 = %+v", retried)
|
||||
}
|
||||
// 退避窗口内不应再次被拉取。
|
||||
pending, err = repo.ListPending(ctx, 10, time.Now().Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("ListPending() error = %v", err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("退避窗口内 ListPending() = %d 条, want 0", len(pending))
|
||||
}
|
||||
// 退避窗口外应重新进入待发送队列。
|
||||
pending, err = repo.ListPending(ctx, 10, time.Now().Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("ListPending() error = %v", err)
|
||||
}
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("退避窗口外 ListPending() = %d 条, want 1", len(pending))
|
||||
}
|
||||
|
||||
sentAt := time.Now()
|
||||
if err := repo.MarkSent(ctx, row.ID, "SMS_512540945", "大锤商行", sentAt); err != nil {
|
||||
t.Fatalf("MarkSent() error = %v", err)
|
||||
}
|
||||
var sent model.SmsMessage
|
||||
if err := db.First(&sent, row.ID).Error; err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if sent.Status != model.SmsStatusSent || sent.TemplateCode != "SMS_512540945" || sent.SignName != "大锤商行" || sent.SentAt == nil {
|
||||
t.Fatalf("发送成功后记录 = %+v", sent)
|
||||
}
|
||||
|
||||
if err := repo.MarkSkipped(ctx, row.ID, "节点短信总开关未开启"); err != nil {
|
||||
t.Fatalf("MarkSkipped() error = %v", err)
|
||||
}
|
||||
var skipped model.SmsMessage
|
||||
if err := db.First(&skipped, row.ID).Error; err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if skipped.Status != model.SmsStatusSkipped || skipped.SkipReason != "节点短信总开关未开启" {
|
||||
t.Fatalf("跳过记录 = %+v", skipped)
|
||||
}
|
||||
|
||||
if err := repo.MarkFailed(ctx, row.ID, 3, "渠道异常"); err != nil {
|
||||
t.Fatalf("MarkFailed() error = %v", err)
|
||||
}
|
||||
var failed model.SmsMessage
|
||||
if err := db.First(&failed, row.ID).Error; err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if failed.Status != model.SmsStatusFailed || failed.RetryCount != 3 {
|
||||
t.Fatalf("失败记录 = %+v", failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueKeepsSiteID(t *testing.T) {
|
||||
db := newSmsTestDB(t)
|
||||
ctx := context.Background()
|
||||
siteID := uint64(77)
|
||||
|
||||
user := model.User{Phone: "13800003333"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
if _, err := Enqueue(db.WithContext(ctx), Entry{
|
||||
UserID: user.ID,
|
||||
Node: NodeListingStalePriceSuggestion,
|
||||
BizType: "listing",
|
||||
BizID: 5001,
|
||||
DistributionSiteID: &siteID,
|
||||
}); err != nil {
|
||||
t.Fatalf("Enqueue() error = %v", err)
|
||||
}
|
||||
|
||||
var row model.SmsMessage
|
||||
if err := db.Where("biz_id = ?", 5001).First(&row).Error; err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if row.DistributionSiteID == nil || *row.DistributionSiteID != siteID {
|
||||
t.Fatalf("distribution_site_id = %v, want %d", row.DistributionSiteID, siteID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user