chore: 并入 iotlcm-admin-svr 历史到 admin-svr/(原提交 49818c6)

This commit is contained in:
MiFaZhan
2026-11-02 13:27:59 +08:00
1250 changed files with 154111 additions and 0 deletions
@@ -0,0 +1,212 @@
package simwarning
import (
"context"
"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/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
const (
warnActionDataCut = "data_cut"
warnDataServiceStatusEnabled = 1
warnDataServiceStatusDisabled = 0
)
func (s *sSimWarning) handleAutoAction(ctx context.Context, hit warningHit, warnLogID int64, runTime time.Time) (bool, error) {
if !autoDataCutEnabled(hit) {
return false, nil
}
allowed, reason := autoDataCutEligibility(hit)
if !allowed {
g.Log().Infof(ctx, "SimWarning auto data cut skipped, reason:%s, configId:%d, cardId:%d, warnLogId:%d", reason, hit.Config.Id, hit.Card.Id, warnLogID)
return false, nil
}
acquired, actionKey, err := acquireWarningActionDedupe(ctx, hit, warnActionDataCut, runTime)
if err != nil {
return false, err
}
if !acquired {
g.Log().Infof(ctx, "SimWarning auto data cut duplicated, action:%s, configId:%d, cardId:%d, warnLogId:%d", warnActionDataCut, hit.Config.Id, hit.Card.Id, warnLogID)
return false, nil
}
eligible, reason, err := s.ensureDataCutEligible(ctx, hit)
if err != nil {
releaseWarningDedupe(ctx, actionKey)
return false, err
}
if !eligible {
releaseWarningDedupe(ctx, actionKey)
g.Log().Infof(ctx, "SimWarning auto data cut skipped, reason:%s, configId:%d, cardId:%d, warnLogId:%d", reason, hit.Config.Id, hit.Card.Id, warnLogID)
return false, nil
}
orderNum, err := s.disableCardDataService(ctx, hit)
if err != nil {
releaseWarningDedupe(ctx, actionKey)
return false, err
}
g.Log().Infof(ctx, "SimWarning auto data cut success, action:%s, configId:%d, cardId:%d, warnLogId:%d, orderNum:%s", warnActionDataCut, hit.Config.Id, hit.Card.Id, warnLogID, orderNum)
return true, nil
}
func autoDataCutEnabled(hit warningHit) bool {
return hit.Config.ItemType == warnItemTypeCard &&
(isExpireKind(hit.Kind) || isTrafficKind(hit.Kind)) &&
hit.WarnStage == warnStageThird &&
hit.Config.AutoAction == warnAutoActionDataCut
}
func autoDataCutEligibility(hit warningHit) (bool, string) {
if hit.Card.BossCardStatus == consts.V3ApiSimBossCardStatusStopped {
return false, "\u5361\u5df2\u505c\u673a\uff0c\u81ea\u52a8\u65ad\u7f51\u672a\u6267\u884c"
}
if hit.Card.BossCardStatus != consts.V3ApiSimBossCardStatusActivated {
return false, "\u5f53\u524d\u8fd0\u8425\u5546\u72b6\u6001\u4e3a" + bossCardStatusText(hit.Card.BossCardStatus) + "\uff0c\u4ec5\u5df2\u6fc0\u6d3b\u5361\u53ef\u4ee5\u81ea\u52a8\u65ad\u7f51"
}
if hit.Card.DataServiceStatus == warnDataServiceStatusDisabled {
return false, "\u6570\u636e\u670d\u52a1\u5df2\u505c\u7528\uff0c\u81ea\u52a8\u65ad\u7f51\u672a\u6267\u884c"
}
if hit.Card.DataServiceStatus != warnDataServiceStatusEnabled {
return false, "\u6570\u636e\u670d\u52a1\u72b6\u6001\u65e0\u6548\uff0c\u81ea\u52a8\u65ad\u7f51\u672a\u6267\u884c"
}
if strings.TrimSpace(hit.Card.ChanKey) == "" {
return false, "\u5361\u7f3a\u5c11\u901a\u9053\u6807\u8bc6\uff0c\u81ea\u52a8\u65ad\u7f51\u672a\u6267\u884c"
}
if strings.TrimSpace(hit.Card.Iccid) == "" &&
strings.TrimSpace(hit.Card.Msisdn) == "" &&
strings.TrimSpace(hit.Card.Imsi) == "" {
return false, "\u5361\u7f3a\u5c11 ICCID/MSISDN/IMSI\uff0c\u81ea\u52a8\u65ad\u7f51\u672a\u6267\u884c"
}
return true, "\u5df2\u914d\u7f6e\u7b2c\u4e09\u6863\u65ad\u7f51\uff0c\u7cfb\u7edf\u5c06\u5728\u72b6\u6001\u6821\u9a8c\u901a\u8fc7\u540e\u53d1\u8d77\u6570\u636e\u670d\u52a1\u505c\u7528"
}
func (s *sSimWarning) ensureDataCutEligible(ctx context.Context, hit warningHit) (bool, string, error) {
simKey, err := dataCutSimKey(hit)
if err != nil {
return false, "", err
}
output, err := service.UpstreamChan().QuerySimCardStatus(ctx, &model.SimCardStatusInput{
SimKeys: []*model.SimKey{simKey},
})
if err != nil {
return false, "", err
}
if len(output) == 0 {
return false, "", gerror.New("upstream card status is empty")
}
status := output[0].ResData.CardStatus
if status == consts.V3ApiSimBossCardStatusStopped {
return false, "\u4e0a\u6e38\u5361\u5df2\u505c\u673a\uff0c\u81ea\u52a8\u65ad\u7f51\u672a\u6267\u884c", nil
}
if status != consts.V3ApiSimBossCardStatusActivated {
return false, fmt.Sprintf("\u4e0a\u6e38\u5f53\u524d\u72b6\u6001\u4e3a%s\uff0c\u4ec5\u5df2\u6fc0\u6d3b\u5361\u53ef\u4ee5\u81ea\u52a8\u65ad\u7f51", bossCardStatusText(status)), nil
}
return true, "\u4e0a\u6e38\u72b6\u6001\u5df2\u786e\u8ba4\uff0c\u53ef\u6267\u884c\u6570\u636e\u670d\u52a1\u505c\u7528", nil
}
func bossCardStatusText(status int) string {
if name := consts.V3ApiSimBossCardStatusNameMap[status]; name != "" {
return name
}
return "\u672a\u77e5\u72b6\u6001"
}
func (s *sSimWarning) disableCardDataService(ctx context.Context, hit warningHit) (string, error) {
simKey, err := dataCutSimKey(hit)
if err != nil {
return "", err
}
output, err := service.UpstreamChan().ChangeSimApnFunction(ctx, &model.SimCardOperateChangeApnInput{
SimKeys: []*model.SimKey{simKey},
ReqData: model.SimCardOperateChangeApnInputItem{
OperType: strconv.Itoa(consts.SimCardDataOperTypeStop),
ApnName: consts.SimCardDataApnName,
},
})
if err != nil {
return "", err
}
if len(output) == 0 {
return "", gerror.New("upstream data service stop response is empty")
}
item := output[0]
if item.Code != 0 {
message := strings.TrimSpace(item.Message)
if message == "" {
message = "upstream data service stop failed"
}
return "", gerror.New(message)
}
if err = s.updateLocalDataServiceDisabled(ctx, hit); err != nil {
return item.ResData.OrderNum, err
}
return item.ResData.OrderNum, nil
}
func dataCutSimKey(hit warningHit) (*model.SimKey, error) {
chanKey := strings.TrimSpace(hit.Card.ChanKey)
if chanKey == "" {
return nil, gerror.New("card chan key is empty")
}
if strings.TrimSpace(hit.Card.Iccid) == "" &&
strings.TrimSpace(hit.Card.Msisdn) == "" &&
strings.TrimSpace(hit.Card.Imsi) == "" {
return nil, gerror.New("card identity is empty")
}
return &model.SimKey{
ChanKey: chanKey,
ICCID: strings.TrimSpace(hit.Card.Iccid),
MSISDN: strings.TrimSpace(hit.Card.Msisdn),
IMSI: strings.TrimSpace(hit.Card.Imsi),
}, nil
}
func (s *sSimWarning) updateLocalDataServiceDisabled(ctx context.Context, hit warningHit) error {
columns := dao.TSimCardInfo.Columns()
result, err := dao.TSimCardInfo.Ctx(ctx).
Where(columns.Id, hit.Card.Id).
Where(columns.TenantId, hit.Config.TenantId).
Where(columns.DataServiceStatus, warnDataServiceStatusEnabled).
Data(do.TSimCardInfo{
DataServiceStatus: warnDataServiceStatusDisabled,
UpdatedBy: hit.Config.CreatedBy,
}).
Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 1 {
return nil
}
count, err := dao.TSimCardInfo.Ctx(ctx).
Where(columns.Id, hit.Card.Id).
Where(columns.TenantId, hit.Config.TenantId).
Where(columns.DataServiceStatus, warnDataServiceStatusDisabled).
Count()
if err != nil {
return err
}
if count > 0 {
return nil
}
return gerror.New("local data service status changed before update")
}
@@ -0,0 +1,700 @@
package simwarning
import (
"context"
"database/sql"
"net/mail"
"strings"
"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/library/xgorm/handler"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
func (s *sSimWarning) ConfigList(ctx context.Context, in *model.SimWarningConfigListParam) (list []*model.SimWarningConfigModel, totalCount int, err error) {
if in == nil {
in = &model.SimWarningConfigListParam{}
}
if in.Page <= 0 {
in.Page = 1
}
if in.PageSize <= 0 {
in.PageSize = 20
}
mod := s.configModel(ctx)
columns := dao.TWarnConfig.Columns()
if in.Id > 0 {
mod = mod.Where(columns.Id, in.Id)
}
if in.ItemType > 0 {
mod = mod.Where(columns.ItemType, in.ItemType)
}
if in.ItemId > 0 {
mod = mod.Where(columns.ItemId, in.ItemId)
}
if in.WarnUnit > 0 {
if in.WarnUnit == warnUnitDay || in.WarnUnit == warnUnitMonth {
mod = mod.Where("1 = 0")
} else {
mod = mod.Where(columns.WarnUnit, in.WarnUnit)
}
} else if in.WarnType > 0 {
mod = applyWarnTypeCompatibilityFilter(mod, in.WarnType)
}
if in.IsEnabled != nil {
mod = mod.Where(columns.IsEnabled, *in.IsEnabled)
}
if itemCode := strings.TrimSpace(in.ItemCode); itemCode != "" {
mod = mod.WhereLike(columns.ItemCode, "%"+itemCode+"%")
}
if keyword := strings.TrimSpace(in.Keyword); keyword != "" {
mod = mod.WhereLike(columns.ItemCode, "%"+keyword+"%")
}
err = mod.Page(in.Page, in.PageSize).
OrderDesc(columns.Id).
ScanAndCount(&list, &totalCount, false)
if err != nil {
return nil, 0, gerror.Wrap(err, "获取预警配置列表失败,请稍后重试")
}
if list == nil {
list = []*model.SimWarningConfigModel{}
}
fillConfigWarnTypes(list)
return list, totalCount, nil
}
func (s *sSimWarning) ConfigView(ctx context.Context, in *model.SimWarningConfigViewParam) (res *model.SimWarningConfigModel, err error) {
if in == nil || (in.Id <= 0 && in.ItemId <= 0) {
return nil, gerror.New("配置ID和对象ID不能同时为空")
}
mod := s.configModel(ctx)
columns := dao.TWarnConfig.Columns()
if in.Id > 0 {
mod = mod.Where(columns.Id, in.Id)
} else {
itemType := in.ItemType
if itemType == 0 {
itemType = warnItemTypeCard
}
if itemType != warnItemTypeCard && itemType != warnItemTypeFlowPool {
return nil, gerror.New("预警对象类型无效")
}
warnUnit, err := warnUnitForConfigLookup(itemType, in.WarnType, in.WarnUnit)
if err != nil {
return nil, err
}
mod = mod.Where(columns.ItemType, itemType).
Where(columns.ItemId, in.ItemId).
Where(columns.WarnUnit, warnUnit)
}
if err = mod.Scan(&res); err != nil {
return nil, gerror.Wrap(err, "获取预警配置详情失败,请稍后重试")
}
if res == nil || res.Id == 0 {
return nil, gerror.New("预警配置不存在或无权限访问")
}
if res.WarnUnit == warnUnitDay || res.WarnUnit == warnUnitMonth {
return nil, gerror.New("日期预警由系统默认规则处理,不支持查询用户配置")
}
res.WarnType = deriveWarnType(res.ItemType, res.WarnUnit)
return res, nil
}
func (s *sSimWarning) ConfigSave(ctx context.Context, in *model.SimWarningConfigSaveParam) (res *model.SimWarningConfigSaveModel, err error) {
if in == nil {
return nil, gerror.New("请求参数不能为空")
}
user := contexts.GetUser(ctx)
if user == nil || user.TenantId == "" || user.ID == 0 {
return nil, gerror.New("登录信息已过期")
}
in.ItemCode = strings.TrimSpace(in.ItemCode)
itemType := in.ItemType
if itemType == 0 {
itemType = warnItemTypeCard
}
in.ItemType = itemType
if err = normalizeConfigSaveParam(in); err != nil {
return nil, err
}
var itemCode string
switch in.ItemType {
case warnItemTypeCard:
card, err := s.queryConfigCard(ctx, in.ItemId, in.ItemCode)
if err != nil {
return nil, err
}
in.ItemId = card.Id
itemCode = strings.TrimSpace(in.ItemCode)
if itemCode == "" {
itemCode = strings.TrimSpace(card.Iccid)
}
if itemCode == "" {
itemCode = strings.TrimSpace(card.Msisdn)
}
case warnItemTypeFlowPool:
pool, err := s.queryConfigFlowPool(ctx, in.ItemId, in.ItemCode)
if err != nil {
return nil, err
}
in.ItemId = pool.Id
itemCode = strings.TrimSpace(in.ItemCode)
if itemCode == "" {
itemCode = strings.TrimSpace(pool.FlowPoolCode)
}
default:
return nil, gerror.New("预警对象类型无效")
}
if err = s.ensureConfigWritable(ctx, user.TenantId, in.Id, in.ItemType, in.ItemId, in.WarnUnit); err != nil {
return nil, err
}
now := gtime.Now()
data := buildConfigSaveData(user.TenantId, user.DeptId, user.ID, itemCode, in, now)
columns := dao.TWarnConfig.Columns()
_, err = dao.TWarnConfig.Ctx(ctx).
Data(data).
OnConflict(columns.TenantId, columns.ItemType, columns.ItemId, columns.WarnUnit).
OnDuplicateEx(strings.Join([]string{
columns.Id,
columns.TenantId,
columns.ItemType,
columns.ItemId,
columns.WarnUnit,
columns.CreatedDept,
columns.CreatedBy,
columns.CreatedAt,
}, ",")).
Save()
if err != nil {
return nil, gerror.Wrap(err, "保存预警配置失败,请稍后重试")
}
saved, err := s.findConfigByItem(ctx, user.TenantId, in.ItemType, in.ItemId, in.WarnUnit, true)
if err != nil {
return nil, err
}
if saved == nil || saved.Id == 0 {
return nil, gerror.New("保存预警配置后未查询到配置")
}
return &model.SimWarningConfigSaveModel{Id: saved.Id}, nil
}
func (s *sSimWarning) ConfigStatus(ctx context.Context, in *model.SimWarningConfigStatusParam) (err error) {
if in == nil || in.Id <= 0 {
return gerror.New("配置ID不能为空")
}
if in.IsEnabled != 0 && in.IsEnabled != 1 {
return gerror.New("启停状态无效")
}
if _, err = s.ConfigView(ctx, &model.SimWarningConfigViewParam{Id: in.Id}); err != nil {
return err
}
userId := contexts.GetUserId(ctx)
columns := dao.TWarnConfig.Columns()
result, err := s.configModel(ctx).Where(columns.Id, in.Id).Data(do.TWarnConfig{
IsEnabled: in.IsEnabled,
UpdatedBy: userId,
UpdatedAt: gtime.Now(),
}).Update()
if err != nil {
return gerror.Wrap(err, "启停预警配置失败,请稍后重试")
}
return ensureAffected(result, "预警配置不存在或无权限访问")
}
func (s *sSimWarning) ConfigDelete(ctx context.Context, in *model.SimWarningConfigDeleteParam) (err error) {
if in == nil || in.Id <= 0 {
return gerror.New("配置ID不能为空")
}
if _, err = s.ConfigView(ctx, &model.SimWarningConfigViewParam{Id: in.Id}); err != nil {
return err
}
result, err := s.configModel(ctx).Where(dao.TWarnConfig.Columns().Id, in.Id).Delete()
if err != nil {
return gerror.Wrap(err, "删除预警配置失败,请稍后重试")
}
return ensureAffected(result, "预警配置不存在或无权限访问")
}
func (s *sSimWarning) configModel(ctx context.Context) *gdb.Model {
return handler.Model(dao.TWarnConfig.Ctx(ctx)).
Fields(warningConfigFields()...)
}
func (s *sSimWarning) queryConfigCard(ctx context.Context, itemId int64, itemCode string) (*entity.TSimCardInfo, error) {
itemCode = strings.TrimSpace(itemCode)
if itemId <= 0 && itemCode == "" {
return nil, gerror.New("单卡ID或ICCID/MSISDN/IMSI不能为空")
}
var card *entity.TSimCardInfo
mod := service.IotTSimCardInfo().Model(ctx).Fields(
"id",
"iccid",
"msisdn",
"imsi",
"tenant_id",
)
if itemId > 0 {
mod = mod.Where("id", itemId)
} else {
mod = mod.Where("(iccid = ? OR msisdn = ? OR imsi = ?)", itemCode, itemCode, itemCode)
}
if err := mod.Scan(&card); err != nil {
return nil, gerror.Wrap(err, "查询单卡失败,请稍后重试")
}
if card == nil || card.Id == 0 {
return nil, gerror.New("单卡不存在或无权限访问")
}
return card, nil
}
func (s *sSimWarning) queryConfigFlowPool(ctx context.Context, itemId int64, itemCode string) (*entity.TSimFlowPool, error) {
itemCode = strings.TrimSpace(itemCode)
if itemId <= 0 && itemCode == "" {
return nil, gerror.New("流量池ID或流量池编号不能为空")
}
var pool *entity.TSimFlowPool
mod := service.IotTSimFlowPool().Model(ctx).Fields(
"id",
"flow_pool_code",
"tenant_id",
)
if itemId > 0 {
mod = mod.Where("id", itemId)
} else {
mod = mod.Where("flow_pool_code", itemCode)
}
if err := mod.Scan(&pool); err != nil {
return nil, gerror.Wrap(err, "查询流量池失败,请稍后重试")
}
if pool == nil || pool.Id == 0 {
return nil, gerror.New("流量池不存在或无权限访问")
}
return pool, nil
}
func (s *sSimWarning) ensureConfigWritable(ctx context.Context, tenantId string, id int64, itemType int, itemId int64, warnUnit int) error {
if id > 0 {
existing, err := s.findConfigByID(ctx, tenantId, id, false)
if err != nil {
return err
}
if existing == nil || existing.Id == 0 {
return gerror.New("预警配置不存在")
}
if existing.ItemType != itemType || existing.ItemId != itemId || existing.WarnUnit != warnUnit {
return gerror.New("配置ID与对象或预警维度不匹配")
}
return s.ensureConfigVisible(ctx, existing.Id)
}
existing, err := s.findConfigByItem(ctx, tenantId, itemType, itemId, warnUnit, false)
if err != nil {
return err
}
if existing == nil || existing.Id == 0 {
return nil
}
return s.ensureConfigVisible(ctx, existing.Id)
}
func (s *sSimWarning) ensureConfigVisible(ctx context.Context, id int64) error {
visible, err := s.findConfigByID(ctx, "", id, true)
if err != nil {
return err
}
if visible == nil || visible.Id == 0 {
return gerror.New("预警配置不存在或无权限访问")
}
return nil
}
func (s *sSimWarning) findConfigByID(ctx context.Context, tenantId string, id int64, withAuth bool) (*model.SimWarningConfigModel, error) {
if id <= 0 {
return nil, nil
}
columns := dao.TWarnConfig.Columns()
mod := dao.TWarnConfig.Ctx(ctx).
Fields(warningConfigFields()...).
Where(columns.Id, id)
if tenantId != "" {
mod = mod.Where(columns.TenantId, tenantId)
}
if withAuth {
mod = handler.Model(mod)
}
var res *model.SimWarningConfigModel
if err := mod.Scan(&res); err != nil {
return nil, gerror.Wrap(err, "查询预警配置失败,请稍后重试")
}
if res != nil {
res.WarnType = deriveWarnType(res.ItemType, res.WarnUnit)
}
return res, nil
}
func (s *sSimWarning) findConfigByItem(ctx context.Context, tenantId string, itemType int, itemId int64, warnUnit int, withAuth bool) (*model.SimWarningConfigModel, error) {
if tenantId == "" || itemId <= 0 || warnUnit <= 0 {
return nil, nil
}
if itemType == 0 {
itemType = warnItemTypeCard
}
columns := dao.TWarnConfig.Columns()
mod := dao.TWarnConfig.Ctx(ctx).
Fields(warningConfigFields()...).
Where(columns.TenantId, tenantId).
Where(columns.ItemType, itemType).
Where(columns.ItemId, itemId).
Where(columns.WarnUnit, warnUnit)
if withAuth {
mod = handler.Model(mod)
}
var res *model.SimWarningConfigModel
if err := mod.Scan(&res); err != nil {
return nil, gerror.Wrap(err, "查询预警配置失败,请稍后重试")
}
if res != nil {
res.WarnType = deriveWarnType(res.ItemType, res.WarnUnit)
}
return res, nil
}
func warningConfigFields() []any {
columns := dao.TWarnConfig.Columns()
return []any{
columns.Id,
columns.TenantId,
columns.BatchNo,
columns.ItemType,
columns.ItemId,
columns.ItemCode,
columns.WarnUnit,
columns.FirstWarn,
columns.SecondWarn,
columns.ThirdWarn,
columns.WarnMethods,
columns.WarnPhones,
columns.WarnEmails,
columns.AutoAction,
columns.IsEnabled,
columns.WxTemplateId,
columns.Remark,
columns.CreatedDept,
columns.CreatedBy,
columns.CreatedAt,
columns.UpdatedBy,
columns.UpdatedAt,
columns.AppNotifyEnabled,
}
}
func buildConfigSaveData(tenantId string, deptId int64, userId int64, resolvedItemCode string, in *model.SimWarningConfigSaveParam, now *gtime.Time) do.TWarnConfig {
itemCode := strings.TrimSpace(resolvedItemCode)
if itemCode == "" {
itemCode = strings.TrimSpace(in.ItemCode)
}
isEnabled := 1
if in.IsEnabled != nil {
isEnabled = *in.IsEnabled
}
warnMethods := normalizeConfigWarnMethods(in.WarnMethods)
data := do.TWarnConfig{
TenantId: tenantId,
ItemType: in.ItemType,
ItemId: in.ItemId,
ItemCode: itemCode,
BatchNo: strings.TrimSpace(in.BatchNo),
WarnUnit: in.WarnUnit,
FirstWarn: in.FirstWarn,
SecondWarn: in.SecondWarn,
ThirdWarn: in.ThirdWarn,
WarnMethods: warnMethods,
WarnPhones: strings.TrimSpace(in.WarnPhones),
WarnEmails: strings.TrimSpace(in.WarnEmails),
AutoAction: in.AutoAction,
Remark: strings.TrimSpace(in.Remark),
CreatedDept: deptId,
CreatedBy: userId,
CreatedAt: now,
UpdatedBy: userId,
UpdatedAt: now,
IsEnabled: isEnabled,
}
if in.AppNotifyEnabled != nil {
data.AppNotifyEnabled = *in.AppNotifyEnabled
}
return data
}
func normalizeConfigSaveParam(in *model.SimWarningConfigSaveParam) error {
in.ItemCode = strings.TrimSpace(in.ItemCode)
in.WarnMethods = normalizeConfigWarnMethods(in.WarnMethods)
in.WarnPhones = strings.TrimSpace(in.WarnPhones)
in.WarnEmails = strings.TrimSpace(in.WarnEmails)
in.Remark = strings.TrimSpace(in.Remark)
if in.ItemType == 0 {
in.ItemType = warnItemTypeCard
}
if in.ItemType != warnItemTypeCard && in.ItemType != warnItemTypeFlowPool {
return gerror.New("预警对象类型无效")
}
if in.ItemId <= 0 && in.ItemCode == "" {
if in.ItemType == warnItemTypeFlowPool {
return gerror.New("流量池ID或流量池编号不能为空")
}
return gerror.New("单卡ID或ICCID/MSISDN/IMSI不能为空")
}
if in.WarnUnit == warnUnitDay || in.WarnUnit == warnUnitMonth {
return gerror.New("日期预警由系统默认规则处理,不支持手动配置")
}
if err := normalizeConfigContacts(in); err != nil {
return err
}
if in.IsEnabled != nil && *in.IsEnabled != 0 && *in.IsEnabled != 1 {
return gerror.New("启停状态无效")
}
if in.AppNotifyEnabled != nil && *in.AppNotifyEnabled != 0 && *in.AppNotifyEnabled != 1 {
return gerror.New("小程序订阅通知状态无效")
}
if in.AutoAction != 0 && in.AutoAction != warnAutoActionDataCut {
return gerror.New("第三次是否断网配置无效")
}
if in.ItemType == warnItemTypeFlowPool && in.AutoAction != 0 {
return gerror.New("流量池预警不支持自动断网")
}
in.WarnType = deriveWarnType(in.ItemType, in.WarnUnit)
if err := validateWarnTypeUnit(in.ItemType, in.WarnType, in.WarnUnit); err != nil {
return err
}
if in.ItemType == warnItemTypeCard && in.AutoAction == warnAutoActionDataCut && in.ThirdWarn <= 0 {
return gerror.New("开启第三次断网时必须配置第三次提醒")
}
switch in.WarnType {
case warnTypeCardTraffic, warnTypeFlowPoolPercent:
if in.WarnUnit == warnUnitPercent {
first := normalizePercentThreshold(in.FirstWarn)
second := normalizePercentThreshold(in.SecondWarn)
third := normalizePercentThreshold(in.ThirdWarn)
if first > 100 || second > 100 || third > 100 {
return gerror.New("百分比阈值不能大于100")
}
in.FirstWarn = first
in.SecondWarn = second
in.ThirdWarn = third
}
return validateTrafficThresholds(in.FirstWarn, in.SecondWarn, in.ThirdWarn)
default:
return gerror.New("预警类型无效")
}
}
func normalizeConfigContacts(in *model.SimWarningConfigSaveParam) error {
if hasConfigWarnMethod(in.WarnMethods, "0") {
phones, err := normalizeConfigWarnPhones(in.WarnPhones)
if err != nil {
return err
}
in.WarnPhones = phones
} else {
in.WarnPhones = ""
}
if hasConfigWarnMethod(in.WarnMethods, "1") {
emails, err := normalizeConfigWarnEmails(in.WarnEmails)
if err != nil {
return err
}
in.WarnEmails = emails
} else {
in.WarnEmails = ""
}
return nil
}
func hasConfigWarnMethod(methods string, target string) bool {
for _, method := range splitConfigWarnContactList(methods) {
if method == target {
return true
}
}
return false
}
func normalizeConfigWarnMethods(methods string) string {
selected := map[string]struct{}{
warnMethodPlatform: {},
}
for _, method := range splitConfigWarnContactList(methods) {
switch method {
case warnMethodPlatform, warnMethodEmail, warnMethodSMS, warnMethodPhone:
selected[method] = struct{}{}
}
}
ordered := []string{
warnMethodPlatform,
warnMethodEmail,
warnMethodSMS,
warnMethodPhone,
}
result := make([]string, 0, len(ordered))
for _, method := range ordered {
if _, ok := selected[method]; ok {
result = append(result, method)
}
}
return strings.Join(result, ",")
}
func normalizeConfigWarnEmails(value string) (string, error) {
emails := splitConfigWarnContactList(value)
if len(emails) == 0 {
return "", gerror.New("请选择邮箱提醒时必须填写邮箱地址")
}
result := make([]string, 0, len(emails))
for _, email := range emails {
address, err := mail.ParseAddress(email)
if err != nil || address == nil || strings.TrimSpace(address.Address) == "" {
return "", gerror.Newf("邮箱格式不正确:%s", email)
}
result = append(result, strings.ToLower(strings.TrimSpace(address.Address)))
}
return strings.Join(result, ","), nil
}
func normalizeConfigWarnPhones(value string) (string, error) {
phones := splitConfigWarnContactList(value)
if len(phones) == 0 {
return "", gerror.New("请选择短信提醒时必须填写短信号码")
}
for _, phone := range phones {
if !isValidConfigWarnPhone(phone) {
return "", gerror.Newf("手机号格式不正确:%s", phone)
}
}
return strings.Join(phones, ","), nil
}
func isValidConfigWarnPhone(phone string) bool {
if len(phone) != 11 || phone[0] != '1' {
return false
}
for _, r := range phone {
if r < '0' || r > '9' {
return false
}
}
return phone[1] >= '3' && phone[1] <= '9'
}
func splitConfigWarnContactList(value string) []string {
parts := strings.FieldsFunc(value, func(r rune) bool {
switch r {
case ',', ';', ',', ';', '|', '\n', '\r', '\t', ' ':
return true
default:
return false
}
})
seen := make(map[string]struct{}, len(parts))
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
key := strings.ToLower(part)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
result = append(result, part)
}
return result
}
func validateTrafficThresholds(first, second, third float64) error {
if first <= 0 {
return gerror.New("第一档阈值必须大于0")
}
if second < 0 || third < 0 {
return gerror.New("预警阈值不能小于0")
}
if second > 0 && second <= first {
return gerror.New("流量预警第二档阈值必须大于第一档")
}
if third > 0 {
if second <= 0 {
return gerror.New("配置第三档时必须先配置第二档")
}
if third <= second {
return gerror.New("流量预警第三档阈值必须大于第二档")
}
}
return nil
}
func warnUnitForConfigLookup(itemType, warnType, warnUnit int) (int, error) {
if warnUnit > 0 {
if warnUnit == warnUnitDay || warnUnit == warnUnitMonth {
return 0, gerror.New("日期预警由系统默认规则处理,不支持查询用户配置")
}
if err := validateWarnTypeUnit(itemType, deriveWarnType(itemType, warnUnit), warnUnit); err != nil {
return 0, err
}
return warnUnit, nil
}
switch warnType {
case warnTypeFlowPoolPercent:
return warnUnitPercent, nil
case warnTypeCardTraffic:
return 0, gerror.New("通过对象ID查询单卡流量预警时必须指定warnUnit")
default:
return 0, gerror.New("通过对象ID查询预警配置时必须指定warnUnit")
}
}
func applyWarnTypeCompatibilityFilter(mod *gdb.Model, warnType int) *gdb.Model {
switch warnType {
case warnTypeCardTraffic:
return mod.Where("item_type = ? AND warn_unit IN (?, ?)", warnItemTypeCard, warnUnitMb, warnUnitPercent)
case warnTypeFlowPoolPercent:
return mod.Where("item_type = ? AND warn_unit = ?", warnItemTypeFlowPool, warnUnitPercent)
default:
return mod.Where("1 = 0")
}
}
func fillConfigWarnTypes(list []*model.SimWarningConfigModel) {
for _, item := range list {
if item == nil {
continue
}
item.WarnType = deriveWarnType(item.ItemType, item.WarnUnit)
}
}
func ensureAffected(result sql.Result, message string) error {
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return gerror.New(message)
}
return nil
}
@@ -0,0 +1,151 @@
package simwarning
import (
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/cache"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
)
const (
warnDedupeKeyPrefix = "iotlcm:warning"
warnDedupeMinTTL = 24 * time.Hour
)
func acquireWarningDedupe(ctx context.Context, hit warningHit, runTime time.Time) (bool, string, error) {
key := warningDedupeKey(hit)
if key == "" {
return false, "", gerror.New("warning dedupe key is empty")
}
ok, err := cache.Instance().SetIfNotExist(ctx, key, "1", warningDedupeTTL(hit, runTime))
return ok, key, err
}
func acquireWarningActionDedupe(ctx context.Context, hit warningHit, action string, runTime time.Time) (bool, string, error) {
action = strings.TrimSpace(action)
if action == "" {
return false, "", gerror.New("warning action is empty")
}
key := warningActionDedupeKey(hit, action)
if key == "" {
return false, "", gerror.New("warning action dedupe key is empty")
}
ok, err := cache.Instance().SetIfNotExist(ctx, key, "1", warningDedupeTTL(hit, runTime))
return ok, key, err
}
func releaseWarningDedupe(ctx context.Context, key string) {
key = strings.TrimSpace(key)
if key == "" {
return
}
_, _ = cache.Instance().Remove(ctx, key)
}
func warningDedupeKey(hit warningHit) string {
if hit.Config.TenantId == "" || hit.Config.Id == 0 || hit.Config.ItemId == 0 || hit.WarnStage == 0 {
return ""
}
return fmt.Sprintf(
"%s:hit:%d:%d:%s:%d:%d:%d:%s",
warnDedupeKeyPrefix,
hit.Config.ItemType,
hit.Config.WarnUnit,
hit.Config.TenantId,
hit.Config.Id,
hit.Config.ItemId,
hit.WarnStage,
warningPeriodKey(hit),
)
}
func warningActionDedupeKey(hit warningHit, action string) string {
if hit.Config.TenantId == "" || hit.Config.Id == 0 || hit.Config.ItemId == 0 || hit.WarnStage == 0 {
return ""
}
return fmt.Sprintf(
"%s:act:%s:%d:%d:%s:%d:%d:%d:%s",
warnDedupeKeyPrefix,
action,
hit.Config.ItemType,
hit.Config.WarnUnit,
hit.Config.TenantId,
hit.Config.Id,
hit.Config.ItemId,
hit.WarnStage,
warningPeriodKey(hit),
)
}
func warningDedupeTTL(hit warningHit, runTime time.Time) time.Duration {
if runTime.IsZero() {
runTime = time.Now()
}
expireAt := warningDedupeExpireAt(hit)
if expireAt.IsZero() {
return warnDedupeMinTTL
}
ttl := expireAt.Sub(runTime)
if ttl <= 0 {
return warnDedupeMinTTL
}
return ttl
}
func warningDedupeExpireAt(hit warningHit) time.Time {
if !hit.DedupeExpireAt.IsZero() {
return hit.DedupeExpireAt
}
if !hit.WindowEnd.IsZero() {
return hit.WindowEnd
}
if !hit.PeriodEnd.IsZero() {
return hit.PeriodEnd.AddDate(0, 1, 0)
}
return time.Time{}
}
func warnUnix(value time.Time) int64 {
if value.IsZero() {
return 0
}
return value.Unix()
}
func warningPeriodKey(hit warningHit) string {
begin, end := warningWindow(hit)
return fmt.Sprintf("%d-%d", warnUnix(begin), warnUnix(end))
}
func warningWindow(hit warningHit) (time.Time, time.Time) {
begin := hit.WindowBegin
end := hit.WindowEnd
if begin.IsZero() {
begin = hit.PeriodBegin
}
if end.IsZero() {
end = hit.PeriodEnd
}
return begin, end
}
func isTrafficKind(kind string) bool {
switch kind {
case warnKindTrafficMb, warnKindTrafficPercent, warnKindPoolTrafficPercent:
return true
default:
return false
}
}
func isExpireKind(kind string) bool {
switch kind {
case warnKindExpireDay, warnKindExpireMonth:
return true
default:
return false
}
}
@@ -0,0 +1,622 @@
package simwarning
import (
"context"
"encoding/json"
"fmt"
"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/model/entity"
"net/mail"
"sort"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
const (
expireCandidateKeyPrefix = warnDedupeKeyPrefix + ":sim_card:expire:candidate"
expireCandidateSetPrefix = warnDedupeKeyPrefix + ":sim_card:expire:candidates"
expireCandidateTTLBuffer = 7 * 24 * time.Hour
simTerminalTable = "t_sim_terminal"
terminalTable = "t_terminal"
)
type expireCandidate struct {
TenantID string `json:"tenantId"`
CardID int64 `json:"cardId"`
ICCID string `json:"iccid"`
MSISDN string `json:"msisdn"`
PackageCode string `json:"packageCode"`
ExpireUnix int64 `json:"expireUnix"`
ExpireAt string `json:"expireAt"`
TargetMonth string `json:"targetMonth"`
CreatedDept int64 `json:"createdDept"`
CreatedBy int64 `json:"createdBy"`
LoadedAt string `json:"loadedAt"`
}
type expireCandidateStats struct {
CandidateCount int
CachedCount int
DeletedCount int
scanStats
}
type expireWarningContactRow struct {
UserID int64 `orm:"user_id"`
Email string `orm:"email"`
Phonenumber string `orm:"phonenumber"`
}
func (s *sSimWarning) PreloadExpireCandidates(ctx context.Context) {
if ctx == nil {
ctx = context.Background()
}
runTime := time.Now()
targetBegin, targetEnd := expirePreloadTargetMonth(runTime)
targetMonth := targetBegin.Format("200601")
stats := expireCandidateStats{}
lastID := int64(0)
g.Log().Infof(ctx, "SimWarning.ExpirePreload begin, runTime:%s, targetMonth:%s", runTime.Format("2006-01-02 15:04:05"), targetMonth)
for {
if err := ctx.Err(); err != nil {
stats.ErrorCount++
g.Log().Warningf(ctx, "SimWarning.ExpirePreload canceled, targetMonth:%s, lastID:%d, err:%+v", targetMonth, lastID, err)
break
}
cards, err := s.queryExpireCandidateCards(ctx, lastID, targetBegin, targetEnd)
if err != nil {
stats.ErrorCount++
g.Log().Errorf(ctx, "SimWarning.ExpirePreload query candidates failed, targetMonth:%s, lastID:%d, err:%+v", targetMonth, lastID, err)
break
}
if len(cards) == 0 {
break
}
stats.CandidateCount += len(cards)
for _, card := range cards {
if card.Id > lastID {
lastID = card.Id
}
if reason := validExpireCandidateCard(card, runTime); reason != "" {
stats.SkipCount++
g.Log().Debugf(ctx, "SimWarning.ExpirePreload skipped, targetMonth:%s, cardId:%d, iccid:%s, reason:%s", targetMonth, card.Id, card.Iccid, reason)
continue
}
if err := storeExpireCandidate(ctx, card, targetMonth, targetEnd.Add(expireCandidateTTLBuffer), runTime); err != nil {
stats.ErrorCount++
g.Log().Errorf(ctx, "SimWarning.ExpirePreload store candidate failed, targetMonth:%s, cardId:%d, iccid:%s, err:%+v", targetMonth, card.Id, card.Iccid, err)
continue
}
stats.CachedCount++
}
if len(cards) < scanPageSize {
break
}
}
g.Log().Infof(ctx, "SimWarning.ExpirePreload end, targetMonth:%s, candidates:%d, cached:%d, skipped:%d, errors:%d", targetMonth, stats.CandidateCount, stats.CachedCount, stats.SkipCount, stats.ErrorCount)
}
func (s *sSimWarning) ScanExpireCandidates(ctx context.Context) {
if ctx == nil {
ctx = context.Background()
}
runTime := time.Now()
stats := expireCandidateStats{}
pendingWarnings := make([]pendingWarning, 0)
g.Log().Infof(ctx, "SimWarning.ExpireCandidateScan begin, runTime:%s, mode:warn_log_only", runTime.Format("2006-01-02 15:04:05"))
for _, targetMonth := range expireCandidateScanMonths(runTime) {
setKey := expireCandidateSetKey(targetMonth)
members, err := g.Redis().SMembers(ctx, setKey)
if err != nil {
stats.ErrorCount++
g.Log().Errorf(ctx, "SimWarning.ExpireCandidateScan read redis set failed, setKey:%s, err:%+v", setKey, err)
continue
}
keys := members.Strings()
stats.CandidateCount += len(keys)
for _, key := range keys {
if err := ctx.Err(); err != nil {
stats.ErrorCount++
g.Log().Warningf(ctx, "SimWarning.ExpireCandidateScan canceled, setKey:%s, err:%+v", setKey, err)
break
}
pending := s.scanExpireCandidateKey(ctx, setKey, key, runTime, &stats)
if pending != nil {
pendingWarnings = append(pendingWarnings, *pending)
}
}
}
storedWarnings := s.storePendingWarnings(ctx, pendingWarnings, runTime, &stats.scanStats)
g.Log().Infof(
ctx,
"SimWarning.ExpireCandidateScan end, candidates:%d, hits:%d, duplicated:%d, deleted:%d, skipped:%d, errors:%d, mode:warn_log_only",
stats.CandidateCount,
len(storedWarnings),
stats.DupeCount,
stats.DeletedCount,
stats.SkipCount,
stats.ErrorCount,
)
}
func (s *sSimWarning) scanExpireCandidateKey(ctx context.Context, setKey string, key string, runTime time.Time, stats *expireCandidateStats) *pendingWarning {
key = strings.TrimSpace(key)
if key == "" {
stats.SkipCount++
return nil
}
candidate, ok, err := readExpireCandidate(ctx, key)
if err != nil {
stats.ErrorCount++
g.Log().Errorf(ctx, "SimWarning.ExpireCandidateScan read candidate failed, key:%s, err:%+v", key, err)
return nil
}
if !ok {
stats.DeletedCount++
removeExpireCandidate(ctx, setKey, key)
return nil
}
card, err := s.queryExpireCandidateCard(ctx, candidate.TenantID, candidate.CardID)
if err != nil {
stats.ErrorCount++
g.Log().Errorf(ctx, "SimWarning.ExpireCandidateScan query card failed, key:%s, tenantId:%s, cardId:%d, err:%+v", key, candidate.TenantID, candidate.CardID, err)
return nil
}
if reason := removeExpireCandidateReason(candidate, card, runTime); reason != "" {
stats.DeletedCount++
removeExpireCandidate(ctx, setKey, key)
g.Log().Infof(ctx, "SimWarning.ExpireCandidateScan candidate removed, key:%s, reason:%s", key, reason)
return nil
}
warnPhones, warnEmails, err := s.resolveExpireWarningContacts(ctx, *card)
if err != nil {
stats.ErrorCount++
g.Log().Warningf(ctx, "SimWarning.ExpireCandidateScan resolve contacts failed, cardId:%d, iccid:%s, err:%+v", card.Id, card.Iccid, err)
}
cfg := systemExpireWarnConfig(ctx, *card, warnPhones, warnEmails)
hit, reason := evaluateSystemExpireDay(cfg, *card, runTime)
if hit == nil {
stats.SkipCount++
g.Log().Debugf(ctx, "SimWarning.ExpireCandidateScan skipped, key:%s, cardId:%d, reason:%s", key, card.Id, reason)
return nil
}
exists, err := s.existsInPeriod(ctx, *hit)
if err != nil {
stats.ErrorCount++
g.Log().Errorf(ctx, "SimWarning.ExpireCandidateScan idempotent check failed, cardId:%d, stage:%d, err:%+v", card.Id, hit.WarnStage, err)
return nil
}
if exists {
stats.DupeCount++
g.Log().Infof(ctx, "SimWarning.ExpireCandidateScan duplicated by warn log, cardId:%d, stage:%d", card.Id, hit.WarnStage)
return nil
}
acquired, dedupeKey, err := acquireWarningDedupe(ctx, *hit, runTime)
if err != nil {
stats.ErrorCount++
g.Log().Errorf(ctx, "SimWarning.ExpireCandidateScan acquire dedupe failed, cardId:%d, stage:%d, err:%+v", card.Id, hit.WarnStage, err)
return nil
}
if !acquired {
stats.DupeCount++
g.Log().Infof(ctx, "SimWarning.ExpireCandidateScan duplicated by redis, cardId:%d, stage:%d", card.Id, hit.WarnStage)
return nil
}
return &pendingWarning{Hit: *hit, DedupeKey: dedupeKey}
}
func (s *sSimWarning) queryExpireCandidateCards(ctx context.Context, lastID int64, targetBegin time.Time, targetEnd time.Time) ([]entity.TSimCardInfo, error) {
columns := dao.TSimCardInfo.Columns()
cards := make([]entity.TSimCardInfo, 0, scanPageSize)
err := dao.TSimCardInfo.Ctx(ctx).
Fields(
columns.Id,
columns.Iccid,
columns.Msisdn,
columns.CurrentPackageCode,
columns.PackageStatus,
columns.IsFlowPool,
columns.FlowPkgBeginAt,
columns.FlowPkgEndAt,
columns.TenantId,
columns.DeptId,
columns.CreatedDept,
columns.CreatedBy,
).
WhereGT(columns.Id, lastID).
Where(columns.PackageStatus, consts.PackageStatusEffective).
Where(columns.IsFlowPool, 0).
Where(columns.CurrentPackageCode+" <> ?", "").
Where(columns.FlowPkgEndAt+" >= ?", targetBegin).
Where(columns.FlowPkgEndAt+" < ?", targetEnd).
OrderAsc(columns.Id).
Limit(scanPageSize).
Scan(&cards)
return cards, err
}
func (s *sSimWarning) queryExpireCandidateCard(ctx context.Context, tenantID string, cardID int64) (*entity.TSimCardInfo, error) {
if strings.TrimSpace(tenantID) == "" || cardID <= 0 {
return nil, nil
}
columns := dao.TSimCardInfo.Columns()
var card *entity.TSimCardInfo
err := dao.TSimCardInfo.Ctx(ctx).
Fields(
columns.Id,
columns.Iccid,
columns.Msisdn,
columns.CurrentPackageCode,
columns.PackageStatus,
columns.IsFlowPool,
columns.FlowPkgBeginAt,
columns.FlowPkgEndAt,
columns.TenantId,
columns.DeptId,
columns.CreatedDept,
columns.CreatedBy,
).
Where(columns.Id, cardID).
Where(columns.TenantId, tenantID).
Scan(&card)
return card, err
}
func validExpireCandidateCard(card entity.TSimCardInfo, runTime time.Time) string {
if reason := validCommonCard(card); reason != "" {
return reason
}
if card.FlowPkgEndAt == nil || card.FlowPkgEndAt.IsZero() {
return "package end time is empty"
}
if !runTime.Before(card.FlowPkgEndAt.Time) {
return "package expired"
}
return ""
}
func removeExpireCandidateReason(candidate expireCandidate, card *entity.TSimCardInfo, runTime time.Time) string {
if strings.TrimSpace(candidate.TenantID) == "" || candidate.CardID <= 0 || candidate.ExpireUnix <= 0 {
return "candidate data invalid"
}
if card == nil || card.Id == 0 {
return "card not found"
}
if reason := validExpireCandidateCard(*card, runTime); reason != "" {
return reason
}
if card.FlowPkgEndAt.Time.Unix() != candidate.ExpireUnix {
return "package renewed"
}
return ""
}
func storeExpireCandidate(ctx context.Context, card entity.TSimCardInfo, targetMonth string, expireAt time.Time, runTime time.Time) error {
if card.FlowPkgEndAt == nil || card.FlowPkgEndAt.IsZero() {
return gerror.New("package end time is empty")
}
candidate := expireCandidate{
TenantID: strings.TrimSpace(card.TenantId),
CardID: card.Id,
ICCID: strings.TrimSpace(card.Iccid),
MSISDN: strings.TrimSpace(card.Msisdn),
PackageCode: strings.TrimSpace(card.CurrentPackageCode),
ExpireUnix: card.FlowPkgEndAt.Time.Unix(),
ExpireAt: card.FlowPkgEndAt.Time.Format("2006-01-02 15:04:05"),
TargetMonth: targetMonth,
CreatedDept: expireWarningDeptID(card),
CreatedBy: card.CreatedBy,
LoadedAt: runTime.Format("2006-01-02 15:04:05"),
}
key := expireCandidateKey(candidate.TenantID, candidate.CardID, candidate.ExpireUnix)
if key == "" {
return gerror.New("expire candidate key is empty")
}
payload, err := json.Marshal(candidate)
if err != nil {
return err
}
ttl := expireCandidateTTL(expireAt, runTime)
if _, err = g.Redis().Do(ctx, "SET", key, string(payload), "EX", int(ttl.Seconds())); err != nil {
return err
}
setKey := expireCandidateSetKey(targetMonth)
if _, err = g.Redis().Do(ctx, "SADD", setKey, key); err != nil {
return err
}
_, err = g.Redis().Do(ctx, "EXPIRE", setKey, int(ttl.Seconds()))
return err
}
func readExpireCandidate(ctx context.Context, key string) (expireCandidate, bool, error) {
res, err := g.Redis().Do(ctx, "GET", key)
if err != nil {
return expireCandidate{}, false, err
}
raw := strings.TrimSpace(res.String())
if raw == "" {
return expireCandidate{}, false, nil
}
var candidate expireCandidate
if err = json.Unmarshal([]byte(raw), &candidate); err != nil {
return expireCandidate{}, false, err
}
return candidate, true, nil
}
func removeExpireCandidate(ctx context.Context, setKey string, key string) {
key = strings.TrimSpace(key)
if key == "" {
return
}
if strings.TrimSpace(setKey) != "" {
_, _ = g.Redis().Do(ctx, "SREM", setKey, key)
}
_, _ = g.Redis().Do(ctx, "DEL", key)
}
func systemExpireWarnConfig(ctx context.Context, card entity.TSimCardInfo, phones string, emails string) warnConfig {
thresholds := systemExpireWarnThresholds(ctx)
return warnConfig{
TWarnConfig: entity.TWarnConfig{
Id: systemExpireConfigID,
TenantId: strings.TrimSpace(card.TenantId),
ItemType: warnItemTypeCard,
ItemId: card.Id,
ItemCode: strings.TrimSpace(card.Iccid),
WarnUnit: warnUnitDay,
FirstWarn: thresholds.First,
SecondWarn: thresholds.Second,
ThirdWarn: thresholds.Third,
WarnMethods: systemExpireWarnMethods(phones, emails),
WarnPhones: phones,
WarnEmails: emails,
AppNotifyEnabled: 1,
WxTemplateId: consts.WarningWechatTemplateIDDefault,
IsEnabled: 1,
CreatedDept: expireWarningDeptID(card),
CreatedBy: card.CreatedBy,
},
WarnType: warnTypeExpireDay,
}
}
func systemExpireWarnMethods(phones string, emails string) string {
methods := []string{warnMethodPlatform}
if strings.TrimSpace(emails) != "" {
methods = append(methods, warnMethodEmail)
}
if strings.TrimSpace(phones) != "" {
methods = append(methods, warnMethodSMS)
}
return strings.Join(methods, ",")
}
func (s *sSimWarning) resolveExpireWarningContacts(ctx context.Context, card entity.TSimCardInfo) (string, string, error) {
rows := make([]expireWarningContactRow, 0)
agentRows, err := queryExpireAgentContactRows(ctx, card)
if err != nil {
return "", "", err
}
rows = append(rows, agentRows...)
terminalRows, err := queryExpireTerminalContactRows(ctx, card)
if err != nil {
return "", "", err
}
rows = append(rows, terminalRows...)
return normalizeExpireContactRows(rows)
}
func queryExpireAgentContactRows(ctx context.Context, card entity.TSimCardInfo) ([]expireWarningContactRow, error) {
tenantID := strings.TrimSpace(card.TenantId)
deptID := expireWarningDeptID(card)
if tenantID == "" || (deptID <= 0 && card.CreatedBy <= 0) {
return nil, nil
}
columns := dao.SysUser.Columns()
mod := dao.SysUser.Ctx(ctx).
Fields(columns.UserId, columns.Email, columns.Phonenumber).
Where(columns.TenantId, tenantID).
Where(columns.UserType, consts.SysUserTypeSys).
Where(columns.Status, consts.SysDeptStatusNormal)
switch {
case deptID > 0 && card.CreatedBy > 0:
mod = mod.Where("("+columns.DeptId+" = ? OR "+columns.UserId+" = ?)", deptID, card.CreatedBy)
case deptID > 0:
mod = mod.Where(columns.DeptId, deptID)
default:
mod = mod.Where(columns.UserId, card.CreatedBy)
}
var rows []expireWarningContactRow
err := mod.Scan(&rows)
return rows, err
}
func queryExpireTerminalContactRows(ctx context.Context, card entity.TSimCardInfo) ([]expireWarningContactRow, error) {
rows := make([]expireWarningContactRow, 0)
tenantID := strings.TrimSpace(card.TenantId)
if tenantID == "" {
return rows, nil
}
createdRows, err := queryExpireCreatedTerminalContactRows(ctx, tenantID, card.CreatedBy)
if err != nil {
return nil, err
}
rows = append(rows, createdRows...)
iccid := strings.TrimSpace(card.Iccid)
msisdn := strings.TrimSpace(card.Msisdn)
if iccid == "" && msisdn == "" {
return rows, nil
}
mod := g.DB().Model(simTerminalTable+" st").Ctx(ctx).Safe().
LeftJoin(terminalTable+" t", "t.terminal_id = st.terminal_id AND t.tenant_id = st.tenant_id").
LeftJoin(dao.SysUser.Table()+" u", "(u.user_id = t.leader OR u.user_id = st.terminal_id) AND u.tenant_id = st.tenant_id").
Fields("DISTINCT u.user_id, u.email, u.phonenumber").
Where("st.tenant_id", tenantID).
Where("u.user_type", consts.SysUserTypeAPP).
Where("u.status", consts.SysDeptStatusNormal)
if iccid != "" && msisdn != "" {
mod = mod.Where("(st.iccid = ? OR st.msisdn = ?)", iccid, msisdn)
} else if iccid != "" {
mod = mod.Where("st.iccid", iccid)
} else {
mod = mod.Where("st.msisdn", msisdn)
}
var relatedRows []expireWarningContactRow
if err = mod.Scan(&relatedRows); err != nil {
return nil, err
}
rows = append(rows, relatedRows...)
return rows, nil
}
func queryExpireCreatedTerminalContactRows(ctx context.Context, tenantID string, createdBy int64) ([]expireWarningContactRow, error) {
if strings.TrimSpace(tenantID) == "" || createdBy <= 0 {
return nil, nil
}
columns := dao.SysUser.Columns()
var rows []expireWarningContactRow
err := dao.SysUser.Ctx(ctx).
Fields(columns.UserId, columns.Email, columns.Phonenumber).
Where(columns.TenantId, tenantID).
Where(columns.UserId, createdBy).
Where(columns.UserType, consts.SysUserTypeAPP).
Where(columns.Status, consts.SysDeptStatusNormal).
Scan(&rows)
return rows, err
}
func normalizeExpireContactRows(rows []expireWarningContactRow) (string, string, error) {
phoneSeen := make(map[string]struct{})
emailSeen := make(map[string]struct{})
phones := make([]string, 0)
emails := make([]string, 0)
for _, row := range rows {
phone := strings.TrimSpace(row.Phonenumber)
if phone != "" && isValidConfigWarnPhone(phone) {
phones = appendUniqueString(phones, phoneSeen, phone)
}
email := strings.TrimSpace(row.Email)
if email != "" {
address, err := mail.ParseAddress(email)
if err == nil && address != nil && strings.TrimSpace(address.Address) != "" {
emails = appendUniqueString(emails, emailSeen, strings.ToLower(strings.TrimSpace(address.Address)))
}
}
}
sort.Strings(phones)
sort.Strings(emails)
return strings.Join(phones, ","), strings.Join(emails, ","), nil
}
func appendUniqueString(values []string, seen map[string]struct{}, value string) []string {
value = strings.TrimSpace(value)
if value == "" {
return values
}
key := strings.ToLower(value)
if _, ok := seen[key]; ok {
return values
}
seen[key] = struct{}{}
return append(values, value)
}
func expireWarningDeptID(card entity.TSimCardInfo) int64 {
if card.CreatedDept > 0 {
return card.CreatedDept
}
return card.DeptId
}
func expirePreloadTargetMonth(runTime time.Time) (time.Time, time.Time) {
begin := calendarMonthBegin(runTime)
targetBegin := begin.AddDate(0, 2, 0)
return targetBegin, targetBegin.AddDate(0, 1, 0)
}
func expireCandidateScanMonths(runTime time.Time) []string {
begin := calendarMonthBegin(runTime)
seen := make(map[string]struct{}, 4)
months := make([]string, 0, 4)
for _, offset := range []int{-1, 0, 1, 2} {
month := begin.AddDate(0, offset, 0).Format("200601")
if _, ok := seen[month]; ok {
continue
}
seen[month] = struct{}{}
months = append(months, month)
}
return months
}
func calendarMonthBegin(runTime time.Time) time.Time {
if runTime.IsZero() {
runTime = time.Now()
}
location := runTime.Location()
local := runTime.In(location)
return time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, location)
}
func expireCandidateTTL(expireAt time.Time, runTime time.Time) time.Duration {
ttl := expireAt.Sub(runTime)
if ttl < warnDedupeMinTTL {
return warnDedupeMinTTL
}
return ttl
}
func expireCandidateKey(tenantID string, cardID int64, expireUnix int64) string {
tenantID = strings.TrimSpace(tenantID)
if tenantID == "" || cardID <= 0 || expireUnix <= 0 {
return ""
}
return fmt.Sprintf("%s:%s:%d:%d", expireCandidateKeyPrefix, tenantID, cardID, expireUnix)
}
func expireCandidateSetKey(targetMonth string) string {
targetMonth = strings.TrimSpace(targetMonth)
if targetMonth == "" {
targetMonth = "unknown"
}
return expireCandidateSetPrefix + ":" + targetMonth
}
func parseExpireCandidateKey(key string) (tenantID string, cardID int64, expireUnix int64, ok bool) {
key = strings.TrimSpace(key)
prefix := expireCandidateKeyPrefix + ":"
if !strings.HasPrefix(key, prefix) {
return "", 0, 0, false
}
parts := strings.Split(strings.TrimPrefix(key, prefix), ":")
if len(parts) != 3 {
return "", 0, 0, false
}
cardID, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || cardID <= 0 {
return "", 0, 0, false
}
expireUnix, err = strconv.ParseInt(parts[2], 10, 64)
if err != nil || expireUnix <= 0 {
return "", 0, 0, false
}
return parts[0], cardID, expireUnix, true
}
@@ -0,0 +1,43 @@
package simwarning
import (
"context"
"github.com/gogf/gf/v2/frame/g"
)
const (
defaultExpireFirstWarnDays = 30
defaultExpireSecondWarnDays = 15
defaultExpireThirdWarnDays = 7
)
type expireWarnThresholds struct {
First float64
Second float64
Third float64
}
func systemExpireWarnThresholds(ctx context.Context) expireWarnThresholds {
first := g.Cfg().MustGet(ctx, "warning.expire.firstWarnDays", defaultExpireFirstWarnDays).Int()
second := g.Cfg().MustGet(ctx, "warning.expire.secondWarnDays", defaultExpireSecondWarnDays).Int()
third := g.Cfg().MustGet(ctx, "warning.expire.thirdWarnDays", defaultExpireThirdWarnDays).Int()
if first <= 0 || second <= 0 || third <= 0 || !(first > second && second > third) {
g.Log().Warningf(ctx, "SimWarning expire threshold config invalid, first:%d, second:%d, third:%d, use default:%d/%d/%d",
first,
second,
third,
defaultExpireFirstWarnDays,
defaultExpireSecondWarnDays,
defaultExpireThirdWarnDays,
)
first = defaultExpireFirstWarnDays
second = defaultExpireSecondWarnDays
third = defaultExpireThirdWarnDays
}
return expireWarnThresholds{
First: float64(first),
Second: float64(second),
Third: float64(third),
}
}
@@ -0,0 +1,131 @@
package simwarning
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
func writeWarningRecord(ctx context.Context, hit warningHit, warnTime time.Time) (warningRecord, error) {
stored, err := writeWarningBatch(ctx, []pendingWarning{{Hit: hit}}, warnTime)
if err != nil {
return warningRecord{}, err
}
if len(stored) == 0 {
return warningRecord{}, nil
}
return stored[0].Record, nil
}
func writeWarningBatch(ctx context.Context, pendingWarnings []pendingWarning, warnTime time.Time) ([]storedWarning, error) {
if len(pendingWarnings) == 0 {
return nil, nil
}
storedWarnings := make([]storedWarning, 0, len(pendingWarnings))
err := dao.TWarnLog.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, pending := range pendingWarnings {
warnLogID, err := tx.Ctx(ctx).Model(dao.TWarnLog.Table()).Safe().
Data(buildWarnLogData(pending.Hit, warnTime)).
InsertAndGetId()
if err != nil {
return err
}
storedWarnings = append(storedWarnings, storedWarning{
Hit: pending.Hit,
Record: warningRecord{
WarnLogID: warnLogID,
},
})
}
return nil
})
return storedWarnings, err
}
func buildWarnLogData(hit warningHit, warnTime time.Time) do.TWarnLog {
var remainFlowMb any
if isTrafficKind(hit.Kind) {
remainFlowMb = hit.RemainFlowMb
}
return do.TWarnLog{
TenantId: hit.Config.TenantId,
ConfigId: hit.Config.Id,
ItemType: hit.Config.ItemType,
ItemId: hit.Config.ItemId,
ItemCode: warnItemCodeSnapshot(hit),
WarnStage: hit.WarnStage,
WarnLevel: hit.WarnLevel,
WarnUnit: hit.Config.WarnUnit,
ThresholdValue: hit.ThresholdValue,
ActualValue: hit.ActualValue,
TotalFlowMb: hit.TotalFlowMb,
RemainFlowMb: remainFlowMb,
WarnMethods: warnMethodsSnapshot(hit),
WarnPhones: hit.Config.WarnPhones,
WarnEmails: hit.Config.WarnEmails,
MessageLogId: nil,
WarnTime: gtime.NewFromTime(warnTime),
Remark: hit.Remark,
CreatedDept: hit.Config.CreatedDept,
CreatedBy: hit.Config.CreatedBy,
}
}
func logWarningHit(ctx context.Context, hit warningHit, record warningRecord) {
g.Log().Infof(
ctx,
"SimWarning hit stored, mode:warn_log_only, warnLogId:%d, kind:%s, configId:%d, tenantId:%s, itemType:%d, itemId:%d, itemCode:%s, warnUnit:%d, stage:%d, level:%d, threshold:%.3f, actual:%.3f, totalFlowMb:%.3f, remainFlowMb:%.3f, methods:%s, phones:%s, emails:%s, remark:%s",
record.WarnLogID,
hit.Kind,
hit.Config.Id,
hit.Config.TenantId,
hit.Config.ItemType,
hit.Config.ItemId,
warnItemCodeSnapshot(hit),
hit.Config.WarnUnit,
hit.WarnStage,
hit.WarnLevel,
hit.ThresholdValue,
hit.ActualValue,
hit.TotalFlowMb,
hit.RemainFlowMb,
warnMethodsSnapshot(hit),
hit.Config.WarnPhones,
hit.Config.WarnEmails,
hit.Remark,
)
}
func logWarningBatch(ctx context.Context, storedWarnings []storedWarning) {
if len(storedWarnings) == 0 {
return
}
g.Log().Infof(ctx, "SimWarning warning batch stored, mode:warn_log_only, warnings:%d", len(storedWarnings))
}
func warnItemCodeSnapshot(hit warningHit) string {
if hit.Config.ItemCode != "" {
return hit.Config.ItemCode
}
if hit.Config.ItemType == warnItemTypeFlowPool && hit.Pool.FlowPoolCode != "" {
return hit.Pool.FlowPoolCode
}
if hit.Card.Iccid != "" {
return hit.Card.Iccid
}
return hit.Card.Msisdn
}
func warnMethodsSnapshot(hit warningHit) string {
if hit.Config.WarnMethods != "" {
return hit.Config.WarnMethods
}
return warnMessageDefaultMethod
}
@@ -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 // 已投递;若标记失败,上层分别记录成功投递与去重错误。
}
@@ -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), &params); 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"]), &params); 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)
}
}
@@ -0,0 +1,108 @@
package simwarning
import (
"context"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/mcache"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/warningnotify"
"github.com/gogf/gf/v2/frame/g"
)
func notifyStoredWarnings(ctx context.Context, storedWarnings []storedWarning, warnTime time.Time) {
if len(storedWarnings) == 0 {
return
}
events := make([]warningnotify.Event, 0, len(storedWarnings))
for _, stored := range storedWarnings {
events = append(events, warningNotifyEvent(ctx, stored, warnTime))
}
if err := warningnotify.Dispatch(ctx, events); err != nil {
g.Log().Errorf(ctx, "SimWarning notify dispatch failed, warnings:%d, err:%+v", len(storedWarnings), err)
}
}
func warningNotifyEvent(ctx context.Context, stored storedWarning, warnTime time.Time) warningnotify.Event {
hit := stored.Hit
operationText := ""
affectOperation := autoDataCutEnabled(hit)
if affectOperation {
_, operationText = autoDataCutEligibility(hit)
}
return warningnotify.Event{
Business: warningnotify.BusinessSimWarning,
EventID: stored.Record.WarnLogID,
TenantID: hit.Config.TenantId,
DeptID: hit.Config.CreatedDept,
DeptName: warningDeptName(ctx, hit.Config.CreatedDept),
CreatedBy: hit.Config.CreatedBy,
Category: warningCategoryText(hit),
Kind: hit.Kind,
ItemType: hit.Config.ItemType,
ItemID: hit.Config.ItemId,
ItemCode: warnItemCodeSnapshot(hit),
MSISDN: hit.Card.Msisdn,
WarnStage: hit.WarnStage,
WarnLevel: hit.WarnLevel,
WarnMethods: warnMethodsSnapshot(hit),
WarnPhones: hit.Config.WarnPhones,
WarnEmails: hit.Config.WarnEmails,
AppNotifyEnabled: hit.Config.AppNotifyEnabled != 0,
WxTemplateID: hit.Config.WxTemplateId,
UnitText: warnUnitText(hit.Config.WarnUnit),
Threshold: hit.ThresholdValue,
Actual: hit.ActualValue,
TotalFlowMB: hit.TotalFlowMb,
RemainFlowMB: hit.RemainFlowMb,
Remark: hit.Remark,
WarnTime: warnTime,
PeriodBegin: hit.PeriodBegin,
PeriodEnd: hit.PeriodEnd,
AffectOperation: affectOperation,
OperationText: operationText,
}
}
func warningDeptName(ctx context.Context, deptID int64) string {
if deptID <= 0 {
return ""
}
deptName, err := mcache.GetDeptName(ctx, deptID)
if err != nil {
g.Log().Warningf(ctx, "SimWarning load dept name failed, deptId:%d, err:%+v", deptID, err)
return ""
}
return strings.TrimSpace(deptName)
}
func warningCategoryText(hit warningHit) string {
switch deriveWarnType(hit.Config.ItemType, hit.Config.WarnUnit) {
case warnTypeCardTraffic:
return "单卡流量预警"
case warnTypeFlowPoolPercent:
return "流量池预警"
case warnTypeExpireDay:
return "到期预警(天)"
case warnTypeExpireMonth:
return "到期预警(月)"
default:
return "预警"
}
}
func warnUnitText(unit int) string {
switch unit {
case warnUnitMb:
return "MB"
case warnUnitPercent:
return "%"
case warnUnitDay:
return "天"
case warnUnitMonth:
return "月"
default:
return ""
}
}
@@ -0,0 +1,379 @@
package simwarning
import (
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"math"
"time"
)
func evaluateConfig(cfg warnConfig, card entity.TSimCardInfo, runTime time.Time) (*warningHit, string) {
cfg.WarnType = normalizeWarnType(cfg.ItemType, cfg.WarnUnit)
switch cfg.WarnType {
case warnTypeCardTraffic:
return evaluateTraffic(cfg, card, runTime)
default:
return nil, "unsupported warn type"
}
}
func evaluateTraffic(cfg warnConfig, card entity.TSimCardInfo, runTime time.Time) (*warningHit, string) {
cfg.WarnType = normalizeWarnType(warnItemTypeCard, cfg.WarnUnit)
if cfg.WarnType != warnTypeCardTraffic {
return nil, "unsupported traffic warn type"
}
if cfg.WarnUnit != warnUnitMb && cfg.WarnUnit != warnUnitPercent {
return nil, "unsupported traffic warn unit"
}
if reason := validCommonCard(card); reason != "" {
return nil, reason
}
if card.PackageTotalFlowMb <= 0 {
return nil, "package total flow is empty"
}
usedFlowMb := card.UsedFlowMb
if usedFlowMb < 0 {
usedFlowMb = 0
}
totalFlowMb := card.PackageTotalFlowMb
remainFlowMb := totalFlowMb - usedFlowMb
if remainFlowMb < 0 {
remainFlowMb = 0
}
usedPercent := usedFlowMb / totalFlowMb * 100
var (
stage int
threshold float64
actual float64
kind string
)
if cfg.WarnUnit == warnUnitPercent {
first := normalizePercentThreshold(cfg.FirstWarn)
second := normalizePercentThreshold(cfg.SecondWarn)
third := normalizePercentThreshold(cfg.ThirdWarn)
stage, threshold = matchGreaterStage(usedPercent, first, second, third)
actual = usedPercent
kind = warnKindTrafficPercent
} else {
stage, threshold = matchGreaterStage(usedFlowMb, cfg.FirstWarn, cfg.SecondWarn, cfg.ThirdWarn)
actual = usedFlowMb
kind = warnKindTrafficMb
}
if stage == 0 {
return nil, "traffic threshold not reached"
}
level := warnLevelByStage(stage)
if cfg.WarnUnit == warnUnitPercent && usedPercent >= 100 {
level = warnLevelUrgent
}
remark := trafficRemark(cfg.WarnUnit, stage, threshold, usedFlowMb, usedPercent, totalFlowMb, remainFlowMb)
hit := baseHit(cfg, card, kind)
hit.WarnStage = stage
hit.WarnLevel = level
hit.ThresholdValue = threshold
hit.ActualValue = round(actual, 3)
hit.TotalFlowMb = round(totalFlowMb, 3)
hit.RemainFlowMb = round(remainFlowMb, 3)
hit.Remark = remark
setTrafficDedupeWindow(&hit, runTime)
return &hit, ""
}
func evaluatePoolTrafficPercent(cfg warnConfig, pool entity.TSimFlowPool, runTime time.Time) (*warningHit, string) {
cfg.WarnType = normalizeWarnType(warnItemTypeFlowPool, cfg.WarnUnit)
if cfg.WarnType != warnTypeFlowPoolPercent {
return nil, "unsupported flow pool warn type"
}
if cfg.WarnUnit != warnUnitPercent {
return nil, "flow pool only supports percent warning"
}
if pool.PoolStatus != 0 {
return nil, "flow pool is not normal"
}
if pool.TotalFlowMb <= 0 {
return nil, "flow pool total flow is empty"
}
usedFlowMb := pool.UsedFlowMb
if usedFlowMb < 0 {
usedFlowMb = 0
}
totalFlowMb := pool.TotalFlowMb
remainFlowMb := totalFlowMb - usedFlowMb
if remainFlowMb < 0 {
remainFlowMb = 0
}
usedPercent := usedFlowMb / totalFlowMb * 100
first := normalizePercentThreshold(cfg.FirstWarn)
second := normalizePercentThreshold(cfg.SecondWarn)
third := normalizePercentThreshold(cfg.ThirdWarn)
stage, threshold := matchGreaterStage(usedPercent, first, second, third)
if stage == 0 {
return nil, "flow pool traffic threshold not reached"
}
level := warnLevelByStage(stage)
if usedPercent >= 100 {
level = warnLevelUrgent
}
remark := fmt.Sprintf(
"流量池流量已达到第%d档阈值,阈值:%.2f%%,实际使用率:%.2f%%,实际已用:%.3f MB,剩余:%.3f MB,总量:%.3f MB",
stage,
threshold,
usedPercent,
usedFlowMb,
remainFlowMb,
totalFlowMb,
)
if usedPercent >= 100 {
remark += ",已超出流量池总量"
}
hit := basePoolHit(cfg, pool, warnKindPoolTrafficPercent)
hit.WarnStage = stage
hit.WarnLevel = level
hit.ThresholdValue = threshold
hit.ActualValue = round(usedPercent, 3)
hit.TotalFlowMb = round(totalFlowMb, 3)
hit.RemainFlowMb = round(remainFlowMb, 3)
hit.Remark = remark
setTrafficDedupeWindow(&hit, runTime)
return &hit, ""
}
func evaluateSystemExpireDay(cfg warnConfig, card entity.TSimCardInfo, runTime time.Time) (*warningHit, string) {
cfg.WarnType = normalizeWarnType(warnItemTypeCard, cfg.WarnUnit)
if cfg.WarnType != warnTypeExpireDay {
return nil, "unsupported expire day warn type"
}
if cfg.WarnUnit != warnUnitDay {
return nil, "unsupported expire day warn unit"
}
if reason := validCommonCard(card); reason != "" {
return nil, reason
}
if card.FlowPkgEndAt == nil || card.FlowPkgEndAt.IsZero() {
return nil, "package end time is empty"
}
endTime := card.FlowPkgEndAt.Time
remainDays := diffDateDays(runTime, endTime)
if remainDays < 0 {
return nil, "package expired"
}
stage, threshold := matchLessStage(float64(remainDays), cfg.FirstWarn, cfg.SecondWarn, cfg.ThirdWarn)
if stage == 0 {
return nil, "expire day threshold not reached"
}
hit := baseHit(cfg, card, warnKindExpireDay)
hit.WarnStage = stage
hit.WarnLevel = warnLevelByStage(stage)
hit.ThresholdValue = threshold
hit.ActualValue = float64(remainDays)
hit.TotalFlowMb = 0
hit.RemainFlowMb = 0
hit.Remark = fmt.Sprintf("套餐将在 %d 天后到期", remainDays)
setExpireDayDedupeWindow(&hit, runTime)
return &hit, ""
}
func validCommonCard(card entity.TSimCardInfo) string {
if card.PackageStatus != consts.PackageStatusEffective {
return "package is not effective"
}
if card.IsFlowPool != 0 {
return "flow pool card is not supported"
}
if card.CurrentPackageCode == "" {
return "current package code is empty"
}
return ""
}
func baseHit(cfg warnConfig, card entity.TSimCardInfo, kind string) warningHit {
hit := warningHit{
Config: cfg,
Card: card,
Kind: kind,
}
if card.FlowPkgBeginAt != nil && !card.FlowPkgBeginAt.IsZero() {
hit.PeriodBegin = card.FlowPkgBeginAt.Time
}
if card.FlowPkgEndAt != nil && !card.FlowPkgEndAt.IsZero() {
hit.PeriodEnd = card.FlowPkgEndAt.Time
}
if hit.PeriodBegin.IsZero() && !hit.PeriodEnd.IsZero() {
hit.PeriodBegin = warnPeriodFallbackBegin(hit.PeriodEnd.Location())
}
return hit
}
func basePoolHit(cfg warnConfig, pool entity.TSimFlowPool, kind string) warningHit {
return warningHit{
Config: cfg,
Pool: pool,
Kind: kind,
}
}
func warnLevelByStage(stage int) int {
switch stage {
case warnStageFirst:
return warnLevelNotice
case warnStageSecond:
return warnLevelWarning
case warnStageThird:
return warnLevelDanger
default:
return warnLevelNotice
}
}
func trafficRemark(warnUnit, stage int, threshold, usedFlowMb, usedPercent, totalFlowMb, remainFlowMb float64) string {
if warnUnit == warnUnitPercent {
remark := fmt.Sprintf(
"单卡流量已达到第%d档阈值,阈值:%.2f%%,实际使用率:%.2f%%,实际已用:%.3f MB,剩余:%.3f MB,总量:%.3f MB",
stage,
threshold,
usedPercent,
usedFlowMb,
remainFlowMb,
totalFlowMb,
)
if usedPercent >= 100 {
return remark + ",已超出套餐流量"
}
return remark
}
return fmt.Sprintf(
"单卡流量已达到第%d档阈值,阈值:%.3f MB,实际已用:%.3f MB,剩余:%.3f MB,总量:%.3f MB",
stage,
threshold,
usedFlowMb,
remainFlowMb,
totalFlowMb,
)
}
func matchGreaterStage(actual, first, second, third float64) (int, float64) {
if third > 0 && actual >= third {
return warnStageThird, third
}
if second > 0 && actual >= second {
return warnStageSecond, second
}
if first > 0 && actual >= first {
return warnStageFirst, first
}
return 0, 0
}
func matchLessStage(actual, first, second, third float64) (int, float64) {
if third > 0 && actual <= third {
return warnStageThird, third
}
if second > 0 && actual <= second {
return warnStageSecond, second
}
if first > 0 && actual <= first {
return warnStageFirst, first
}
return 0, 0
}
func normalizePercentThreshold(value float64) float64 {
if value > 0 && value <= 1 {
return value * 100
}
return value
}
func diffDateDays(runTime, endTime time.Time) int {
location := runTime.Location()
runDate := time.Date(runTime.In(location).Year(), runTime.In(location).Month(), runTime.In(location).Day(), 0, 0, 0, 0, location)
endLocal := endTime.In(location)
endDate := time.Date(endLocal.Year(), endLocal.Month(), endLocal.Day(), 0, 0, 0, 0, location)
return int(endDate.Sub(runDate).Hours() / 24)
}
func round(value float64, places int) float64 {
pow := math.Pow10(places)
return math.Round(value*pow) / pow
}
func warnPeriodFallbackBegin(location *time.Location) time.Time {
if location == nil {
location = time.Local
}
return time.Date(1970, 1, 1, 0, 0, 0, 0, location)
}
func setTrafficDedupeWindow(hit *warningHit, runTime time.Time) {
monthBegin, monthEnd := calendarMonthWindow(runTime)
hit.WindowBegin = monthBegin
hit.WindowEnd = monthEnd
hit.DedupeExpireAt = monthEnd
}
func calendarMonthWindow(runTime time.Time) (time.Time, time.Time) {
if runTime.IsZero() {
runTime = time.Now()
}
location := runTime.Location()
local := runTime.In(location)
begin := time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, location)
return begin, begin.AddDate(0, 1, 0)
}
func setExpireDayDedupeWindow(hit *warningHit, runTime time.Time) {
if hit.PeriodEnd.IsZero() {
return
}
stageBegin := expireDayStageBegin(runTime, hit.PeriodEnd, hit.ThresholdValue)
stageEnd := expireDayStageEnd(runTime, *hit)
if !stageEnd.After(stageBegin) {
stageEnd = stageBegin.Add(24 * time.Hour)
}
hit.WindowBegin = stageBegin
hit.WindowEnd = stageEnd
hit.DedupeExpireAt = stageEnd
}
func expireDayStageBegin(runTime, endTime time.Time, threshold float64) time.Time {
location := runTime.Location()
endLocal := endTime.In(location)
endDate := time.Date(endLocal.Year(), endLocal.Month(), endLocal.Day(), 0, 0, 0, 0, location)
return endDate.AddDate(0, 0, -int(threshold))
}
func expireDayStageEnd(runTime time.Time, hit warningHit) time.Time {
if next, ok := nextExpireThreshold(hit); ok {
return expireDayStageBegin(runTime, hit.PeriodEnd, next)
}
location := runTime.Location()
endLocal := hit.PeriodEnd.In(location)
endDate := time.Date(endLocal.Year(), endLocal.Month(), endLocal.Day(), 0, 0, 0, 0, location)
return endDate.AddDate(0, 0, 1)
}
func nextExpireThreshold(hit warningHit) (float64, bool) {
switch hit.WarnStage {
case warnStageFirst:
if hit.Config.SecondWarn > 0 {
return hit.Config.SecondWarn, true
}
case warnStageSecond:
if hit.Config.ThirdWarn > 0 {
return hit.Config.ThirdWarn, true
}
}
return 0, false
}
@@ -0,0 +1,491 @@
package simwarning
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"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
)
type sSimWarning struct{}
func NewSimWarning() *sSimWarning {
return &sSimWarning{}
}
func init() {
service.RegisterSimWarning(NewSimWarning())
}
func (s *sSimWarning) Scan(ctx context.Context) {
s.scan(ctx, scanScopeAll)
}
func (s *sSimWarning) ScanTraffic(ctx context.Context) {
s.scan(ctx, scanScopeTraffic)
}
func (s *sSimWarning) ScanFlowPool(ctx context.Context) {
s.scan(ctx, scanScopeFlowPool)
}
func (s *sSimWarning) ScanExpire(ctx context.Context) {
s.ScanExpireCandidates(ctx)
}
func (s *sSimWarning) scan(ctx context.Context, scope scanScope) {
if ctx == nil {
ctx = context.Background()
}
scope = normalizeScanScope(scope)
runTime := time.Now()
stats := scanStats{}
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, lastID, scope)
if err != nil {
stats.ErrorCount++
g.Log().Errorf(ctx, "SimWarning.Scan query configs failed, page:%d, err:%+v", page, err)
break
}
if len(configs) == 0 {
break
}
lastID = configs[len(configs)-1].Id
stats.ConfigCount += len(configs)
cardMap, err := s.queryCards(ctx, configs)
if err != nil {
stats.ErrorCount++
g.Log().Errorf(ctx, "SimWarning.Scan query cards failed, page:%d, err:%+v", page, err)
break
}
poolMap, err := s.queryFlowPools(ctx, configs)
if err != nil {
stats.ErrorCount++
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
}
}
if err := collector.Flush(ctx); err != nil {
stats.ErrorCount++
g.Log().Errorf(ctx, "warning notification failed: %+v", err)
}
g.Log().Infof(
ctx,
"SimWarning.Scan end, configs:%d, hits:%d, duplicated:%d, skipped:%d, errors:%d, autoActions:%d, autoActionErrors:%d, scope:%s, mode:warn_log_only",
stats.ConfigCount,
stats.HitCount,
stats.DupeCount,
stats.SkipCount,
stats.ErrorCount,
stats.AutoActionCount,
stats.AutoActionErrors,
scope,
)
}
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).
Fields(
columns.Id,
columns.TenantId,
columns.ItemType,
columns.ItemId,
columns.ItemCode,
columns.WarnUnit,
columns.FirstWarn,
columns.SecondWarn,
columns.ThirdWarn,
columns.WarnMethods,
columns.WarnPhones,
columns.WarnEmails,
columns.AutoAction,
columns.AppNotifyEnabled,
columns.IsEnabled,
columns.WxTemplateId,
columns.CreatedDept,
columns.CreatedBy,
).
Where(columns.IsEnabled, 1)
switch scope {
case scanScopeTraffic:
mod = mod.Where(
columns.ItemType+" = ? AND "+columns.WarnUnit+" IN (?, ?)",
warnItemTypeCard,
warnUnitMb,
warnUnitPercent,
)
case scanScopeFlowPool:
mod = mod.Where(
columns.ItemType+" = ? AND "+columns.WarnUnit+" = ?",
warnItemTypeFlowPool,
warnUnitPercent,
)
case scanScopeExpire:
mod = mod.Where("1 = 0")
default:
mod = mod.Where(
"("+columns.ItemType+" = ? AND "+columns.WarnUnit+" IN (?, ?)) OR ("+
columns.ItemType+" = ? AND "+columns.WarnUnit+" = ?)",
warnItemTypeCard,
warnUnitMb,
warnUnitPercent,
warnItemTypeFlowPool,
warnUnitPercent,
)
}
entities := make([]entity.TWarnConfig, 0)
err := mod.WhereGT(columns.Id, lastID).OrderAsc(columns.Id).
Limit(scanPageSize).
Scan(&entities)
if err != nil {
return nil, err
}
configs := make([]warnConfig, 0, len(entities))
for i := range entities {
configs = append(configs, warnConfig{
TWarnConfig: entities[i],
WarnType: deriveWarnType(entities[i].ItemType, entities[i].WarnUnit),
})
}
return configs, nil
}
func normalizeScanScope(scope scanScope) scanScope {
switch scope {
case scanScopeTraffic, scanScopeFlowPool, scanScopeExpire:
return scope
default:
return scanScopeAll
}
}
func (s *sSimWarning) scanConfig(ctx context.Context, cfg warnConfig, card *entity.TSimCardInfo, pool *entity.TSimFlowPool, runTime time.Time, stats *scanStats) *pendingWarning {
if cfg.ItemType == warnItemTypeFlowPool {
return s.scanFlowPoolConfig(ctx, cfg, pool, runTime, stats)
}
return s.scanCardConfig(ctx, cfg, card, runTime, stats)
}
func (s *sSimWarning) scanCardConfig(ctx context.Context, cfg warnConfig, card *entity.TSimCardInfo, runTime time.Time, stats *scanStats) *pendingWarning {
if card == nil {
stats.SkipCount++
g.Log().Warningf(ctx, "SimWarning.Scan skipped, card not found, configId:%d, tenantId:%s, itemId:%d", cfg.Id, cfg.TenantId, cfg.ItemId)
return nil
}
hit, reason := evaluateConfig(cfg, *card, runTime)
if hit == nil {
stats.SkipCount++
g.Log().Debugf(ctx, "SimWarning.Scan skipped, configId:%d, cardId:%d, reason:%s", cfg.Id, card.Id, reason)
return nil
}
exists, err := s.existsInPeriod(ctx, *hit)
if err != nil {
stats.ErrorCount++
g.Log().Errorf(ctx, "SimWarning.Scan idempotent check failed, configId:%d, cardId:%d, err:%+v", cfg.Id, card.Id, err)
return nil
}
if exists {
stats.DupeCount++
g.Log().Infof(ctx, "SimWarning.Scan duplicated, configId:%d, cardId:%d, stage:%d", cfg.Id, card.Id, hit.WarnStage)
s.scanAutoAction(ctx, *hit, 0, runTime, stats)
return nil
}
acquired, dedupeKey, err := acquireWarningDedupe(ctx, *hit, runTime)
if err != nil {
stats.ErrorCount++
g.Log().Errorf(ctx, "SimWarning.Scan acquire dedupe failed, configId:%d, cardId:%d, stage:%d, err:%+v", cfg.Id, card.Id, hit.WarnStage, err)
return nil
}
if !acquired {
stats.DupeCount++
g.Log().Infof(ctx, "SimWarning.Scan duplicated by redis, configId:%d, cardId:%d, stage:%d", cfg.Id, card.Id, hit.WarnStage)
return nil
}
return &pendingWarning{Hit: *hit, DedupeKey: dedupeKey}
}
func (s *sSimWarning) scanFlowPoolConfig(ctx context.Context, cfg warnConfig, pool *entity.TSimFlowPool, runTime time.Time, stats *scanStats) *pendingWarning {
if pool == nil {
stats.SkipCount++
g.Log().Warningf(ctx, "SimWarning.Scan skipped, flow pool not found, configId:%d, tenantId:%s, itemId:%d", cfg.Id, cfg.TenantId, cfg.ItemId)
return nil
}
hit, reason := evaluatePoolTrafficPercent(cfg, *pool, runTime)
if hit == nil {
stats.SkipCount++
g.Log().Debugf(ctx, "SimWarning.Scan skipped, configId:%d, flowPoolId:%d, reason:%s", cfg.Id, pool.Id, reason)
return nil
}
exists, err := s.existsInPeriod(ctx, *hit)
if err != nil {
stats.ErrorCount++
g.Log().Errorf(ctx, "SimWarning.Scan idempotent check failed, configId:%d, flowPoolId:%d, err:%+v", cfg.Id, pool.Id, err)
return nil
}
if exists {
stats.DupeCount++
g.Log().Infof(ctx, "SimWarning.Scan duplicated, configId:%d, flowPoolId:%d, stage:%d", cfg.Id, pool.Id, hit.WarnStage)
return nil
}
acquired, dedupeKey, err := acquireWarningDedupe(ctx, *hit, runTime)
if err != nil {
stats.ErrorCount++
g.Log().Errorf(ctx, "SimWarning.Scan acquire dedupe failed, configId:%d, flowPoolId:%d, stage:%d, err:%+v", cfg.Id, pool.Id, hit.WarnStage, err)
return nil
}
if !acquired {
stats.DupeCount++
g.Log().Infof(ctx, "SimWarning.Scan duplicated by redis, configId:%d, flowPoolId:%d, stage:%d", cfg.Id, pool.Id, hit.WarnStage)
return nil
}
return &pendingWarning{Hit: *hit, DedupeKey: dedupeKey}
}
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
}
storedWarnings, err := writeWarningBatch(ctx, pendingWarnings, runTime)
if err != nil {
for _, pending := range pendingWarnings {
releaseWarningDedupe(ctx, pending.DedupeKey)
}
stats.ErrorCount++
g.Log().Errorf(ctx, "SimWarning.Scan write warning batch failed, warnings:%d, err:%+v", len(pendingWarnings), err)
return nil
}
stats.HitCount += len(storedWarnings)
logWarningBatch(ctx, storedWarnings)
for _, item := range storedWarnings {
logWarningHit(ctx, item.Hit, item.Record)
}
return storedWarnings
}
func (s *sSimWarning) scanAutoAction(ctx context.Context, hit warningHit, warnLogID int64, runTime time.Time, stats *scanStats) {
handled, err := s.handleAutoAction(ctx, hit, warnLogID, runTime)
if err != nil {
stats.ErrorCount++
stats.AutoActionErrors++
g.Log().Errorf(ctx, "SimWarning.Scan auto action failed, configId:%d, cardId:%d, stage:%d, warnLogId:%d, err:%+v", hit.Config.Id, hit.Card.Id, hit.WarnStage, warnLogID, err)
return
}
if handled {
stats.AutoActionCount++
}
}
func (s *sSimWarning) queryCards(ctx context.Context, configs []warnConfig) (map[string]*entity.TSimCardInfo, error) {
result := make(map[string]*entity.TSimCardInfo)
if len(configs) == 0 {
return result, nil
}
itemIds, tenantIds := configKeys(configs, warnItemTypeCard)
if len(itemIds) == 0 || len(tenantIds) == 0 {
return result, nil
}
cards := make([]entity.TSimCardInfo, 0, len(itemIds))
columns := dao.TSimCardInfo.Columns()
err := dao.TSimCardInfo.Ctx(ctx).
Fields(
columns.Id,
columns.Iccid,
columns.Msisdn,
columns.Imsi,
columns.CurrentPackageCode,
columns.PackageStatus,
columns.BossCardStatus,
columns.IsFlowPool,
columns.DataServiceStatus,
columns.FlowPkgBeginAt,
columns.FlowPkgEndAt,
columns.PackageTotalFlowMb,
columns.UsedFlowMb,
columns.ChanKey,
columns.TenantId,
columns.CreatedDept,
columns.CreatedBy,
).
WhereIn(columns.Id, itemIds).
WhereIn(columns.TenantId, tenantIds).
Scan(&cards)
if err != nil {
return nil, err
}
for i := range cards {
card := cards[i]
result[itemKey(card.TenantId, card.Id)] = &cards[i]
}
return result, nil
}
func (s *sSimWarning) queryFlowPools(ctx context.Context, configs []warnConfig) (map[string]*entity.TSimFlowPool, error) {
result := make(map[string]*entity.TSimFlowPool)
if len(configs) == 0 {
return result, nil
}
itemIds, tenantIds := configKeys(configs, warnItemTypeFlowPool)
if len(itemIds) == 0 || len(tenantIds) == 0 {
return result, nil
}
pools := make([]entity.TSimFlowPool, 0, len(itemIds))
columns := dao.TSimFlowPool.Columns()
err := dao.TSimFlowPool.Ctx(ctx).
Fields(
columns.Id,
columns.FlowPoolCode,
columns.TotalFlowMb,
columns.UsedFlowMb,
columns.RemainFlowMb,
columns.PoolStatus,
columns.TenantId,
columns.CreatedDept,
columns.CreatedBy,
).
WhereIn(columns.Id, itemIds).
WhereIn(columns.TenantId, tenantIds).
Scan(&pools)
if err != nil {
return nil, err
}
for i := range pools {
pool := pools[i]
result[itemKey(pool.TenantId, pool.Id)] = &pools[i]
}
return result, nil
}
func (s *sSimWarning) existsInPeriod(ctx context.Context, hit warningHit) (bool, error) {
windowBegin, windowEnd := warningWindow(hit)
if windowBegin.IsZero() || windowEnd.IsZero() {
return false, nil
}
columns := dao.TWarnLog.Columns()
count, err := dao.TWarnLog.Ctx(ctx).
Where(columns.TenantId, hit.Config.TenantId).
Where(columns.ConfigId, hit.Config.Id).
Where(columns.ItemType, hit.Config.ItemType).
Where(columns.ItemId, hit.Config.ItemId).
Where(columns.WarnUnit, hit.Config.WarnUnit).
Where(columns.WarnStage, hit.WarnStage).
Where(columns.WarnTime+" >= ?", windowBegin).
Where(columns.WarnTime+" < ?", windowEnd).
Count()
if err != nil {
if isTableNotExist(err) {
g.Log().Warningf(ctx, "SimWarning.Scan skip idempotent check, warn log table not found: %s", dao.TWarnLog.Table())
return false, nil
}
return false, err
}
return count > 0, nil
}
func isTableNotExist(err error) bool {
if err == nil {
return false
}
for err != nil {
text := strings.ToLower(err.Error())
if strings.Contains(text, "doesn't exist") ||
strings.Contains(text, "does not exist") ||
strings.Contains(text, "not exist") ||
strings.Contains(text, "no such table") ||
strings.Contains(text, "unknown table") {
return true
}
err = errors.Unwrap(err)
}
return false
}
func configKeys(configs []warnConfig, itemType int) ([]int64, []string) {
itemIdSet := make(map[int64]struct{})
tenantIdSet := make(map[string]struct{})
itemIds := make([]int64, 0, len(configs))
tenantIds := make([]string, 0, len(configs))
for _, cfg := range configs {
if itemType > 0 && cfg.ItemType != itemType {
continue
}
if cfg.ItemId > 0 {
if _, ok := itemIdSet[cfg.ItemId]; !ok {
itemIdSet[cfg.ItemId] = struct{}{}
itemIds = append(itemIds, cfg.ItemId)
}
}
if cfg.TenantId != "" {
if _, ok := tenantIdSet[cfg.TenantId]; !ok {
tenantIdSet[cfg.TenantId] = struct{}{}
tenantIds = append(tenantIds, cfg.TenantId)
}
}
}
return itemIds, tenantIds
}
func itemKey(tenantId string, itemId int64) string {
return tenantId + ":" + strconv.FormatInt(itemId, 10)
}
@@ -0,0 +1,105 @@
package simwarning
import (
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"time"
)
const (
warnItemTypeCard = 1
warnItemTypeFlowPool = 2
warnUnitMb = 1
warnUnitPercent = 2
warnUnitDay = 3
warnUnitMonth = 4
warnTypeCardTraffic = 1
warnTypeExpireDay = 2
warnTypeExpireMonth = 3
warnTypeFlowPoolPercent = 4
warnKindTrafficMb = "traffic_mb"
warnKindTrafficPercent = "traffic_percent"
warnKindPoolTrafficPercent = "pool_traffic_percent"
warnKindExpireDay = "expire_day"
warnKindExpireMonth = "expire_month"
warnStageFirst = 1
warnStageSecond = 2
warnStageThird = 3
warnLevelNotice = 2
warnLevelWarning = 3
warnLevelDanger = 4
warnLevelUrgent = 5
warnAutoActionDataCut = 3
warnMethodSMS = "0"
warnMethodEmail = "1"
warnMethodPhone = "2"
warnMethodPlatform = "3"
warnMessageDefaultMethod = warnMethodPlatform
scanPageSize = 200
systemExpireConfigID = -1
)
type scanScope string
const (
scanScopeAll scanScope = "all"
scanScopeTraffic scanScope = "traffic"
scanScopeFlowPool scanScope = "flow_pool"
scanScopeExpire scanScope = "expire"
)
type warningRecord struct {
WarnLogID int64
}
type pendingWarning struct {
Hit warningHit
DedupeKey string
}
type storedWarning struct {
Hit warningHit
Record warningRecord
}
type warnConfig struct {
entity.TWarnConfig
WarnType int `orm:"-"`
}
type warningHit struct {
Config warnConfig
Card entity.TSimCardInfo
Pool entity.TSimFlowPool
Kind string
WarnStage int
WarnLevel int
ThresholdValue float64
ActualValue float64
TotalFlowMb float64
RemainFlowMb float64
Remark string
PeriodBegin time.Time
PeriodEnd time.Time
WindowBegin time.Time
WindowEnd time.Time
DedupeExpireAt time.Time
}
type scanStats struct {
ConfigCount int
HitCount int
SkipCount int
DupeCount int
ErrorCount int
AutoActionCount int
AutoActionErrors int
}
@@ -0,0 +1,49 @@
package simwarning
import "github.com/gogf/gf/v2/errors/gerror"
func normalizeWarnType(itemType, warnUnit int) int {
return deriveWarnType(itemType, warnUnit)
}
func deriveWarnType(itemType, warnUnit int) int {
if itemType == warnItemTypeFlowPool {
return warnTypeFlowPoolPercent
}
switch warnUnit {
case warnUnitMb, warnUnitPercent:
return warnTypeCardTraffic
case warnUnitDay:
return warnTypeExpireDay
case warnUnitMonth:
return warnTypeExpireMonth
default:
return 0
}
}
func validateWarnTypeUnit(itemType, warnType, warnUnit int) error {
switch warnType {
case warnTypeCardTraffic:
if itemType != warnItemTypeCard {
return gerror.New("单卡流量预警只能配置单卡对象")
}
if warnUnit != warnUnitMb && warnUnit != warnUnitPercent {
return gerror.New("单卡流量预警仅支持MB或百分比单位")
}
case warnTypeExpireDay:
return gerror.New("日期预警由系统默认规则处理,不支持用户配置")
case warnTypeExpireMonth:
return gerror.New("日期预警由系统默认规则处理,不支持用户配置")
case warnTypeFlowPoolPercent:
if itemType != warnItemTypeFlowPool {
return gerror.New("流量池预警只能配置流量池对象")
}
if warnUnit != warnUnitPercent {
return gerror.New("流量池预警仅支持百分比单位")
}
default:
return gerror.New("预警类型无效")
}
return nil
}