sync: 同步 v1.0.4 并脱敏配置与镜像

This commit is contained in:
MiFaZhan
2026-10-29 14:36:49 +08:00
parent 743e8e6e6e
commit 2206a393f7
516 changed files with 18119 additions and 2253 deletions
@@ -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), &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 // 已投递;若标记失败,上层分别记录成功投递与去重错误。
}