chore: 初始开源发布

iotlcm-admin-svr:管理后台服务。

- Go module:iotlcm → git.5iot.com/iotlcm-public/iotlcm-admin-svr(729 文件)
- 凭据与地址脱敏:JWT signingKey(含代码里的 MustGet 默认值)、
  MySQL DSN、Redis 口令、告警邮件发件人邮箱全部改为占位;
  私网 IP → 文档地址段;库名 xiujie_admin → iotlcm
- 真实手机号/邮箱 → 合法 11 位编造号 / example.com
- 移除内部资产:CI 触发器、过时 DDL(sql/db_iotlcm.sql)、
  私有依赖 go get 行、明文 registry 登录口令
- 启动 banner 统一 IOTLCM 方块字 + 社区惯例署名
- Dockerfile 改用公共基础镜像
- 拼错的 RADEME.md → README.md,README 重写
- 需要真实数据库的用例改为可用 IOTLCM_TEST_WITH_DB=1 显式启用
- 补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes

数据库 schema 不随本仓发布(生产 DDL 已过时),README 已说明。
This commit is contained in:
MiFaZhan
2026-10-23 14:46:05 +08:00
commit 59eb34976a
1132 changed files with 138197 additions and 0 deletions
@@ -0,0 +1,953 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
import (
"context"
"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/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/cardflow"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/trafficcutoffconfig"
"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"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
)
type sIotTSimCardInfo struct{}
const (
simFlowInternalErrorCode = 9999
simFlowUpstreamUnavailableMessage = "同步卡流量失败,请稍后重试!"
// 移动 OneLink 在自身繁忙时会对逐卡查询随机返回该状态,实测占比接近 50%,
// 属上游侧瞬时状态,与我方系统无关,因此对用户展示为上游服务的提示。
simUpstreamBusyCode = 13000
simUpstreamBusyMessage = "上游系统服务忙,请稍后再试"
simUpstreamBusyForbiddenMessage = "上游系统禁止访问,请稍后再试"
)
func NewIotTSimCardInfo() *sIotTSimCardInfo {
return &sIotTSimCardInfo{}
}
func init() {
service.RegisterIotTSimCardInfo(NewIotTSimCardInfo())
}
// Model 卡信息表ORM模型
func (s *sIotTSimCardInfo) Model(ctx context.Context, option ...*handler.Option) *gdb.Model {
if len(option) == 0 {
option = append(option, &handler.Option{
FilterTenant: true,
FilterAuth: true,
})
}
return handler.Model(dao.TSimCardInfo.Ctx(ctx), option...)
}
// List 获取卡信息表列表
func (s *sIotTSimCardInfo) List(ctx context.Context, in *model.TSimCardInfoListParam) (list []*model.TSimCardInfoListModel, totalCount int, err error) {
mod := s.Model(ctx)
cardInfoTable := dao.TSimCardInfo.Table()
simChannels := dao.TSimChannels.Table()
sysDept := dao.SysDept.Table()
flowPackage := dao.TSimFlowPackageInfo.Table()
BatchMain := dao.BatchMain.Table()
// 字段过滤
mod = mod.FieldsPrefix(cardInfoTable, model.TSimCardInfoListModel{})
mod = mod.Fields(fmt.Sprintf(
"COALESCE(NULLIF(%s.%s, ''), %s.%s) as chanName, %s.%s as chanAlias",
simChannels,
dao.TSimChannels.Columns().ChanName,
cardInfoTable,
dao.TSimCardInfo.Columns().ChanKey,
simChannels,
dao.TSimChannels.Columns().ChanAlias,
))
// 部门名称改用标量子查询,不再 JOIN sys_dept。
// 原因:sys_dept 带 deleted_at 软删除字段,LEFT JOIN 后框架会把 `sys_dept.deleted_at IS NULL`
// 追加到 WHERE,使优化器对 sys_dept 选用 hash join;hash join 不保证输出行序,
// 导致 `ORDER BY id DESC` 无法借索引序提前终止,只能对全表 19 万行 filesort(生产实测 7.3s)。
// 标量子查询只对 LIMIT 后的 20 行求值,开销可忽略。
mod = mod.Fields(fmt.Sprintf(
"(SELECT d.%s FROM %s AS d WHERE d.%s = %s.%s AND d.%s IS NULL) as deptName",
dao.SysDept.Columns().DeptName,
sysDept,
dao.SysDept.Columns().DeptId,
cardInfoTable,
dao.TSimCardInfo.Columns().DeptId,
dao.SysDept.Columns().DeletedAt,
))
mod = mod.Fields(fmt.Sprintf(
"COALESCE(NULLIF(%s.%s, ''), %s.%s) as packageName",
flowPackage,
dao.TSimFlowPackageInfo.Columns().PackageName,
cardInfoTable,
dao.TSimCardInfo.Columns().CurrentPackageCode,
))
mod = mod.Fields(cardflow.RemainFlowExpr(cardInfoTable, flowPackage) + " as reminFlowMb")
if in.DeptId != 0 {
mod = mod.Where(dao.TSimCardInfo.Columns().DeptId, in.DeptId)
}
if in.CardValue != "" {
cardValueCondition := "(" + dao.TSimCardInfo.Columns().Msisdn + " = ? OR " +
dao.TSimCardInfo.Columns().Iccid + " = ? OR " +
dao.TSimCardInfo.Columns().Imsi + " = ?)"
mod = mod.Where(cardValueCondition, in.CardValue, in.CardValue, in.CardValue)
}
if in.Remark != "" {
mod = mod.Where(dao.TSimCardInfo.Columns().Remark+" LIKE ?", "%"+in.Remark+"%")
}
if in.Imei != "" {
mod = mod.Where(dao.TSimCardInfo.Columns().Imei, in.Imei)
}
if len(in.TrafficCutoffStatus) > 0 {
mod = mod.WhereIn(dao.TSimCardInfo.Columns().TrafficCutoffStatus, in.TrafficCutoffStatus)
}
// 查询运营商卡状态
if in.BossCardStatus != nil {
mod = mod.Where(dao.TSimCardInfo.Columns().BossCardStatus, in.BossCardStatus)
}
// 查询时间类型
if in.TimeType != nil {
switch *in.TimeType {
case 0: // 开卡时间
mod = mod.WhereBetween(dao.TSimCardInfo.Columns().BossOpenAt, in.Time[0], in.Time[1])
case 1: // 激活时间
mod = mod.WhereBetween(dao.TSimCardInfo.Columns().BossActivateAt, in.Time[0], in.Time[1])
case 2: //入库时间
mod = mod.LeftJoin(BatchMain, BatchMain+"."+dao.BatchMain.Columns().SourceBatchNo+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().InboundBatchNo)
mod = mod.WhereBetween(BatchMain+"."+dao.BatchMain.Columns().StartTime, in.Time[0], in.Time[1])
case 3: //出库时间
mod = mod.LeftJoin(BatchMain, BatchMain+"."+dao.BatchMain.Columns().SourceBatchNo+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().OutboundBatchNo)
mod = mod.WhereBetween(BatchMain+"."+dao.BatchMain.Columns().StartTime, in.Time[0], in.Time[1])
case 4: //到期时间
mod = mod.WhereBetween(dao.TSimCardInfo.Columns().FlowPkgEndAt, in.Time[0], in.Time[1])
case 5: //创建时间
mod = mod.WhereBetween(dao.TSimCardInfo.Columns().CreatedAt, in.Time[0], in.Time[1])
case 6: //流量同步时间
mod = mod.WhereBetween(dao.TSimCardInfo.Columns().FlowSyncTime, in.Time[0], in.Time[1])
case 7: //状态同步时间
mod = mod.WhereBetween(dao.TSimCardInfo.Columns().StatusSyncTime, in.Time[0], in.Time[1])
default:
mod = mod.WhereBetween(dao.TSimCardInfo.Columns().BossOpenAt, in.Time[0], in.Time[1])
}
}
// 查询查询条件
if in.Condition != nil {
switch *in.Condition {
case "msisdn": // MSISDN
mod = mod.Where(dao.TSimCardInfo.Columns().Msisdn+" >= ?", in.StartNum).
Where(dao.TSimCardInfo.Columns().Msisdn+" <= ?", in.EndNum)
case "iccid": // ICCID
mod = mod.Where(dao.TSimCardInfo.Columns().Iccid+" >= ?", in.StartNum).
Where(dao.TSimCardInfo.Columns().Iccid+" <= ?", in.EndNum)
case "imis": // IMSI
mod = mod.Where(dao.TSimCardInfo.Columns().Imsi+" >= ?", in.StartNum).
Where(dao.TSimCardInfo.Columns().Imsi+" <= ?", in.EndNum)
case "imei": // IMEI
mod = mod.Where(dao.TSimCardInfo.Columns().Imei+" >= ?", in.StartNum).
Where(dao.TSimCardInfo.Columns().Imei+" <= ?", in.EndNum)
}
}
// 查询维度
if in.Dimension != nil && in.DimensionStatus != nil && in.DimensionValue != nil {
switch *in.Dimension {
case 1: // 使用流量
mod = mod.Where(dao.TSimCardInfo.Columns().UsedFlowMb + *in.DimensionStatus + *in.DimensionValue)
case 2: // 剩余流量
mod = mod.Where(cardflow.RemainFlowExpr(cardInfoTable, flowPackage) + *in.DimensionStatus + *in.DimensionValue)
}
}
// 查询排序值
if in.SortValue != nil && in.SortType != nil {
var orderStr string
switch *in.SortValue {
case 1: // iccid
orderStr = dao.TSimCardInfo.Columns().Iccid
case 2: // msisdn
orderStr = dao.TSimCardInfo.Columns().Msisdn
case 3: // imsi
orderStr = dao.TSimCardInfo.Columns().Imsi
case 4: // 流量用量
orderStr = dao.TSimCardInfo.Columns().UsedFlowMb
case 5: // 激活日期
orderStr = dao.TSimCardInfo.Columns().BossActivateAt
case 6: // 流量到期日期
orderStr = dao.TSimCardInfo.Columns().FlowPkgEndAt
case 7: // 状态同步日期
orderStr = dao.TSimCardInfo.Columns().StatusSyncTime
case 8: // 出库时间
orderStr = dao.TSimCardInfo.Columns().OutStockTime
}
if *in.SortType == 0 {
mod = mod.Order(orderStr + " ASC")
} else {
mod = mod.Order(orderStr + " DESC")
}
}
// 查询通道编号
if in.ChanKey != nil {
mod = mod.Where(dao.TSimCardInfo.Columns().ChanKey, *in.ChanKey)
}
// 查询当前套餐编码
if in.CurrentPackageCode != nil {
mod = mod.Where(dao.TSimCardInfo.Columns().CurrentPackageCode, *in.CurrentPackageCode)
}
// 查询IMEI
if in.Imei != "" {
mod = mod.Where(dao.TSimCardInfo.Columns().Imei, in.Imei)
}
// 分页
mod = mod.Page(in.Page, in.PageSize)
// 排序
mod = mod.OrderDesc(dao.TSimCardInfo.Columns().Id)
// 部门软删除过滤改用反连接(NOT EXISTS),不再 JOIN sys_dept。
// 语义与原 LEFT JOIN + WHERE dept.deleted_at IS NULL 完全等价(已被软删除的部门下的卡同样不返回),
// 但不会引入 hash join,优化器得以按主键逆序扫描并在取满 20 行后立即终止。
mod = mod.Where(fmt.Sprintf(
"NOT EXISTS (SELECT 1 FROM %s AS d WHERE d.%s = %s.%s AND d.%s IS NOT NULL)",
sysDept,
dao.SysDept.Columns().DeptId,
cardInfoTable,
dao.TSimCardInfo.Columns().DeptId,
dao.SysDept.Columns().DeletedAt,
))
mod = mod.LeftJoin(simChannels, simChannels+"."+dao.TSimChannels.Columns().ChanKey+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().ChanKey+" AND "+simChannels+"."+dao.TSimChannels.Columns().TenantId+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().TenantId)
mod = mod.LeftJoin(flowPackage, flowPackage+"."+dao.TSimFlowPackageInfo.Columns().PackageCode+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().CurrentPackageCode+" AND "+flowPackage+"."+dao.TSimFlowPackageInfo.Columns().TenantId+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().TenantId)
// 操作人摘要信息
mod = mod.Hook(hook.MemberSummary)
// 查询数据
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取卡信息表列表失败,请稍后重试!")
return
}
if resolver, resolverErr := newCardDeptDisplayResolver(ctx); resolverErr != nil {
logCardDeptDisplayResolverError(ctx, resolverErr)
} else {
for _, item := range list {
if item != nil {
item.DeptName = resolver(item.DeptId, item.DeptName)
}
}
}
return
}
// Export 导出卡信息表
func (s *sIotTSimCardInfo) Export(ctx context.Context, in *model.TSimCardInfoListParam) (err error) {
list, totalCount, err := s.List(ctx, in)
if err != nil {
return
}
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
tags, err := convert.GetEntityDescTags(model.TSimCardInfoExportModel{})
if err != nil {
return
}
var (
fileName = "导出卡信息表-" + gctx.CtxId(ctx)
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
exports []*model.TSimCardInfoExportModel
)
if err = gconv.Scan(list, &exports); err != nil {
return
}
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
return
}
// Edit 修改/新增卡信息表
func (s *sIotTSimCardInfo) Edit(ctx context.Context, in *model.TSimCardInfoEditParam) (err error) {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
if err = s.validateTrafficCutoffConfig(ctx, tx, in); err != nil {
return
}
// 修改
if in.Id > 0 {
userId := contexts.GetUserId(ctx)
in.UpdatedBy = &userId
if in.TrafficCutoffStatus != nil && *in.TrafficCutoffStatus != 1 {
if _, err = tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
Where(dao.TSimCardInfo.Columns().Id, in.Id).
Data(do.TSimCardTrafficCutoffConfig{
TrafficCutoffCrossMonthHold: 0,
TrafficCutoffTriggeredAt: gdb.Raw("NULL"),
UpdatedBy: userId,
}).Update(); err != nil {
return
}
}
if _, err = s.Model(ctx).
Fields(model.TSimCardInfoUpdateFields{}).
WherePri(in.Id).
Data(in).
OmitNil().
Update(); err != nil {
g.Log().Error(ctx, "修改卡信息表失败", "id", in.Id, "error", err)
err = gerror.Wrap(err, "修改卡信息表失败,请稍后重试!")
}
return
}
// 新增
// 验证'Iccid'唯一
if err = xgorm.IsUnique(ctx, &dao.TSimCardInfo, g.Map{dao.TSimCardInfo.Columns().Iccid: in.Iccid}, "ICCID已存在", in.Id); err != nil {
return
}
// 验证'Msisdn'唯一
if err = xgorm.IsUnique(ctx, &dao.TSimCardInfo, g.Map{dao.TSimCardInfo.Columns().Msisdn: in.Msisdn}, "MSISDN/卡号已存在", in.Id); err != nil {
}
deptId := contexts.GetDeptId(ctx)
in.CreatedDept = &deptId
userId := contexts.GetUserId(ctx)
in.CreatedBy = &userId
in.CreatedAt = gtime.Now()
tenantId := contexts.GetTenantId(ctx)
in.TenantId = &tenantId
if _, err = s.Model(ctx, &handler.Option{FilterAuth: false}).
Fields(model.TSimCardInfoInsertFields{}).
Data(in).Insert(); err != nil {
err = gerror.Wrap(err, "新增卡信息表失败,请稍后重试!")
}
return
})
}
func (s *sIotTSimCardInfo) validateTrafficCutoffConfig(ctx context.Context, tx gdb.TX, in *model.TSimCardInfoEditParam) error {
if in == nil || (in.TrafficCutoffStatus == nil && in.TrafficCutoffThreshold == nil) {
return nil
}
if in.TrafficCutoffStatus != nil && *in.TrafficCutoffStatus == 0 && in.TrafficCutoffThreshold == nil {
threshold := float64(0)
in.TrafficCutoffThreshold = &threshold
}
var current struct {
Status int `orm:"traffic_cutoff_status"`
Threshold float64 `orm:"traffic_cutoff_threshold"`
}
if in.Id > 0 {
columns := dao.TSimCardInfo.Columns()
if err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
Fields(columns.TrafficCutoffStatus, columns.TrafficCutoffThreshold).
Where(columns.Id, in.Id).
Scan(&current); err != nil {
return gerror.Wrap(err, "查询卡达量断网配置失败")
}
}
if in.TrafficCutoffStatus != nil {
current.Status = *in.TrafficCutoffStatus
}
if in.TrafficCutoffThreshold != nil {
current.Threshold = *in.TrafficCutoffThreshold
}
return trafficcutoffconfig.ValidateConfig(current.Status, current.Threshold)
}
// Delete 删除卡信息表
func (s *sIotTSimCardInfo) Delete(ctx context.Context, in *model.TSimCardInfoDeleteParam) (err error) {
if _, err = s.Model(ctx).WherePri(in.Id).Unscoped().Delete(); err != nil {
err = gerror.Wrap(err, "删除卡信息表失败,请稍后重试!")
return
}
return
}
// View 获取卡信息表指定信息
func (s *sIotTSimCardInfo) View(ctx context.Context, in *model.TSimCardInfoViewParam) (res *model.TSimCardInfoViewModel, err error) {
mod := s.Model(ctx)
cardInfoTable := dao.TSimCardInfo.Table()
simChannels := dao.TSimChannels.Table()
sysDept := dao.SysDept.Table()
flowPackage := dao.TSimFlowPackageInfo.Table()
// BatchMain := dao.BatchMain.Table()
mod = mod.LeftJoin(simChannels, simChannels+"."+dao.TSimChannels.Columns().ChanKey+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().ChanKey+" AND "+simChannels+"."+dao.TSimChannels.Columns().TenantId+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().TenantId)
mod = mod.LeftJoin(sysDept, sysDept+"."+dao.SysDept.Columns().DeptId+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().DeptId)
mod = mod.LeftJoin(flowPackage, flowPackage+"."+dao.TSimFlowPackageInfo.Columns().PackageCode+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().CurrentPackageCode+" AND "+flowPackage+"."+dao.TSimFlowPackageInfo.Columns().TenantId+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().TenantId)
// 字段过滤
mod = mod.FieldsPrefix(dao.TSimCardInfo.Table(), model.TSimCardInfoListModel{})
mod = mod.Fields(fmt.Sprintf(
"COALESCE(NULLIF(%s.%s, ''), %s.%s) as chanName",
simChannels,
dao.TSimChannels.Columns().ChanName,
cardInfoTable,
dao.TSimCardInfo.Columns().ChanKey,
))
mod = mod.Fields(simChannels + ".chan_alias as chanAlias")
mod = mod.Fields(sysDept + ".dept_name as deptName")
mod = mod.Fields(flowPackage + ".package_name as packageName")
mod = mod.Fields(cardflow.RemainFlowExpr(cardInfoTable, flowPackage) + " as reminFlowMb")
mod = mod.WherePri(in.Id)
err = mod.Hook(hook.MemberSummary).Scan(&res)
if err != nil {
err = gerror.Wrap(err, "获取卡信息表信息,请稍后重试!")
return
}
if res != nil {
if resolver, resolverErr := newCardDeptDisplayResolver(ctx); resolverErr != nil {
logCardDeptDisplayResolverError(ctx, resolverErr)
} else {
res.DeptName = resolver(res.DeptId, res.DeptName)
}
}
return
}
// SyncSimCardFlow 同步SIM卡流量
func (s *sIotTSimCardInfo) SyncSimCardFlow(ctx context.Context, input *model.SyncSimCardFlowInput) (err error) {
in := model.SimDataUsageInput{
SimKeys: []*model.SimKey{
{
ICCID: input.Iccid,
},
},
}
out, err := service.UpstreamChan().QuerySimDatausage(ctx, &in)
if err != nil {
g.Log().Errorf(ctx, "SyncSimCardFlow query upstream failed, iccid:%s, err:%+v", input.Iccid, err)
return gerror.New(simFlowUpstreamUnavailableMessage)
}
if len(out) > 0 && out[0].Code != 0 {
g.Log().Errorf(ctx, "SyncSimCardFlow upstream business failure, iccid:%s, code:%d, message:%s", input.Iccid, out[0].Code, out[0].Message)
}
usage, err := validateSimDataUsageResult(out)
if err != nil {
return err
}
if _, err = s.Model(ctx).
Where(dao.TSimCardInfo.Columns().Iccid, input.Iccid).
Update(do.TSimCardInfo{
UsedFlowMb: usage.ResData.DataUsage,
FlowSyncTime: gtime.Now(),
}); err != nil {
g.Log().Errorf(ctx, "SyncSimCardFlow update card usage failed, iccid:%s, err:%+v", input.Iccid, err)
err = gerror.New("同步卡流量数据保存失败,请稍后重试!")
return
}
return nil
}
func validateSimDataUsageResult(out []model.SimBaseRes[model.SimDataUsageItem]) (model.SimBaseRes[model.SimDataUsageItem], error) {
if len(out) == 0 {
return model.SimBaseRes[model.SimDataUsageItem]{}, gerror.New(simFlowUpstreamUnavailableMessage)
}
if out[0].Code != 0 {
return model.SimBaseRes[model.SimDataUsageItem]{}, newSimDataUsageError(out[0].Code, out[0].Message)
}
return out[0], nil
}
func newSimDataUsageError(code int, message string) error {
message = strings.TrimSpace(message)
if code == simFlowInternalErrorCode || isInternalUpstreamError(message) {
return gerror.New(simFlowUpstreamUnavailableMessage)
}
// 上游返回的业务状态不直接暴露给用户,避免用户把上游问题误认为本平台故障。
if display := upstreamStatusDisplayMessage(code, message); display != "" {
return gerror.New(display)
}
if message == "" {
return gerror.Newf("同步卡流量失败(错误码:%d),请稍后重试!", code)
}
return gerror.Newf("同步卡流量失败(错误码:%d):%s", code, message)
}
// upstreamStatusDisplayMessage 把上游业务状态翻译成面向用户的提示。
// 返回空字符串表示该状态需要保留原始信息(调用方自行决定如何展示)。
//
// 目前只覆盖上游自身繁忙/受限这类「与用户操作无关、重试即可」的状态:
// 这类状态若把原始错误码抛给用户,会被误认为本平台出错。
func upstreamStatusDisplayMessage(code int, message string) string {
switch code {
case simUpstreamBusyCode:
return simUpstreamBusyMessage
}
// 上游可能用不同状态码返回同一种语义,用文案兜底识别。
normalized := strings.TrimSpace(message)
if normalized != "" && isUpstreamBusyMessage(normalized) {
return simUpstreamBusyMessage
}
if normalized != "" && isUpstreamForbiddenMessage(normalized) {
return simUpstreamBusyForbiddenMessage
}
return ""
}
func isUpstreamBusyMessage(message string) bool {
return strings.Contains(message, "系统忙")
}
func isUpstreamForbiddenMessage(message string) bool {
return strings.Contains(message, "禁止访问")
}
func isInternalUpstreamError(message string) bool {
lower := strings.ToLower(strings.TrimSpace(message))
if lower == "" {
return false
}
for _, marker := range []string{
"request failed",
"no host in request url",
"unsupported protocol scheme",
"context deadline exceeded",
"client.timeout",
"connection refused",
"dial tcp",
"http://",
"https://",
"timeout",
"unexpected eof",
} {
if strings.Contains(lower, marker) {
return true
}
}
return strings.Contains(message, "通道未实例化") || strings.Contains(message, "上游服务")
}
// SyncSimCardStatus 同步SIM卡状态
func (s *sIotTSimCardInfo) SyncSimCardStatus(ctx context.Context, input *model.SyncSimCardStatusInput) (err error) {
in := model.SimCardStatusInput{
SimKeys: []*model.SimKey{
{
ICCID: input.Iccid,
},
},
}
out, err := service.UpstreamChan().QuerySimCardStatus(ctx, &in)
if err != nil {
return gerror.Wrap(err, "同步卡状态失败,请稍后重试!")
}
status, err := validateSimCardStatusResult(out, "同步卡状态")
if err != nil {
return err
}
if _, err = s.Model(ctx).
Where(dao.TSimCardInfo.Columns().Iccid, input.Iccid).
Update(do.TSimCardInfo{
BossCardStatus: status.ResData.CardStatus,
StatusSyncTime: gtime.Now(),
}); err != nil {
err = gerror.Wrap(err, "同步卡状态失败,请稍后重试!")
return
}
s.syncBossActivateAtIfNeeded(ctx, input.Iccid, status.ResData.CardStatus)
return nil
}
func validateSimCardStatusResult(out []model.SimBaseRes[model.SimCardStatusItem], operation string) (model.SimBaseRes[model.SimCardStatusItem], error) {
if len(out) == 0 {
return model.SimBaseRes[model.SimCardStatusItem]{}, gerror.Newf("%s失败,请稍后重试!", operation)
}
if out[0].Code != 0 {
if display := upstreamStatusDisplayMessage(out[0].Code, out[0].Message); display != "" {
return model.SimBaseRes[model.SimCardStatusItem]{}, gerror.New(display)
}
return model.SimBaseRes[model.SimCardStatusItem]{}, gerror.Newf("%s失败,错误码:%d,错误信息:%s", operation, out[0].Code, out[0].Message)
}
if out[0].ResData.CardStatus != 0 {
if _, ok := consts.V3ApiSimBossCardStatusNameMap[out[0].ResData.CardStatus]; !ok {
return model.SimBaseRes[model.SimCardStatusItem]{}, gerror.Newf("%s失败,上游返回了不支持的卡状态:%d", operation, out[0].ResData.CardStatus)
}
}
return out[0], nil
}
func validateSimSessionResult(out []model.SimBaseRes[model.SimSessionItem]) (model.SimBaseRes[model.SimSessionItem], error) {
if len(out) == 0 {
return model.SimBaseRes[model.SimSessionItem]{}, gerror.New("查询SIM卡会话失败,请稍后重试!")
}
if out[0].Code != 0 {
if display := upstreamStatusDisplayMessage(out[0].Code, out[0].Message); display != "" {
return model.SimBaseRes[model.SimSessionItem]{}, gerror.New(display)
}
return model.SimBaseRes[model.SimSessionItem]{}, gerror.Newf("查询SIM卡会话失败,错误码:%d,错误信息:%s", out[0].Code, out[0].Message)
}
return out[0], nil
}
// resolveUpstreamSimKey loads the local channel binding before calling upstream.
func (s *sIotTSimCardInfo) resolveUpstreamSimKey(ctx context.Context, iccid string) (*model.SimKey, error) {
var card struct {
ChanKey string
Iccid string
Imsi string
Msisdn string
}
if err := s.Model(ctx).
Fields(
dao.TSimCardInfo.Columns().ChanKey,
dao.TSimCardInfo.Columns().Iccid,
dao.TSimCardInfo.Columns().Imsi,
dao.TSimCardInfo.Columns().Msisdn,
).
Where(dao.TSimCardInfo.Columns().Iccid, iccid).
Scan(&card); err != nil {
return nil, gerror.Wrap(err, `query SIM card channel failed`)
}
return &model.SimKey{
ChanKey: card.ChanKey,
ICCID: iccid,
IMSI: card.Imsi,
MSISDN: card.Msisdn,
}, nil
}
// QuerySimStatus 查询SIM卡状态
func (s *sIotTSimCardInfo) QuerySimStatus(ctx context.Context, input *model.QuerySimStatusInput) (res *model.QuerySimStatusOutput, err error) {
simKey, err := s.resolveUpstreamSimKey(ctx, input.ICCID)
if err != nil {
return nil, err
}
in := model.SimCardStatusInput{
SimKeys: []*model.SimKey{
simKey,
},
}
out, err := service.UpstreamChan().QuerySimCardStatus(ctx, &in)
if err != nil {
return nil, err
}
status, err := validateSimCardStatusResult(out, "查询SIM卡状态")
if err != nil {
return nil, err
}
res = &status.ResData
if _, err = s.Model(ctx).
Where(dao.TSimCardInfo.Columns().Iccid, input.ICCID).
Data(do.TSimCardInfo{
BossCardStatus: res.CardStatus,
StatusSyncTime: gtime.Now(),
}).Update(); err != nil {
err = gerror.Wrap(err, "更新SIM卡状态失败,请稍后重试!")
return
}
s.syncBossActivateAtIfNeeded(ctx, input.ICCID, res.CardStatus)
return res, nil
}
// syncBossActivateAtIfNeeded records the operator activation date once the
// operator confirms that a card is activated. It intentionally never replaces
// an existing value because activation time is historical source data.
func (s *sIotTSimCardInfo) syncBossActivateAtIfNeeded(ctx context.Context, iccid string, cardStatus int) {
if cardStatus != consts.V3ApiSimBossCardStatusActivated {
return
}
missingCount, err := s.Model(ctx).
Where(dao.TSimCardInfo.Columns().Iccid, iccid).
WhereNull(dao.TSimCardInfo.Columns().BossActivateAt).
Count()
if err != nil {
g.Log().Warningf(ctx, "check missing SIM activation date failed, iccid:%s, err:%v", iccid, err)
return
}
if missingCount == 0 {
return
}
output, err := service.UpstreamChan().QuerySimBasicInfo(ctx, &model.SimBasicInfoInput{
SimKeys: []*model.SimKey{{ICCID: iccid}},
})
if err != nil {
g.Log().Warningf(ctx, "query SIM activation date failed, iccid:%s, err:%v", iccid, err)
return
}
if len(output) == 0 || output[0].Code != 0 {
message := "empty response"
code := 0
if len(output) > 0 {
code = output[0].Code
message = output[0].Message
}
g.Log().Warningf(ctx, "query SIM activation date returned no usable data, iccid:%s, code:%d, message:%s", iccid, code, message)
return
}
activeDate := strings.TrimSpace(output[0].ResData.ActiveDate)
if activeDate == "" {
g.Log().Warningf(ctx, "query SIM activation date returned empty activeDate, iccid:%s", iccid)
return
}
activateAt, err := gtime.StrToTime(activeDate)
if err != nil {
g.Log().Warningf(ctx, "parse SIM activation date failed, iccid:%s, activeDate:%s, err:%v", iccid, activeDate, err)
return
}
if _, err = s.Model(ctx).
Where(dao.TSimCardInfo.Columns().Iccid, iccid).
WhereNull(dao.TSimCardInfo.Columns().BossActivateAt).
Update(do.TSimCardInfo{BossActivateAt: activateAt}); err != nil {
g.Log().Warningf(ctx, "save SIM activation date failed, iccid:%s, err:%v", iccid, err)
}
}
// QuerySimSession 查询SIM卡会话
func (s *sIotTSimCardInfo) QuerySimSession(ctx context.Context, input *model.QuerySimSessionInput) (res *model.QuerySimSessionOutput, err error) {
simKey, err := s.resolveUpstreamSimKey(ctx, input.ICCID)
if err != nil {
return nil, err
}
in := model.SimSessionInput{
SimKeys: []*model.SimKey{
simKey,
},
}
out, err := service.UpstreamChan().QuerySimSession(ctx, &in)
if err != nil {
return nil, err
}
session, err := validateSimSessionResult(out)
if err != nil {
return nil, err
}
res = &session.ResData
if imeiList, imeiErr := service.UpstreamChan().QuerySimImei(ctx, &model.SimImeiInput{
SimKeys: []*model.SimKey{
simKey,
},
}); imeiErr == nil && len(imeiList) > 0 {
res.Imei = imeiList[0].ResData.Imei
} else if imeiErr != nil {
g.Log().Warningf(ctx, "查询SIM卡实时IMEI失败,继续返回会话信息,iccid:%s, err:%v", input.ICCID, imeiErr)
}
return res, nil
}
// GetCardPackageOptions 获取卡套餐下拉选项(去重后的套餐编码列表,套餐状态为生效中)
func (s *sIotTSimCardInfo) GetCardPackageOptions(ctx context.Context, deptId int64) (res []*model.GetCardPackageOptionsRes, err error) {
mod := dao.TSimCardInfo.Ctx(ctx)
err = mod.Fields("DISTINCT "+dao.TSimCardInfo.Columns().CurrentPackageCode).
Where(dao.TSimCardInfo.Columns().PackageStatus, 2).
Where(dao.TSimCardInfo.Columns().DeptId, deptId).
Scan(&res)
if err != nil {
return nil, gerror.Wrap(err, "获取卡套餐选项失败,请稍后重试!")
}
return res, nil
}
// CalculatePoolStatistics 计算流量池统计数据
func (s *sIotTSimCardInfo) CalculatePoolStatistics(ctx context.Context, req *model.CalculatePoolStatisticsReq) (res *model.CalculatePoolStatisticsRes, err error) {
g.Log().Info(ctx, "计算流量池统计数据req:", req)
deptId := req.DeptId
res = &model.CalculatePoolStatisticsRes{}
// 容错处理:如果传入的 102 套餐列表为空,则直接返回空结果
if len(req.CardCodes102) == 0 {
return res, nil
}
// 1. 查询该流量池下所有 102 卡的总数量和总已用流量
type CardInfoStats struct {
TotalCardCount int64 `gconv:"total_card_count"`
TotalUsedFlowMb float64 `gconv:"total_used_flow_mb"`
}
var cardStats CardInfoStats
err = s.Model(ctx).
Where("dept_id", deptId).
WhereIn("current_package_code", req.CardCodes102).
Fields("COUNT(1) AS total_card_count, IFNULL(SUM(used_flow_mb), 0) AS total_used_flow_mb").
Scan(&cardStats)
if err != nil {
return nil, err
}
res.TotalCardCount = cardStats.TotalCardCount
res.TotalUsedFlowMb = cardStats.TotalUsedFlowMb
// 2. 查询单卡流量 MB(仅查第一个套餐,从流程套餐表查询)
type CardFlowInfo struct {
FlowMb float64 `gconv:"flow_mb"`
}
var cardFlowInfo CardFlowInfo
err = dao.TSimFlowPackageInfo.Ctx(ctx).
Where("package_code", req.CardCodes102[0]).
Fields("flow_mb").
Scan(&cardFlowInfo)
if err != nil {
return nil, err
}
res.CardFlowMb = cardFlowInfo.FlowMb
// 3. 计算套内总流量 PoolPackageFlowMb
res.PoolPackageFlowMb = float64(res.TotalCardCount) * res.CardFlowMb
// 4. 计算加油包流量 AddOnFlowMb (104 套餐)
if len(req.CardCodes104) > 0 {
g.Log().Info(ctx, "部门编号:", deptId)
g.Log().Info(ctx, "流量池编码:", req.PoolPackageCode)
g.Log().Info(ctx, "104套餐编码:", req.CardCodes104)
type FlowPackageInfo struct {
FlowMb float64 `gconv:"flow_mb"`
}
var flowPackageInfo FlowPackageInfo
bindTable := dao.TSimPoolBindPackage.Table()
packageTable := dao.TSimFlowPackageInfo.Table()
bindColumns := dao.TSimPoolBindPackage.Columns()
packageColumns := dao.TSimFlowPackageInfo.Columns()
now := gtime.Now()
err = dao.TSimPoolBindPackage.Ctx(ctx).
LeftJoin(packageTable, fmt.Sprintf("%s.%s = %s.%s AND %s.%s = %s.%s", bindTable, bindColumns.PackageCode, packageTable, packageColumns.PackageCode, bindTable, bindColumns.TenantId, packageTable, packageColumns.TenantId)).
Where(bindTable+"."+bindColumns.DeptId, deptId).
Where(bindTable+"."+bindColumns.PoolNo, req.PoolPackageCode).
Where(bindTable+"."+bindColumns.PackageType, 104).
Where(bindTable+"."+bindColumns.EffectiveStatus, consts.EffectiveStatusEffective).
WhereIn(bindTable+"."+bindColumns.PackageCode, req.CardCodes104).
Where(fmt.Sprintf("(%s.%s IS NULL OR %s.%s <= ?)", bindTable, bindColumns.EffectiveTime, bindTable, bindColumns.EffectiveTime), now).
Where(fmt.Sprintf("(%s.%s IS NULL OR %s.%s > ?)", bindTable, bindColumns.ExpectedExpireTime, bindTable, bindColumns.ExpectedExpireTime), now).
Fields(fmt.Sprintf("IFNULL(SUM(%s.%s), 0) AS flow_mb", packageTable, packageColumns.FlowMb)).
Scan(&flowPackageInfo)
if err != nil {
return nil, err
}
res.AddOnFlowMb = flowPackageInfo.FlowMb
}
// 5. 核心公式计算 & 状态判定
// 总流量MB = 套内总流量MB + 加油包流量MB
res.TotalFlowMb = res.PoolPackageFlowMb + res.AddOnFlowMb
// 剩余流量MB = 总流量MB - 总已用流量MB
res.RemainFlowMb = res.TotalFlowMb - res.TotalUsedFlowMb
// 状态判断:大于0正常(0),小于等于0停用(1)
if res.RemainFlowMb > 0 {
res.PoolStatus = 0
} else {
res.PoolStatus = 1
}
return res, nil
}
// GetCardsByPackageCodes 根据套餐编码列表获取卡列表
func (s *sIotTSimCardInfo) GetCardsByPackageCodes(ctx context.Context, req *model.GetCardsByPackageCodesParam) (list []*model.TSimCardInfoListModel, totalCount int, err error) {
mod := dao.TSimCardInfo.Ctx(ctx)
cardInfoTable := dao.TSimCardInfo.Table()
simChannels := dao.TSimChannels.Table()
sysDept := dao.SysDept.Table()
flowPackage := dao.TSimFlowPackageInfo.Table()
// 字段过滤
mod = mod.FieldsPrefix(cardInfoTable, model.TSimCardInfoListModel{})
mod = mod.Fields(fmt.Sprintf(
"COALESCE(NULLIF(%s.%s, ''), %s.%s) as chanName, %s.%s as chanAlias",
simChannels,
dao.TSimChannels.Columns().ChanName,
cardInfoTable,
dao.TSimCardInfo.Columns().ChanKey,
simChannels,
dao.TSimChannels.Columns().ChanAlias,
))
mod = mod.Fields(sysDept + ".dept_name as deptName")
mod = mod.Fields(fmt.Sprintf(
"COALESCE(NULLIF(%s.%s, ''), %s.%s) as packageName",
flowPackage,
dao.TSimFlowPackageInfo.Columns().PackageName,
cardInfoTable,
dao.TSimCardInfo.Columns().CurrentPackageCode,
))
mod = mod.Fields(cardflow.RemainFlowExpr(cardInfoTable, flowPackage) + " as reminFlowMb")
// 部门ID条件
if req.DeptId > 0 {
mod = mod.Where(cardInfoTable+"."+dao.TSimCardInfo.Columns().DeptId, req.DeptId)
}
// ICCID条件
if req.Iccid != "" {
mod = mod.Where(cardInfoTable+"."+dao.TSimCardInfo.Columns().Iccid, req.Iccid)
}
// 通道条件
if req.ChanKey != "" {
mod = mod.Where(cardInfoTable+"."+dao.TSimCardInfo.Columns().ChanKey, req.ChanKey)
}
// 卡状态条件
if req.BossCardStatus != nil {
mod = mod.Where(cardInfoTable+"."+dao.TSimCardInfo.Columns().BossCardStatus, *req.BossCardStatus)
}
if len(req.PackageCodes) > 0 {
mod = mod.WhereIn(cardInfoTable+"."+dao.TSimCardInfo.Columns().CurrentPackageCode, req.PackageCodes)
}
// LeftJoin
mod = mod.LeftJoin(simChannels, simChannels+"."+dao.TSimChannels.Columns().ChanKey+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().ChanKey+" AND "+simChannels+"."+dao.TSimChannels.Columns().TenantId+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().TenantId)
mod = mod.LeftJoin(sysDept, sysDept+"."+dao.SysDept.Columns().DeptId+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().DeptId)
mod = mod.LeftJoin(flowPackage, flowPackage+"."+dao.TSimFlowPackageInfo.Columns().PackageCode+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().CurrentPackageCode+" AND "+flowPackage+"."+dao.TSimFlowPackageInfo.Columns().TenantId+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().TenantId)
// 分页
mod = mod.Page(req.Page, req.PageSize)
// 排序
mod = mod.OrderDesc(cardInfoTable + "." + dao.TSimCardInfo.Columns().Id)
// 查询数据
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取卡列表失败,请稍后重试!")
return
}
if resolver, resolverErr := newCardDeptDisplayResolver(ctx); resolverErr != nil {
logCardDeptDisplayResolverError(ctx, resolverErr)
} else {
for _, item := range list {
if item != nil {
item.DeptName = resolver(item.DeptId, item.DeptName)
}
}
}
return
}