sync: 同步 v1.0.4 并脱敏配置与镜像
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
package simwarning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/smsgateway"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const (
|
||||
monthlyExpiryPageSize = 100
|
||||
monthlyExpiryCurrentSMSCode = "SMS_512596191"
|
||||
monthlyExpiryNextSMSCode = "SMS_512646156"
|
||||
monthlyExpirySMSSignName = "示例企业"
|
||||
monthlyExpirySentTTL = 93 * 24 * time.Hour
|
||||
)
|
||||
|
||||
var monthlyExpiryLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
type monthlyExpiryCompany struct {
|
||||
DeptID int64 `orm:"dept_id"`
|
||||
TenantID string `orm:"tenant_id"`
|
||||
LeaderID int64 `orm:"leader"`
|
||||
}
|
||||
|
||||
type monthlyExpirySummary struct {
|
||||
Month string `orm:"expiry_month"`
|
||||
Count int64 `orm:"card_count"`
|
||||
FirstDate string `orm:"first_date"`
|
||||
LastDate string `orm:"last_date"`
|
||||
}
|
||||
|
||||
type monthlyExpiryLeader struct {
|
||||
UserID int64 `orm:"user_id"`
|
||||
Phone string `orm:"phonenumber"`
|
||||
}
|
||||
|
||||
type monthlyExpiryNotice struct {
|
||||
Company monthlyExpiryCompany
|
||||
Summary monthlyExpirySummary
|
||||
Leader monthlyExpiryLeader
|
||||
RunTime time.Time
|
||||
}
|
||||
|
||||
type monthlyExpiryStats struct {
|
||||
Companies, Groups, Sent, Duplicate, Errors int
|
||||
}
|
||||
|
||||
// 回调隔离查询和投递,使分页及部分失败可以在不连库、不发送消息的情况下测试。
|
||||
type monthlyExpiryRunner struct {
|
||||
companies func(context.Context, int64) ([]monthlyExpiryCompany, error)
|
||||
summaries func(context.Context, monthlyExpiryCompany, time.Time, time.Time) ([]monthlyExpirySummary, error)
|
||||
leader func(context.Context, monthlyExpiryCompany) (*monthlyExpiryLeader, error)
|
||||
deliver func(context.Context, monthlyExpiryNotice, string) (bool, error)
|
||||
}
|
||||
|
||||
// NotifyMonthlyExpiry 由独立的 NotifyMonthlyPackageExpiry 任务在每月15日09:00调用。
|
||||
// 实时读取 flow_pkg_end_at(包括已办理的未来续费),不依赖 Redis 逐卡候选。
|
||||
func (s *sSimWarning) NotifyMonthlyExpiry(ctx context.Context) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
runTime := time.Now().In(monthlyExpiryLocation)
|
||||
// 企业顺序发送,短信最多每秒5次,避免企业多时集中冲击网关。
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
runner := monthlyExpiryRunner{
|
||||
companies: queryMonthlyExpiryCompanies,
|
||||
summaries: queryMonthlyExpirySummaries,
|
||||
leader: queryMonthlyExpiryLeader,
|
||||
deliver: func(ctx context.Context, notice monthlyExpiryNotice, channel string) (bool, error) {
|
||||
return deliverMonthlyExpiry(ctx, notice, channel, func() error {
|
||||
if channel == "platform" {
|
||||
return writeMonthlyExpiryMessage(ctx, notice)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
return sendMonthlyExpirySMS(ctx, notice)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
stats, err := runner.run(ctx, runTime)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "SimWarning.MonthlyExpiry interrupted, err:%+v", err)
|
||||
}
|
||||
g.Log().Infof(ctx, "SimWarning.MonthlyExpiry end, runMonth:%s, companies:%d, groups:%d, sent:%d, duplicate:%d, errors:%d",
|
||||
runTime.Format("2006-01"), stats.Companies, stats.Groups, stats.Sent, stats.Duplicate, stats.Errors)
|
||||
}
|
||||
|
||||
func (r monthlyExpiryRunner) run(ctx context.Context, runTime time.Time) (monthlyExpiryStats, error) {
|
||||
stats := monthlyExpiryStats{}
|
||||
runTime = runTime.In(monthlyExpiryLocation)
|
||||
begin := calendarMonthBegin(runTime)
|
||||
end := begin.AddDate(0, 2, 0)
|
||||
lastID := int64(0)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
companies, err := r.companies(ctx, lastID)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
if len(companies) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
for _, company := range companies {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
lastID = company.DeptID
|
||||
stats.Companies++
|
||||
summaries, err := r.summaries(ctx, company, begin, end)
|
||||
if err != nil {
|
||||
stats.Errors++
|
||||
g.Log().Errorf(ctx, "SimWarning.MonthlyExpiry count failed, tenant:%s, dept:%d, err:%+v", company.TenantID, company.DeptID, err)
|
||||
continue
|
||||
}
|
||||
if len(summaries) == 0 {
|
||||
continue
|
||||
}
|
||||
leader, err := r.leader(ctx, company)
|
||||
if err != nil || leader == nil || leader.UserID <= 0 {
|
||||
stats.Errors++
|
||||
g.Log().Warningf(ctx, "SimWarning.MonthlyExpiry leader unavailable, tenant:%s, dept:%d, leader:%d, err:%+v", company.TenantID, company.DeptID, company.LeaderID, err)
|
||||
continue
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
if summary.Count <= 0 {
|
||||
continue
|
||||
}
|
||||
stats.Groups++
|
||||
notice := monthlyExpiryNotice{Company: company, Summary: summary, Leader: *leader, RunTime: runTime}
|
||||
for _, channel := range []string{"platform", "sms"} {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
sent, err := r.deliver(ctx, notice, channel)
|
||||
if err != nil {
|
||||
stats.Errors++
|
||||
if sent {
|
||||
stats.Sent++
|
||||
g.Log().Errorf(ctx, "SimWarning.MonthlyExpiry sent but dedupe mark failed, tenant:%s, dept:%d, month:%s, channel:%s, err:%+v", company.TenantID, company.DeptID, summary.Month, channel, err)
|
||||
} else {
|
||||
g.Log().Errorf(ctx, "SimWarning.MonthlyExpiry delivery failed, tenant:%s, dept:%d, month:%s, channel:%s, err:%+v", company.TenantID, company.DeptID, summary.Month, channel, err)
|
||||
}
|
||||
} else if sent {
|
||||
stats.Sent++
|
||||
g.Log().Infof(ctx, "SimWarning.MonthlyExpiry sent, tenant:%s, dept:%d, month:%s, channel:%s", company.TenantID, company.DeptID, summary.Month, channel)
|
||||
} else {
|
||||
stats.Duplicate++
|
||||
g.Log().Infof(ctx, "SimWarning.MonthlyExpiry duplicate, tenant:%s, dept:%d, month:%s, channel:%s", company.TenantID, company.DeptID, summary.Month, channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(companies) < monthlyExpiryPageSize {
|
||||
return stats, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func queryMonthlyExpiryCompanies(ctx context.Context, lastID int64) ([]monthlyExpiryCompany, error) {
|
||||
var rows []monthlyExpiryCompany
|
||||
err := dao.SysDept.Ctx(ctx).
|
||||
Fields("dept_id, tenant_id, leader").
|
||||
WhereGT("dept_id", lastID).
|
||||
Where("dept_type", 1).
|
||||
Where("status", consts.SysDeptStatusNormal).
|
||||
WhereNull("deleted_at").
|
||||
OrderAsc("dept_id").Limit(monthlyExpiryPageSize).Scan(&rows)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func queryMonthlyExpirySummaries(ctx context.Context, company monthlyExpiryCompany, begin, end time.Time) ([]monthlyExpirySummary, error) {
|
||||
var rows []monthlyExpirySummary
|
||||
// 按当前归属企业计数,不加载卡明细,不加旧规则的卡状态/套餐状态/池卡限制。
|
||||
// 时间条件保留原列以使用已有 dept_id / flow_pkg_end_at 索引。
|
||||
err := dao.TSimCardInfo.Ctx(ctx).
|
||||
Fields("DATE_FORMAT(flow_pkg_end_at, '%Y-%m') AS expiry_month, COUNT(*) AS card_count, DATE_FORMAT(MIN(flow_pkg_end_at), '%Y-%m-%d') AS first_date, DATE_FORMAT(MAX(flow_pkg_end_at), '%Y-%m-%d') AS last_date").
|
||||
Where("tenant_id", company.TenantID).Where("dept_id", company.DeptID).
|
||||
WhereGTE("flow_pkg_end_at", begin.Format("2006-01-02 15:04:05")).
|
||||
WhereLT("flow_pkg_end_at", end.Format("2006-01-02 15:04:05")).
|
||||
Group("expiry_month").OrderAsc("expiry_month").Scan(&rows)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func queryMonthlyExpiryLeader(ctx context.Context, company monthlyExpiryCompany) (*monthlyExpiryLeader, error) {
|
||||
if company.LeaderID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var row *monthlyExpiryLeader
|
||||
err := dao.SysUser.Ctx(ctx).Fields("user_id, phonenumber").
|
||||
Where("tenant_id", company.TenantID).Where("user_id", company.LeaderID).
|
||||
Where("user_type", consts.SysUserTypeSys).Where("status", consts.SysDeptStatusNormal).
|
||||
WhereNull("deleted_at").Scan(&row)
|
||||
return row, err
|
||||
}
|
||||
|
||||
func (n monthlyExpiryNotice) nextMonth() bool {
|
||||
return n.Summary.Month == calendarMonthBegin(n.RunTime.In(monthlyExpiryLocation)).AddDate(0, 1, 0).Format("2006-01")
|
||||
}
|
||||
|
||||
func (n monthlyExpiryNotice) expiryDateText() string {
|
||||
if n.Summary.FirstDate == n.Summary.LastDate {
|
||||
return n.Summary.FirstDate
|
||||
}
|
||||
return n.Summary.FirstDate + "至" + n.Summary.LastDate
|
||||
}
|
||||
|
||||
func (n monthlyExpiryNotice) content() string {
|
||||
deadline, stopText := "本月", "本月底"
|
||||
if n.nextMonth() {
|
||||
deadline, stopText = "次月", ""
|
||||
}
|
||||
return fmt.Sprintf("尊敬的客户:\n您好!您名下共有【%d】张物联网卡将于【%s】套餐到期,请及时办理续费,避免业务中断。\n请最晚于%s25日前完成续费,续费后卡片可正常保持通信使用。若套餐到期未续费,%s卡片将执行停机,后续会做销户处理,销户后卡片将无法恢复使用,请您知悉。\n如有疑问,可联系您的销售经理咨询。", n.Summary.Count, n.expiryDateText(), deadline, stopText)
|
||||
}
|
||||
|
||||
func writeMonthlyExpiryMessage(ctx context.Context, n monthlyExpiryNotice) error {
|
||||
messageCtx := contexts.Set(ctx, &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{
|
||||
TenantId: n.Company.TenantID, DeptId: n.Company.DeptID,
|
||||
}}})
|
||||
return service.CommonTMessageLog().Edit(messageCtx, monthlyExpiryMessageParam(n))
|
||||
}
|
||||
|
||||
func monthlyExpiryMessageParam(n monthlyExpiryNotice) *model.TMessageLogEditParam {
|
||||
messageType, sendUserType, isRecall := uint(1), uint(1), uint(0)
|
||||
title, content, link := "到期提醒", n.content(), ""
|
||||
return &model.TMessageLogEditParam{
|
||||
MessageType: &messageType, SendUserType: &sendUserType,
|
||||
UserIds: gjson.New([]int64{n.Leader.UserID}),
|
||||
MessageTitle: &title, MessageContent: &content, MessageLink: &link, IsRecall: &isRecall,
|
||||
}
|
||||
}
|
||||
|
||||
func sendMonthlyExpirySMS(ctx context.Context, n monthlyExpiryNotice) error {
|
||||
phone := strings.TrimSpace(n.Leader.Phone)
|
||||
if phone == "" {
|
||||
return fmt.Errorf("企业负责人未设置手机号")
|
||||
}
|
||||
code, key := monthlyExpiryCurrentSMSCode, "warning.expire.monthly.currentMonthParamsTemplate"
|
||||
if n.nextMonth() {
|
||||
code, key = monthlyExpiryNextSMSCode, "warning.expire.monthly.nextMonthParamsTemplate"
|
||||
}
|
||||
value, err := g.Cfg().Get(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := renderMonthlyExpirySMSParams(value.String(), n.Summary.Count, n.expiryDateText())
|
||||
if err != nil {
|
||||
return fmt.Errorf("短信模板%s参数未就绪(配置%s): %w", code, key, err)
|
||||
}
|
||||
// 模板和签名均已核实,供应商凭证由短信服务持有;不改通用流量预警模板。
|
||||
sent, err := smsgateway.Send(ctx, smsgateway.Request{
|
||||
TemplateID: code, SignName: monthlyExpirySMSSignName, PhoneNumber: phone, MsgContent: content,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !sent {
|
||||
return fmt.Errorf("短信网关未启用或未配置地址")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 核实供应商变量名后配置 JSON 字符串对象,值中使用 {count} 和 {expireDate}。
|
||||
// 未核实前保持配置为空,禁止猜变量名或用验证码模板替代。
|
||||
func renderMonthlyExpirySMSParams(template string, count int64, expiryDate string) (string, error) {
|
||||
if strings.TrimSpace(template) == "" {
|
||||
return "", fmt.Errorf("请先核实已审核模板变量并配置参数模板")
|
||||
}
|
||||
var params map[string]string
|
||||
if err := json.Unmarshal([]byte(template), ¶ms); err != nil || len(params) == 0 {
|
||||
return "", fmt.Errorf("参数模板必须为非空 JSON 字符串对象")
|
||||
}
|
||||
replacer := strings.NewReplacer("{count}", strconv.FormatInt(count, 10), "{expireDate}", expiryDate)
|
||||
for key, value := range params {
|
||||
params[key] = replacer.Replace(value)
|
||||
}
|
||||
encoded, err := json.Marshal(params)
|
||||
return string(encoded), err
|
||||
}
|
||||
|
||||
func monthlyExpirySentKey(n monthlyExpiryNotice, channel string) string {
|
||||
return fmt.Sprintf("iotlcm:monthly-expiry:%s:%s:%d:%s:%s", n.RunTime.In(monthlyExpiryLocation).Format("200601"), n.Company.TenantID, n.Company.DeptID, n.Summary.Month, channel)
|
||||
}
|
||||
|
||||
func deliverMonthlyExpiry(ctx context.Context, n monthlyExpiryNotice, channel string, send func() error) (bool, error) {
|
||||
key := monthlyExpirySentKey(n, channel)
|
||||
value, err := g.Redis().Do(ctx, "GET", key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if value.String() == "sent" {
|
||||
return false, nil
|
||||
}
|
||||
if err := send(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 外层任务锁串行化扫描;成功才记去重,两渠道互不阻断。
|
||||
// 外部网关和 Redis 无分布式事务,失败/未知结果须核对后手动重跑,不自动重试短信。
|
||||
_, err = g.Redis().Do(ctx, "SET", key, "sent", "EX", int(monthlyExpirySentTTL.Seconds()))
|
||||
return true, err // 已投递;若标记失败,上层分别记录成功投递与去重错误。
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package simwarning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
)
|
||||
|
||||
func TestMonthlyExpiryRunPagesAndPartialFailures(t *testing.T) {
|
||||
firstPage := make([]monthlyExpiryCompany, monthlyExpiryPageSize)
|
||||
for i := range firstPage {
|
||||
firstPage[i] = monthlyExpiryCompany{TenantID: "000000", DeptID: int64(i + 1), LeaderID: int64(i + 1001)}
|
||||
}
|
||||
calls := make(map[string]int)
|
||||
pages := make([]int64, 0, 2)
|
||||
runner := monthlyExpiryRunner{
|
||||
companies: func(_ context.Context, lastID int64) ([]monthlyExpiryCompany, error) {
|
||||
pages = append(pages, lastID)
|
||||
if lastID == 0 {
|
||||
return firstPage, nil
|
||||
}
|
||||
if lastID == int64(monthlyExpiryPageSize) {
|
||||
return []monthlyExpiryCompany{{TenantID: "000000", DeptID: 101, LeaderID: 1101}}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected cursor %d", lastID)
|
||||
},
|
||||
summaries: func(_ context.Context, company monthlyExpiryCompany, begin, end time.Time) ([]monthlyExpirySummary, error) {
|
||||
if begin.Format("2006-01-02") != "2026-12-01" || end.Format("2006-01-02") != "2027-02-01" {
|
||||
t.Errorf("wrong query window %s..%s", begin, end)
|
||||
}
|
||||
if company.DeptID == 2 {
|
||||
return nil, errors.New("count failed")
|
||||
}
|
||||
return []monthlyExpirySummary{{Month: "2026-12", Count: 3, FirstDate: "2026-12-01", LastDate: "2026-12-31"},
|
||||
{Month: "2027-01", Count: 6, FirstDate: "2027-01-31", LastDate: "2027-01-31"}}, nil
|
||||
},
|
||||
leader: func(_ context.Context, company monthlyExpiryCompany) (*monthlyExpiryLeader, error) {
|
||||
if company.DeptID == 3 {
|
||||
return nil, nil
|
||||
}
|
||||
return &monthlyExpiryLeader{UserID: company.LeaderID, Phone: "13800000000"}, nil
|
||||
},
|
||||
deliver: func(_ context.Context, notice monthlyExpiryNotice, channel string) (bool, error) {
|
||||
calls[notice.Summary.Month+"/"+channel]++
|
||||
if notice.Company.DeptID == 1 && channel == "sms" {
|
||||
return false, errors.New("sms failed")
|
||||
}
|
||||
if notice.Company.DeptID == 1 && notice.Summary.Month == "2027-01" && channel == "platform" {
|
||||
return false, nil
|
||||
}
|
||||
if notice.Company.DeptID == 4 && notice.Summary.Month == "2026-12" && channel == "platform" {
|
||||
return true, errors.New("dedupe mark failed after delivery")
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
}
|
||||
stats, err := runner.run(context.Background(), time.Date(2026, 12, 15, 9, 0, 0, 0, monthlyExpiryLocation))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Companies != 101 || stats.Groups != 198 || stats.Sent != 393 || stats.Duplicate != 1 || stats.Errors != 5 {
|
||||
t.Fatalf("unexpected stats: %+v", stats)
|
||||
}
|
||||
if len(pages) != 2 || pages[0] != 0 || pages[1] != 100 {
|
||||
t.Fatalf("wrong page cursors: %v", pages)
|
||||
}
|
||||
if calls["2026-12/platform"] != 99 || calls["2027-01/platform"] != 99 {
|
||||
t.Fatalf("wrong delivered group counts: %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthlyExpiryNoticeContentAndRecipient(t *testing.T) {
|
||||
base := monthlyExpiryNotice{
|
||||
Company: monthlyExpiryCompany{TenantID: "000000", DeptID: 117, LeaderID: 12},
|
||||
Leader: monthlyExpiryLeader{UserID: 12},
|
||||
RunTime: time.Date(2026, 10, 15, 9, 0, 0, 0, monthlyExpiryLocation),
|
||||
Summary: monthlyExpirySummary{Month: "2026-10", Count: 7, FirstDate: "2026-10-21", LastDate: "2026-10-31"},
|
||||
}
|
||||
current := base.content()
|
||||
for _, text := range []string{"【7】张", "2026-10-21至2026-10-31", "本月25日前", "本月底卡片将执行停机"} {
|
||||
if !strings.Contains(current, text) {
|
||||
t.Errorf("current notice missing %q: %s", text, current)
|
||||
}
|
||||
}
|
||||
param := monthlyExpiryMessageParam(base)
|
||||
if *param.MessageType != 1 || *param.SendUserType != 1 || *param.MessageTitle != "到期提醒" || !strings.Contains(param.UserIds.String(), "12") {
|
||||
t.Fatalf("wrong targeted system message: %+v", param)
|
||||
}
|
||||
base.Summary = monthlyExpirySummary{Month: "2026-11", Count: 9, FirstDate: "2026-11-30", LastDate: "2026-11-30"}
|
||||
if got := base.content(); !strings.Contains(got, "次月25日前") || strings.Contains(got, "本月底卡片") {
|
||||
t.Fatalf("next month notice incorrect: %s", got)
|
||||
}
|
||||
if base.expiryDateText() != "2026-11-30" {
|
||||
t.Errorf("single-day range not collapsed: %s", base.expiryDateText())
|
||||
}
|
||||
if key := monthlyExpirySentKey(base, "sms"); !strings.Contains(key, ":000000:117:2026-11:sms") {
|
||||
t.Errorf("missing dedupe dimensions: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthlyExpirySMSParamsRequireConfirmedTemplate(t *testing.T) {
|
||||
for _, invalid := range []string{"", "invalid", "{}", `{"count":7}`} {
|
||||
if _, err := renderMonthlyExpirySMSParams(invalid, 3, "2026-11-30"); err == nil {
|
||||
t.Errorf("unconfirmed/invalid template accepted: %q", invalid)
|
||||
}
|
||||
}
|
||||
content, err := renderMonthlyExpirySMSParams(`{"actualCount":"{count}","actualExpire":"{expireDate}"}`, 5, "2026-11-01至2026-11-30")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var params map[string]string
|
||||
if err := json.Unmarshal([]byte(content), ¶ms); err != nil || params["actualCount"] != "5" || params["actualExpire"] != "2026-11-01至2026-11-30" {
|
||||
t.Fatalf("incorrect template params: %s, %v", content, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthlyExpirySMSLocalGateway(t *testing.T) {
|
||||
// 所有请求只发到本机测试服务;清除环境覆盖,绝不读取真实网关配置。
|
||||
for _, name := range []string{"IOTLCM_SMS_GATEWAY_ENABLED", "IOTLCM_SMS_GATEWAY_URL", "IOTLCM_SMS_GATEWAY_GRPC_ADDR", "IOTLCM_SMS_GATEWAY_TIMEOUT", "IOTLCM_SMS_GATEWAY_ROUTER_ID"} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
requests := make([]map[string]map[string]string, 0, 3)
|
||||
fail := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
requests = append(requests, body)
|
||||
if fail {
|
||||
_, _ = w.Write([]byte(`{"code":502,"message":"supplier failure"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"OK"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
adapter, err := gcfg.NewAdapterContent()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := g.Cfg().GetAdapter()
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(original) })
|
||||
configure := func(enabled bool, template string) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(g.Map{
|
||||
"sms": g.Map{"gateway": g.Map{"enabled": enabled, "url": server.URL, "timeout": "1s"}},
|
||||
"warning": g.Map{"expire": g.Map{"monthly": g.Map{
|
||||
"currentMonthParamsTemplate": template, "nextMonthParamsTemplate": template,
|
||||
}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := adapter.SetContent(string(data)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
configure(true, `{"card_num":"{count}","expire_date":"{expireDate}"}`)
|
||||
notice := monthlyExpiryNotice{
|
||||
RunTime: time.Date(2026, 10, 15, 9, 0, 0, 0, monthlyExpiryLocation),
|
||||
Leader: monthlyExpiryLeader{UserID: 12, Phone: "13800000000"},
|
||||
Summary: monthlyExpirySummary{Month: "2026-10", Count: 7, FirstDate: "2026-10-31", LastDate: "2026-10-31"},
|
||||
}
|
||||
if err := sendMonthlyExpirySMS(context.Background(), notice); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notice.Summary = monthlyExpirySummary{Month: "2026-11", Count: 9, FirstDate: "2026-11-01", LastDate: "2026-11-30"}
|
||||
if err := sendMonthlyExpirySMS(context.Background(), notice); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(requests) != 2 || requests[0]["config"]["templateId"] != "SMS_512596191" || requests[1]["config"]["templateId"] != "SMS_512646156" {
|
||||
t.Fatalf("incorrect gateway templates: %+v", requests)
|
||||
}
|
||||
for _, request := range requests {
|
||||
if request["config"]["signName"] != "示例企业" {
|
||||
t.Fatalf("incorrect gateway sign: %+v", request)
|
||||
}
|
||||
}
|
||||
var params map[string]string
|
||||
if err := json.Unmarshal([]byte(requests[1]["params"]["msgContent"]), ¶ms); err != nil || params["card_num"] != "9" || params["expire_date"] != "2026-11-01至2026-11-30" {
|
||||
t.Fatalf("incorrect gateway params: %+v, %v", params, err)
|
||||
}
|
||||
fail = true
|
||||
if err := sendMonthlyExpirySMS(context.Background(), notice); err == nil {
|
||||
t.Fatal("gateway business failure must propagate")
|
||||
}
|
||||
configure(true, "")
|
||||
if err := sendMonthlyExpirySMS(context.Background(), notice); err == nil || len(requests) != 3 {
|
||||
t.Fatal("unconfirmed template must block before calling gateway")
|
||||
}
|
||||
configure(false, `{"card_num":"{count}","expire_date":"{expireDate}"}`)
|
||||
if err := sendMonthlyExpirySMS(context.Background(), notice); err == nil || len(requests) != 3 {
|
||||
t.Fatal("disabled gateway must not count as successful delivery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthlyExpiryCancellationStopsBeforeDelivery(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
runner := monthlyExpiryRunner{
|
||||
companies: func(context.Context, int64) ([]monthlyExpiryCompany, error) {
|
||||
t.Fatal("canceled run should not query")
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
_, err := runner.run(ctx, time.Now())
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected cancellation, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/warningnotify"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"strconv"
|
||||
@@ -46,16 +47,17 @@ func (s *sSimWarning) scan(ctx context.Context, scope scanScope) {
|
||||
scope = normalizeScanScope(scope)
|
||||
runTime := time.Now()
|
||||
stats := scanStats{}
|
||||
pendingWarnings := make([]pendingWarning, 0)
|
||||
collector := warningnotify.NewCollector()
|
||||
g.Log().Infof(ctx, "SimWarning.Scan begin, runTime:%s, scope:%s, mode:warn_log_only", runTime.Format("2006-01-02 15:04:05"), scope)
|
||||
|
||||
var lastID int64
|
||||
for page := 1; ; page++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
stats.ErrorCount++
|
||||
g.Log().Warningf(ctx, "SimWarning.Scan canceled, page:%d, scope:%s, err:%+v", page, scope, err)
|
||||
break
|
||||
}
|
||||
configs, err := s.queryConfigs(ctx, page, scope)
|
||||
configs, err := s.queryConfigs(ctx, lastID, scope)
|
||||
if err != nil {
|
||||
stats.ErrorCount++
|
||||
g.Log().Errorf(ctx, "SimWarning.Scan query configs failed, page:%d, err:%+v", page, err)
|
||||
@@ -64,6 +66,7 @@ func (s *sSimWarning) scan(ctx context.Context, scope scanScope) {
|
||||
if len(configs) == 0 {
|
||||
break
|
||||
}
|
||||
lastID = configs[len(configs)-1].Id
|
||||
stats.ConfigCount += len(configs)
|
||||
|
||||
cardMap, err := s.queryCards(ctx, configs)
|
||||
@@ -78,19 +81,30 @@ func (s *sSimWarning) scan(ctx context.Context, scope scanScope) {
|
||||
g.Log().Errorf(ctx, "SimWarning.Scan query flow pools failed, page:%d, err:%+v", page, err)
|
||||
break
|
||||
}
|
||||
pendingWarnings := make([]pendingWarning, 0, len(configs))
|
||||
for _, cfg := range configs {
|
||||
if pending := s.scanConfig(ctx, cfg, cardMap[itemKey(cfg.TenantId, cfg.ItemId)], poolMap[itemKey(cfg.TenantId, cfg.ItemId)], runTime, &stats); pending != nil {
|
||||
pendingWarnings = append(pendingWarnings, *pending)
|
||||
}
|
||||
}
|
||||
stored := s.storePendingWarningsWithoutNotify(ctx, pendingWarnings, runTime, &stats)
|
||||
events := make([]warningnotify.Event, 0, len(stored))
|
||||
for _, item := range stored {
|
||||
events = append(events, warningNotifyEvent(ctx, item, runTime))
|
||||
s.scanAutoAction(ctx, item.Hit, item.Record.WarnLogID, runTime, &stats)
|
||||
}
|
||||
if err := collector.Add(ctx, events); err != nil {
|
||||
stats.ErrorCount++
|
||||
g.Log().Errorf(ctx, "warning recipient aggregation failed: %+v", err)
|
||||
}
|
||||
if len(configs) < scanPageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
storedWarnings := s.storePendingWarnings(ctx, pendingWarnings, runTime, &stats)
|
||||
for _, stored := range storedWarnings {
|
||||
s.scanAutoAction(ctx, stored.Hit, stored.Record.WarnLogID, runTime, &stats)
|
||||
if err := collector.Flush(ctx); err != nil {
|
||||
stats.ErrorCount++
|
||||
g.Log().Errorf(ctx, "warning notification failed: %+v", err)
|
||||
}
|
||||
|
||||
g.Log().Infof(
|
||||
@@ -107,7 +121,7 @@ func (s *sSimWarning) scan(ctx context.Context, scope scanScope) {
|
||||
)
|
||||
}
|
||||
|
||||
func (s *sSimWarning) queryConfigs(ctx context.Context, page int, scope scanScope) ([]warnConfig, error) {
|
||||
func (s *sSimWarning) queryConfigs(ctx context.Context, lastID int64, scope scanScope) ([]warnConfig, error) {
|
||||
scope = normalizeScanScope(scope)
|
||||
columns := dao.TWarnConfig.Columns()
|
||||
mod := dao.TWarnConfig.Ctx(ctx).
|
||||
@@ -162,8 +176,8 @@ func (s *sSimWarning) queryConfigs(ctx context.Context, page int, scope scanScop
|
||||
}
|
||||
|
||||
entities := make([]entity.TWarnConfig, 0)
|
||||
err := mod.OrderAsc(columns.Id).
|
||||
Page(page, scanPageSize).
|
||||
err := mod.WhereGT(columns.Id, lastID).OrderAsc(columns.Id).
|
||||
Limit(scanPageSize).
|
||||
Scan(&entities)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -279,6 +293,12 @@ func (s *sSimWarning) scanFlowPoolConfig(ctx context.Context, cfg warnConfig, po
|
||||
}
|
||||
|
||||
func (s *sSimWarning) storePendingWarnings(ctx context.Context, pendingWarnings []pendingWarning, runTime time.Time, stats *scanStats) []storedWarning {
|
||||
stored := s.storePendingWarningsWithoutNotify(ctx, pendingWarnings, runTime, stats)
|
||||
notifyStoredWarnings(ctx, stored, runTime)
|
||||
return stored
|
||||
}
|
||||
|
||||
func (s *sSimWarning) storePendingWarningsWithoutNotify(ctx context.Context, pendingWarnings []pendingWarning, runTime time.Time, stats *scanStats) []storedWarning {
|
||||
if len(pendingWarnings) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -298,7 +318,6 @@ func (s *sSimWarning) storePendingWarnings(ctx context.Context, pendingWarnings
|
||||
for _, item := range storedWarnings {
|
||||
logWarningHit(ctx, item.Hit, item.Record)
|
||||
}
|
||||
notifyStoredWarnings(ctx, storedWarnings, runTime)
|
||||
return storedWarnings
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user