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