chore: 并入 iotlcm-admin-svr 历史到 admin-svr/(原提交 49818c6)
This commit is contained in:
@@ -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), ¶ms); err != nil || len(params) == 0 {
|
||||
return "", fmt.Errorf("参数模板必须为非空 JSON 字符串对象")
|
||||
}
|
||||
replacer := strings.NewReplacer("{count}", strconv.FormatInt(count, 10), "{expireDate}", expiryDate)
|
||||
for key, value := range params {
|
||||
params[key] = replacer.Replace(value)
|
||||
}
|
||||
encoded, err := json.Marshal(params)
|
||||
return string(encoded), err
|
||||
}
|
||||
|
||||
func monthlyExpirySentKey(n monthlyExpiryNotice, channel string) string {
|
||||
return fmt.Sprintf("iotlcm:monthly-expiry:%s:%s:%d:%s:%s", n.RunTime.In(monthlyExpiryLocation).Format("200601"), n.Company.TenantID, n.Company.DeptID, n.Summary.Month, channel)
|
||||
}
|
||||
|
||||
func deliverMonthlyExpiry(ctx context.Context, n monthlyExpiryNotice, channel string, send func() error) (bool, error) {
|
||||
key := monthlyExpirySentKey(n, channel)
|
||||
value, err := g.Redis().Do(ctx, "GET", key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if value.String() == "sent" {
|
||||
return false, nil
|
||||
}
|
||||
if err := send(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 外层任务锁串行化扫描;成功才记去重,两渠道互不阻断。
|
||||
// 外部网关和 Redis 无分布式事务,失败/未知结果须核对后手动重跑,不自动重试短信。
|
||||
_, err = g.Redis().Do(ctx, "SET", key, "sent", "EX", int(monthlyExpirySentTTL.Seconds()))
|
||||
return true, err // 已投递;若标记失败,上层分别记录成功投递与去重错误。
|
||||
}
|
||||
@@ -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), ¶ms); 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"]), ¶ms); 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
|
||||
}
|
||||
Reference in New Issue
Block a user