Files
iotlcm/admin-svr/server/internal/logic/simwarning/monthly_expiry.go
T

320 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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), &params); 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 // 已投递;若标记失败,上层分别记录成功投递与去重错误。
}