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 // 已投递;若标记失败,上层分别记录成功投递与去重错误。
|
||||
}
|
||||
Reference in New Issue
Block a user