sync: 同步 v1.0.4 并脱敏配置与镜像
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/iot_excelize"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// 导出列白名单。
|
||||
//
|
||||
// 只放行白名单内的字段:前端传什么就导出什么,但传了名单外的字段一律忽略,
|
||||
// 避免误把内部字段(如 id、tenantId)带进对客户的导出文件。
|
||||
var baseColumnWhitelist = map[string]model.ExportColumn{
|
||||
"iccid": {
|
||||
Field: "iccid", Label: "ICCID",
|
||||
},
|
||||
"msisdn": {
|
||||
Field: "msisdn", Label: "MSISDN",
|
||||
},
|
||||
"imsi": {
|
||||
Field: "imsi", Label: "IMSI",
|
||||
},
|
||||
"imei": {
|
||||
Field: "imei", Label: "IMEI",
|
||||
},
|
||||
"usedFlowMb": {
|
||||
Field: "usedFlowMb", Label: "当月用量(MB)",
|
||||
},
|
||||
"remainFlowMb": {
|
||||
Field: "remainFlowMb", Label: "当月剩余用量(MB)",
|
||||
},
|
||||
"packageTotalMb": {
|
||||
Field: "packageTotalMb", Label: "套餐总流量(MB)",
|
||||
},
|
||||
"cardStatus": {
|
||||
Field: "cardStatus", Label: "卡状态", DictType: "t_boss_card_status",
|
||||
},
|
||||
"netModel": {
|
||||
Field: "netModel", Label: "网络类型", DictType: "t_net_model",
|
||||
},
|
||||
"operators": {
|
||||
Field: "operators", Label: "运营商",
|
||||
},
|
||||
"activateAt": {
|
||||
Field: "activateAt", Label: "激活时间",
|
||||
},
|
||||
"outStockTime": {
|
||||
Field: "outStockTime", Label: "出库时间",
|
||||
},
|
||||
"expireAt": {
|
||||
Field: "expireAt", Label: "到期时间",
|
||||
},
|
||||
"outboundBatch": {
|
||||
Field: "outboundBatch", Label: "出库批次",
|
||||
},
|
||||
"inboundBatch": {
|
||||
Field: "inboundBatch", Label: "入库批次",
|
||||
},
|
||||
"packageName": {
|
||||
Field: "packageName", Label: "当前套餐名称",
|
||||
},
|
||||
"deptName": {
|
||||
Field: "deptName", Label: "所属企业",
|
||||
},
|
||||
"remark": {
|
||||
Field: "remark", Label: "备注",
|
||||
},
|
||||
"extraRemark": {
|
||||
Field: "extraRemark", Label: "其他备注",
|
||||
},
|
||||
}
|
||||
|
||||
// baseDefaultOrder 未勾选列时的默认导出顺序,与卡信息列表的常用列保持一致。
|
||||
var baseDefaultOrder = []string{
|
||||
"iccid", "msisdn", "imsi", "imei", "usedFlowMb", "remainFlowMb", "packageTotalMb",
|
||||
"cardStatus", "netModel", "operators", "activateAt", "outStockTime", "expireAt",
|
||||
"outboundBatch", "inboundBatch", "packageName", "deptName", "remark", "extraRemark",
|
||||
}
|
||||
|
||||
// 查询结果与失败原因固定追加在末尾,保证「部分成功」时每条失败可追溯。
|
||||
const (
|
||||
queryResultField = "queryResult"
|
||||
failReasonField = "failReason"
|
||||
)
|
||||
|
||||
func baseDefaultTags() []string {
|
||||
tags := make([]string, 0, len(baseDefaultOrder)+2)
|
||||
for _, field := range baseDefaultOrder {
|
||||
tags = append(tags, baseColumnWhitelist[field].Label)
|
||||
}
|
||||
return append(tags, "查询结果", "失败原因")
|
||||
}
|
||||
|
||||
// baseMandatoryOrder 三码固定导出列。
|
||||
// 这三列是对账的锚点,文件里没有 ICCID/MSISDN/IMSI 就无法定位到卡,
|
||||
// 因此既不允许前端取消勾选,也不允许服务端被传参绕过。
|
||||
var baseMandatoryOrder = []string{"iccid", "msisdn", "imsi"}
|
||||
|
||||
// resolveBaseColumns 归一化前端传入的导出列:三码强制在前,其余去重后按白名单追加,并固定追加结果列。
|
||||
func resolveBaseColumns(in *model.SimCardQueryExportParam) ([]model.ExportColumn, error) {
|
||||
requested := decodeExportColumns(in)
|
||||
columns := make([]model.ExportColumn, 0, len(requested)+5)
|
||||
seen := make(map[string]struct{}, len(requested)+len(baseMandatoryOrder))
|
||||
for _, field := range baseMandatoryOrder {
|
||||
seen[field] = struct{}{}
|
||||
columns = append(columns, baseColumnWhitelist[field])
|
||||
}
|
||||
for _, column := range requested {
|
||||
field := strings.TrimSpace(column.Field)
|
||||
allowed, ok := baseColumnWhitelist[field]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[field]; dup {
|
||||
continue
|
||||
}
|
||||
seen[field] = struct{}{}
|
||||
// 表头以服务端白名单为准,忽略前端自定义文案,避免导出列与页面口径不一致。
|
||||
columns = append(columns, allowed)
|
||||
}
|
||||
// 未勾选任何可选列时,把剩余默认列补齐;三码已在前面,不会重复。
|
||||
if len(columns) == len(baseMandatoryOrder) {
|
||||
for _, field := range baseDefaultOrder {
|
||||
if _, dup := seen[field]; dup {
|
||||
continue
|
||||
}
|
||||
seen[field] = struct{}{}
|
||||
columns = append(columns, baseColumnWhitelist[field])
|
||||
}
|
||||
}
|
||||
// 结果列不允许被去掉:部分成功时用户必须能看到每条失败的原因。
|
||||
columns = append(columns,
|
||||
model.ExportColumn{Field: queryResultField, Label: "查询结果"},
|
||||
model.ExportColumn{Field: failReasonField, Label: "失败原因"},
|
||||
)
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
// decodeExportColumns 解析前端传入的导出列。
|
||||
// 优先解析 JSON 字符串(表单提交时数组套对象会被拆散),失败再退回对象数组。
|
||||
func decodeExportColumns(in *model.SimCardQueryExportParam) []model.ExportColumn {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
if raw := strings.TrimSpace(in.ExportColumnsJson); raw != "" {
|
||||
cols := make([]model.ExportColumn, 0)
|
||||
if err := gjson.New(raw).Scan(&cols); err == nil && len(cols) > 0 {
|
||||
return cols
|
||||
}
|
||||
}
|
||||
return in.ExportColumns
|
||||
}
|
||||
|
||||
// baseQuerySnapshot 落库的筛选条件快照。
|
||||
// 只留模式、条数和前若干条样本,不存全量号码,避免 query_params 膨胀。
|
||||
func baseQuerySnapshot(in *model.SimCardQueryExportParam, inputRows int) g.Map {
|
||||
snapshot := g.Map{
|
||||
"mode": in.Mode,
|
||||
"inputRows": inputRows,
|
||||
}
|
||||
switch in.Mode {
|
||||
case model.SimCardQueryModeRange:
|
||||
snapshot["dimension"] = in.Dimension
|
||||
snapshot["startNum"] = in.StartNum
|
||||
snapshot["endNum"] = in.EndNum
|
||||
case model.SimCardQueryModeInput:
|
||||
snapshot["batchInputPreview"] = previewText(in.BatchInput, 5)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func previewText(text string, limit int) []string {
|
||||
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||
out := make([]string, 0, limit)
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// roundMb 统一保留 3 位小数,与库表 decimal(18,3) 精度一致。
|
||||
func roundMb(value float64) float64 {
|
||||
return math.Round(value*1000) / 1000
|
||||
}
|
||||
|
||||
// usageHistoryWhitelist 月历史用量查询允许勾选的固定列。
|
||||
// 月份列是动态生成的,直接按月份值作 Field,不走白名单。
|
||||
var usageHistoryWhitelist = map[string]model.ExportColumn{
|
||||
"iccid": {Field: "iccid", Label: "ICCID"},
|
||||
"msisdn": {Field: "msisdn", Label: "MSISDN"},
|
||||
"imsi": {Field: "imsi", Label: "IMSI"},
|
||||
"outStockTime": {Field: "outStockTime", Label: "出库时间"},
|
||||
"expireAt": {Field: "expireAt", Label: "到期时间"},
|
||||
}
|
||||
|
||||
// usageHistoryOptionalFields 月历史用量里可勾选的固定列(三码固定导出,不在此列)。
|
||||
var usageHistoryOptionalFields = []string{"outStockTime", "expireAt"}
|
||||
|
||||
// resolveUsageHistoryColumns 生成月历史用量导出列。
|
||||
// 三码固定在最前且不可取消;月份列固定跟随导出口径,不允许前端自行增删。
|
||||
func resolveUsageHistoryColumns(in *model.SimCardUsageHistoryExportParam, months []string) []model.ExportColumn {
|
||||
requested := decodeExportColumns(&in.SimCardQueryExportParam)
|
||||
allowed := make(map[string]struct{}, len(requested))
|
||||
for _, column := range requested {
|
||||
field := strings.TrimSpace(column.Field)
|
||||
if _, ok := usageHistoryWhitelist[field]; ok {
|
||||
allowed[field] = struct{}{}
|
||||
}
|
||||
}
|
||||
columns := make([]model.ExportColumn, 0, len(usageHistoryFixedFields)+len(months)+2)
|
||||
for _, field := range baseMandatoryOrder {
|
||||
columns = append(columns, usageHistoryWhitelist[field])
|
||||
}
|
||||
// 未勾选任何可选列时按默认全出,命中筛选时按固定顺序保留。
|
||||
if len(allowed) == 0 {
|
||||
for _, field := range usageHistoryOptionalFields {
|
||||
columns = append(columns, usageHistoryWhitelist[field])
|
||||
}
|
||||
} else {
|
||||
for _, field := range usageHistoryOptionalFields {
|
||||
if _, ok := allowed[field]; ok {
|
||||
columns = append(columns, usageHistoryWhitelist[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, month := range months {
|
||||
columns = append(columns, model.ExportColumn{Field: month, Label: month})
|
||||
}
|
||||
columns = append(columns,
|
||||
model.ExportColumn{Field: queryResultField, Label: "查询结果"},
|
||||
model.ExportColumn{Field: failReasonField, Label: "失败原因"},
|
||||
)
|
||||
return columns
|
||||
}
|
||||
|
||||
func usageHistoryTags(months []string) []string {
|
||||
tags := make([]string, 0, len(usageHistoryFixedFields)+len(months)+2)
|
||||
for _, field := range usageHistoryFixedFields {
|
||||
tags = append(tags, usageHistoryWhitelist[field].Label)
|
||||
}
|
||||
tags = append(tags, months...)
|
||||
return append(tags, "查询结果", "失败原因")
|
||||
}
|
||||
|
||||
func usageHistorySnapshot(in *model.SimCardUsageHistoryExportParam, months []string, rows int) g.Map {
|
||||
snapshot := g.Map{
|
||||
"mode": in.Mode,
|
||||
"months": months,
|
||||
"inputRows": rows,
|
||||
}
|
||||
if in.Mode == model.SimCardQueryModeDept {
|
||||
snapshot["deptId"] = in.DeptId
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// UsageHistoryTemplate 生成月历史用量查询模板(与卡基础信息查询同一份模板口径)。
|
||||
func (s *Service) UsageHistoryTemplate(ctx context.Context) (*excelize.File, string, error) {
|
||||
return s.BaseTemplate(ctx)
|
||||
}
|
||||
|
||||
// BaseColumnOptions 卡基础信息查询可选导出列,供前端渲染勾选框。
|
||||
func (s *Service) BaseColumnOptions() []model.ExportColumn {
|
||||
options := make([]model.ExportColumn, 0, len(baseDefaultOrder))
|
||||
for _, field := range baseDefaultOrder {
|
||||
options = append(options, baseColumnWhitelist[field])
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// UsageHistoryColumnOptions 月历史用量查询可选固定列(不含动态月份列)。
|
||||
func (s *Service) UsageHistoryColumnOptions() []model.ExportColumn {
|
||||
options := make([]model.ExportColumn, 0, len(usageHistoryFixedFields))
|
||||
for _, field := range usageHistoryFixedFields {
|
||||
options = append(options, usageHistoryWhitelist[field])
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// UsageHistoryMonths 当前导出口径的月份清单,前端用它预览列顺序。
|
||||
func (s *Service) UsageHistoryMonths(now time.Time) []string {
|
||||
return LastCompleteMonths(now, usageHistoryMonths)
|
||||
}
|
||||
|
||||
// BaseTemplate 生成卡基础信息查询模板。
|
||||
// 三列都允许为空,但至少填一个;表头与导入解析的 RequiredHeaders 保持一致。
|
||||
func (s *Service) BaseTemplate(ctx context.Context) (*excelize.File, string, error) {
|
||||
f := excelize.NewFile()
|
||||
sheet := "卡基础信息查询"
|
||||
index, err := f.NewSheet(sheet)
|
||||
if err != nil {
|
||||
return nil, "", gerror.Wrap(err, "创建模板工作表失败")
|
||||
}
|
||||
f.SetActiveSheet(index)
|
||||
if err := f.DeleteSheet("Sheet1"); err != nil {
|
||||
return nil, "", gerror.Wrap(err, "清理默认工作表失败")
|
||||
}
|
||||
|
||||
headers := []string{"ICCID", "MSISDN", "IMSI"}
|
||||
for i, header := range headers {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
if err = f.SetCellValue(sheet, cell, header); err != nil {
|
||||
return nil, "", gerror.Wrap(err, "写入模板表头失败")
|
||||
}
|
||||
}
|
||||
if err = f.SetColWidth(sheet, "A", "C", 26); err != nil {
|
||||
return nil, "", gerror.Wrap(err, "设置模板列宽失败")
|
||||
}
|
||||
if err = iot_excelize.ApplyHeaderColumnsTextStyle(f); err != nil {
|
||||
g.Log().Warningf(ctx, "apply template style failed: %v", err)
|
||||
}
|
||||
|
||||
return f, "卡基础信息查询模板", nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
// DeptOptions 所属公司下拉项(树形)。
|
||||
//
|
||||
// 两点关键口径:
|
||||
// 1. 可见范围交给 handler.FilterAuth:顶级看全部,其他角色按自身数据范围。
|
||||
// 2. CardCount 是「含下级」的累计量,不是本部门直接挂的卡。
|
||||
// 原因:顶级公司(如示例企业)自身往往不挂卡,卡都在下级代理名下,
|
||||
// 只统计本部门会显示 0,看起来像没数据。
|
||||
func (s *Service) DeptOptions(ctx context.Context) ([]*model.SimCardQueryDeptOption, error) {
|
||||
deptColumns := dao.SysDept.Columns()
|
||||
depts := make([]*struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
ParentId int64 `orm:"parent_id"`
|
||||
}, 0, 256)
|
||||
|
||||
err := handler.Model(dao.SysDept.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(deptColumns.DeptId, deptColumns.DeptName, deptColumns.ParentId).
|
||||
Where(deptColumns.Status, 0).
|
||||
WhereNull(deptColumns.DeletedAt).
|
||||
OrderAsc(deptColumns.OrderNum).
|
||||
OrderAsc(deptColumns.DeptId).
|
||||
Scan(&depts)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取可选公司失败,请稍后重试")
|
||||
}
|
||||
if len(depts) == 0 {
|
||||
return []*model.SimCardQueryDeptOption{}, nil
|
||||
}
|
||||
|
||||
direct, err := directCardCountByDept(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subtree := accumulateSubtreeCount(depts, direct)
|
||||
|
||||
options := make([]*model.SimCardQueryDeptOption, 0, len(depts))
|
||||
for _, dept := range depts {
|
||||
options = append(options, &model.SimCardQueryDeptOption{
|
||||
DeptId: dept.DeptId,
|
||||
DeptName: dept.DeptName,
|
||||
CardCount: subtree[dept.DeptId],
|
||||
ParentId: dept.ParentId,
|
||||
})
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// directCardCountByDept 按部门统计直接挂载的卡数量。
|
||||
// 卡表同样过数据权限,防止公司列表被收窄、但计数把无权卡的量带出来。
|
||||
func directCardCountByDept(ctx context.Context) (map[int64]int64, error) {
|
||||
cardColumns := dao.TSimCardInfo.Columns()
|
||||
rows := make([]*struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
Total int64 `orm:"total"`
|
||||
}, 0, 256)
|
||||
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(fmt.Sprintf("%s AS dept_id, COUNT(1) AS total", cardColumns.DeptId)).
|
||||
Group(cardColumns.DeptId).
|
||||
Scan(&rows)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "统计公司卡数量失败")
|
||||
}
|
||||
result := make(map[int64]int64, len(rows))
|
||||
for _, row := range rows {
|
||||
result[row.DeptId] = row.Total
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// accumulateSubtreeCount 把「本部门直接卡数」沿部门树向上累加,得到含下级的卡数。
|
||||
//
|
||||
// 只累加本次查询可见的部门:无权部门根本不在 depts 里,其卡数也不会被计入,
|
||||
// 因此不会通过父级汇总把无权数据带出来。
|
||||
func accumulateSubtreeCount(
|
||||
depts []*struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
ParentId int64 `orm:"parent_id"`
|
||||
},
|
||||
direct map[int64]int64,
|
||||
) map[int64]int64 {
|
||||
visible := make(map[int64]struct{}, len(depts))
|
||||
children := make(map[int64][]int64, len(depts))
|
||||
roots := make([]int64, 0, len(depts))
|
||||
for _, dept := range depts {
|
||||
visible[dept.DeptId] = struct{}{}
|
||||
}
|
||||
for _, dept := range depts {
|
||||
if _, ok := visible[dept.ParentId]; ok && dept.ParentId != dept.DeptId {
|
||||
children[dept.ParentId] = append(children[dept.ParentId], dept.DeptId)
|
||||
continue
|
||||
}
|
||||
roots = append(roots, dept.DeptId)
|
||||
}
|
||||
|
||||
subtree := make(map[int64]int64, len(depts))
|
||||
// 部门树很浅,递归深度可控;用显式栈避免深层树爆栈。
|
||||
type frame struct {
|
||||
id int64
|
||||
child int
|
||||
amount int64
|
||||
}
|
||||
for _, rootID := range roots {
|
||||
stack := []*frame{{id: rootID, amount: direct[rootID]}}
|
||||
for len(stack) > 0 {
|
||||
top := stack[len(stack)-1]
|
||||
if kids := children[top.id]; top.child < len(kids) {
|
||||
childID := kids[top.child]
|
||||
top.child++
|
||||
stack = append(stack, &frame{id: childID, amount: direct[childID]})
|
||||
continue
|
||||
}
|
||||
subtree[top.id] = top.amount
|
||||
stack = stack[:len(stack)-1]
|
||||
if len(stack) > 0 {
|
||||
stack[len(stack)-1].amount += top.amount
|
||||
}
|
||||
}
|
||||
}
|
||||
return subtree
|
||||
}
|
||||
|
||||
// deptSubtreeIDs 返回某部门及其全部下级的 dept_id 子查询。
|
||||
// 与 DeptOptions 的统计口径保持一致:选中公司即包含下级公司。
|
||||
func deptSubtreeIDs(ctx context.Context, deptId int64) *gdb.Model {
|
||||
deptColumns := dao.SysDept.Columns()
|
||||
return handler.Model(dao.SysDept.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
}).Fields(deptColumns.DeptId).
|
||||
Where(fmt.Sprintf("%s = ? OR %s LIKE ?", deptColumns.DeptId, deptColumns.Ancestors),
|
||||
deptId, fmt.Sprintf("%%,%d,%%", deptId))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// buildRows 造 n 行「卡基础信息」级别的数据:22 列、字段长短接近真实。
|
||||
func buildRows(n int) [][]interface{} {
|
||||
rows := make([][]interface{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
id := strconv.Itoa(8986000000000000000 + i)
|
||||
rows[i] = []interface{}{
|
||||
id,
|
||||
"186" + strconv.Itoa(10000000+i),
|
||||
"46000" + strconv.Itoa(100000000+i),
|
||||
"86000000000000" + strconv.Itoa(i%100),
|
||||
float64(i % 5000),
|
||||
float64(5000 - i%5000),
|
||||
10240.0,
|
||||
2,
|
||||
4,
|
||||
"中国移动",
|
||||
"2026-01-01 00:00:00",
|
||||
"2025-12-01 00:00:00",
|
||||
"2027-12-31 23:59:59",
|
||||
"OUT20260101",
|
||||
"IN20251201",
|
||||
"标准套餐A",
|
||||
"示例企业",
|
||||
"备注内容示例",
|
||||
"",
|
||||
"成功",
|
||||
"",
|
||||
"",
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// TestExportWriteCost 量不同行数下「写 Excel + 生成文件」的真实耗时与体积。
|
||||
// 用来判断 20000 这个上限是否偏离实际能力,而不是拍脑袋。
|
||||
func TestExportWriteCost(t *testing.T) {
|
||||
for _, n := range []int{20000, 50000, 100000} {
|
||||
rows := buildRows(n)
|
||||
start := time.Now()
|
||||
|
||||
f := excelize.NewFile()
|
||||
sheet := f.GetSheetName(0)
|
||||
stream, err := f.NewStreamWriter(sheet)
|
||||
if err != nil {
|
||||
t.Fatalf("new stream writer: %v", err)
|
||||
}
|
||||
header := make([]interface{}, 22)
|
||||
for i := range header {
|
||||
header[i] = "列" + strconv.Itoa(i)
|
||||
}
|
||||
if err = stream.SetRow("A1", header); err != nil {
|
||||
t.Fatalf("write header: %v", err)
|
||||
}
|
||||
for i, row := range rows {
|
||||
cell, _ := excelize.CoordinatesToCellName(1, i+2)
|
||||
if err = stream.SetRow(cell, row); err != nil {
|
||||
t.Fatalf("write row %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if err = stream.Flush(); err != nil {
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
buf, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
t.Fatalf("write to buffer: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
t.Logf("rows=%6d write=%8.2fs size=%6.1fMB", n, elapsed.Seconds(), float64(buf.Len())/(1<<20))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
// Package simcardquery 卡基础信息查询 与 月历史用量查询。
|
||||
//
|
||||
// 两个功能都是「按号码批量查卡 → 异步导出」,共用同一套输入解析与导出列机制:
|
||||
// - 输入来源:Excel 模板导入 / 多行文本批量输入 / 连号号段;
|
||||
// - 号码识别:每行 1 个值(ICCID、MSISDN、IMSI 任意一种)或 3 个值(ICCID MSISDN IMSI),
|
||||
// 单值行在三列之间做 OR 匹配,因此不需要用户先选「用三码还是只用 ICCID」;
|
||||
// - 导出走通用异步导出中心,文件末尾固定带「查询结果」「失败原因」两列,
|
||||
// 未命中的输入也会出现在文件里,便于对账。
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"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/batchinput/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/cardflow"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// 查询结果列取值,导出文件与执行日志共用同一套文案。
|
||||
const (
|
||||
queryResultHit = "成功"
|
||||
queryResultMiss = "失败"
|
||||
)
|
||||
|
||||
// chunkSize 单次 IN 查询的号码条数。
|
||||
// 1000 是 MySQL 解析大量占位符仍能稳定走索引的量级,也和现有批量查询上限一致。
|
||||
const chunkSize = 1000
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func New() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
// inputLine 一行输入:可能是 1 个号码,也可能是 ICCID/MSISDN/IMSI 三码。
|
||||
type inputLine struct {
|
||||
RowNo int
|
||||
Values []string
|
||||
}
|
||||
|
||||
// cardBrief 查询命中的卡基础字段,按号码索引后复用。
|
||||
type cardBrief struct {
|
||||
Id int64 `orm:"id"`
|
||||
Iccid string `orm:"iccid"`
|
||||
Msisdn string `orm:"msisdn"`
|
||||
Imsi string `orm:"imsi"`
|
||||
Imei string `orm:"imei"`
|
||||
UsedFlowMb float64 `orm:"used_flow_mb"`
|
||||
PackageTotalMb float64 `orm:"query_total_flow_mb"`
|
||||
CardStatus int `orm:"boss_card_status"`
|
||||
NetModel int `orm:"net_model"`
|
||||
ChanKey string `orm:"chan_key"`
|
||||
ActivateAt *gtime.Time `orm:"boss_activate_at"`
|
||||
OutStockTime *gtime.Time `orm:"out_stock_time"`
|
||||
ExpireAt *gtime.Time `orm:"flow_pkg_end_at"`
|
||||
OutboundBatch string `orm:"outbound_batch_no"`
|
||||
InboundBatch string `orm:"inbound_batch_no"`
|
||||
PackageCode string `orm:"current_package_code"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
Remark string `orm:"remark"`
|
||||
}
|
||||
|
||||
// ExcelInput 上传文件的读取器工厂。
|
||||
// 每次调用返回新的读取器,保证后台解析时读到完整内容。
|
||||
type ExcelInput func() (io.Reader, error)
|
||||
|
||||
// ExportBase 卡基础信息查询导出。
|
||||
func (s *Service) ExportBase(ctx context.Context, in *model.SimCardQueryExportParam, input ExcelInput) (*exportcenter.SubmitResult, error) {
|
||||
lines, err := parseInputs(ctx, in, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columns, err := resolveBaseColumns(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats := &resolveStats{}
|
||||
once := &sync.Once{}
|
||||
var cached []*model.SimCardBaseQueryRow
|
||||
var cacheErr error
|
||||
resolve := func(ctx context.Context) ([]*model.SimCardBaseQueryRow, error) {
|
||||
once.Do(func() {
|
||||
if in.Mode == model.SimCardQueryModeRange {
|
||||
cached, cacheErr = s.resolveRangeRows(ctx, in, stats)
|
||||
return
|
||||
}
|
||||
cached, cacheErr = s.resolveBaseRows(ctx, lines, stats)
|
||||
})
|
||||
return cached, cacheErr
|
||||
}
|
||||
|
||||
return exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimCardBaseQuery",
|
||||
BizName: "卡基础信息查询",
|
||||
PermCode: "cpm:iot:tSimCardBaseQuery:view",
|
||||
SheetName: "卡基础信息",
|
||||
QueryParam: baseQuerySnapshot(in, len(lines)),
|
||||
Columns: columns,
|
||||
Tags: func() ([]string, error) {
|
||||
return baseDefaultTags(), nil
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) ([]interface{}, int, error) {
|
||||
// exportcenter 提交时会用 pageSize=1 做一次「总数探针」。
|
||||
// 直接返回输入行数(号段模式未知条数,返回 1 占位),让提交接口立即返回,
|
||||
// 真正的查库放到后台协程里做。
|
||||
if pageSize <= 1 && page <= 1 {
|
||||
if len(lines) > 0 {
|
||||
return nil, len(lines), nil
|
||||
}
|
||||
// 号段模式:号段包含多少个号码就是预计条数,与「查不查得到」无关。
|
||||
// 这样用户输入 10 个连续号,即使一条都查不到,页面也会显示「预计 10 条」。
|
||||
size, sizeErr := rangeSize(in)
|
||||
if sizeErr != nil {
|
||||
return nil, 0, sizeErr
|
||||
}
|
||||
return nil, int(size), nil
|
||||
}
|
||||
rows, err := resolve(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total := len(rows)
|
||||
start := (page - 1) * pageSize
|
||||
if start >= total {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
out := make([]interface{}, 0, end-start)
|
||||
for _, row := range rows[start:end] {
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, total, nil
|
||||
},
|
||||
Result: stats.resultSummary,
|
||||
})
|
||||
}
|
||||
|
||||
// parseInputs 按模式解析输入号码。
|
||||
func parseInputs(ctx context.Context, in *model.SimCardQueryExportParam, input ExcelInput) ([]inputLine, error) {
|
||||
switch in.Mode {
|
||||
case model.SimCardQueryModeInput:
|
||||
return parseTextInputs(ctx, in.BatchInput)
|
||||
case model.SimCardQueryModeExcel:
|
||||
if input == nil {
|
||||
return nil, gerror.New("请先上传 Excel 文件")
|
||||
}
|
||||
reader, err := input()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reader == nil {
|
||||
return nil, gerror.New("请先上传 Excel 文件")
|
||||
}
|
||||
lines, err := streamExcelInputs(ctx, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return nil, gerror.New("Excel 中没有可解析的号码")
|
||||
}
|
||||
return lines, nil
|
||||
case model.SimCardQueryModeRange:
|
||||
// 号段模式不展开成逐个号码:连号可能跨上百万个号,展开会把内存打满。
|
||||
// 只做校验,实际取数交给 resolveRangeRows 用 BETWEEN 条件分页查。
|
||||
return nil, parseRangeInputs(in)
|
||||
default:
|
||||
return nil, gerror.New("不支持的查询方式")
|
||||
}
|
||||
}
|
||||
|
||||
// parseRangeInputs 校验连号号段参数。
|
||||
func parseRangeInputs(in *model.SimCardQueryExportParam) error {
|
||||
start := strings.TrimSpace(in.StartNum)
|
||||
end := strings.TrimSpace(in.EndNum)
|
||||
if start == "" || end == "" {
|
||||
return gerror.New("连号查询需同时填写起始和结束号码")
|
||||
}
|
||||
if len(start) != len(end) || !isDigits(start) || !isDigits(end) {
|
||||
return gerror.New("起止号码必须为等长数字")
|
||||
}
|
||||
if start > end {
|
||||
return gerror.New("起始号码不能大于结束号码")
|
||||
}
|
||||
_, err := rangeColumn(in.Dimension)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 号段按号码逐个输出(查到出行数据、没查到出失败行),条数必须与号段大小一致。
|
||||
// 因此号段不能超过单次上限,否则无法完整展开。
|
||||
size, err := rangeSize(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if size > model.SimCardQueryMaxRows {
|
||||
return gerror.Newf("号段内号码数量超过单次上限 %d 个,请缩小范围或分批查询", model.SimCardQueryMaxRows)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTextInputs 解析多行文本。每行 1~3 个空白分隔的号码值。
|
||||
func isDigits(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseTextInputs(ctx context.Context, input string) ([]inputLine, error) {
|
||||
lines, err := text.ParseWhitespaceFields(ctx, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, text.ErrEmptyInput) {
|
||||
return nil, gerror.New("批量输入内容不能为空")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(lines) > model.SimCardQueryMaxRows {
|
||||
return nil, gerror.Newf("批量输入 %d 行,超过单次上限 %d 行,请拆分后分批查询", len(lines), model.SimCardQueryMaxRows)
|
||||
}
|
||||
out := make([]inputLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if len(line.Fields) > 3 {
|
||||
return nil, gerror.Newf("第 %d 行格式错误:每行支持 1 至 3 个号码值", line.RowNo)
|
||||
}
|
||||
out = append(out, inputLine{RowNo: line.RowNo, Values: line.Fields})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// streamExcelInputs 流式解析 Excel 上传。ICCID / MSISDN / IMSI 三列任填其一即可。
|
||||
func streamExcelInputs(ctx context.Context, reader io.Reader) ([]inputLine, error) {
|
||||
out := make([]inputLine, 0)
|
||||
_, err := excel.StreamReader(ctx, reader, excel.ParseOptions{
|
||||
SingleSheet: true,
|
||||
MaxDataRows: model.SimCardQueryMaxRows,
|
||||
}, func(row excel.ParsedRow) error {
|
||||
values := make([]string, 0, 3)
|
||||
for _, name := range []string{"ICCID", "MSISDN", "IMSI"} {
|
||||
if value := excel.Field(row, name); value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
// 空行由解析库跳过;这里保留为失败行,便于用户知道模板里哪一行没填。
|
||||
values = append(values, "")
|
||||
}
|
||||
out = append(out, inputLine{RowNo: row.RowNo, Values: values})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, gerror.New("Excel 中没有可解析的号码")
|
||||
}
|
||||
if len(out) > model.SimCardQueryMaxRows {
|
||||
return nil, gerror.Newf("Excel 有效数据 %d 行,超过单次上限 %d 行,请拆分后分批查询", len(out), model.SimCardQueryMaxRows)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveStats 记录命中/未命中/去重数量,用于导出结束后的执行日志汇总。
|
||||
type resolveStats struct {
|
||||
mu sync.Mutex
|
||||
hit int
|
||||
miss int
|
||||
dup int
|
||||
}
|
||||
|
||||
func (s *resolveStats) add(hit bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if hit {
|
||||
s.hit++
|
||||
return
|
||||
}
|
||||
s.miss++
|
||||
}
|
||||
|
||||
// addDup 记录一条因重复而合并的输入。
|
||||
// 同一张卡可能是同一号码写了两遍,也可能一行 ICCID、另一行 MSISDN 指向同一张卡。
|
||||
func (s *resolveStats) addDup() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.dup++
|
||||
}
|
||||
|
||||
func (s *resolveStats) resultSummary(_ int) (int, string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
total := s.hit + s.miss
|
||||
if total == 0 {
|
||||
return consts.BatchRunResultFailed, "查询结果:没有可导出的数据"
|
||||
}
|
||||
dupText := ""
|
||||
if s.dup > 0 {
|
||||
dupText = fmt.Sprintf(",重复 %d 条已合并", s.dup)
|
||||
}
|
||||
if s.miss == 0 {
|
||||
return consts.BatchRunResultSuccess,
|
||||
fmt.Sprintf("查询完成:成功 %d 条,失败 0 条%s", s.hit, dupText)
|
||||
}
|
||||
if s.hit == 0 {
|
||||
return consts.BatchRunResultFailed,
|
||||
fmt.Sprintf("查询失败:成功 0 条,失败 %d 条%s;失败原因见导出文件「失败原因」列", s.miss, dupText)
|
||||
}
|
||||
return consts.BatchRunResultPartialSuccess,
|
||||
fmt.Sprintf("部分成功:成功 %d 条,失败 %d 条%s;失败原因见导出文件「失败原因」列", s.hit, s.miss, dupText)
|
||||
}
|
||||
|
||||
// resolveBaseRows 把输入号码解析成导出行,未命中的行也保留并写明原因。
|
||||
func (s *Service) resolveBaseRows(ctx context.Context, lines []inputLine, stats *resolveStats) ([]*model.SimCardBaseQueryRow, error) {
|
||||
values := collectValues(lines)
|
||||
cards, err := queryCardsByValues(ctx, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byIccid := indexCards(cards, func(card *cardBrief) string { return card.Iccid })
|
||||
byMsisdn := indexCards(cards, func(card *cardBrief) string { return card.Msisdn })
|
||||
byImsi := indexCards(cards, func(card *cardBrief) string { return card.Imsi })
|
||||
|
||||
display := newDisplayResolver(ctx, cards)
|
||||
rows := make([]*model.SimCardBaseQueryRow, 0, len(lines))
|
||||
// 同一张卡在输入里出现多次时只导出一次:
|
||||
// 可能是同一号码重复填写,也可能一行是 ICCID、另一行是它的 MSISDN。
|
||||
exported := make(map[int64]struct{}, len(lines))
|
||||
for _, line := range lines {
|
||||
row := &model.SimCardBaseQueryRow{}
|
||||
card, reason := matchCard(line, byIccid, byMsisdn, byImsi)
|
||||
if card == nil {
|
||||
// 未命中时把输入的第一列原样回填,方便用户定位是哪一条没查到。
|
||||
row.Iccid = firstValue(line.Values)
|
||||
row.QueryResult = queryResultMiss
|
||||
row.FailReason = inputFailure(line, reason)
|
||||
stats.add(false)
|
||||
rows = append(rows, row)
|
||||
continue
|
||||
}
|
||||
if _, ok := exported[card.Id]; ok {
|
||||
stats.addDup()
|
||||
continue
|
||||
}
|
||||
exported[card.Id] = struct{}{}
|
||||
display.fill(row, card)
|
||||
row.QueryResult = queryResultHit
|
||||
stats.add(true)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// resolveRangeRows 号段模式:按起止号段分页取卡,取到的卡全部视为命中。
|
||||
// 取满上限即停,避免上百万号的号段把导出任务拖死。
|
||||
func (s *Service) resolveRangeRows(ctx context.Context, in *model.SimCardQueryExportParam, stats *resolveStats) ([]*model.SimCardBaseQueryRow, error) {
|
||||
cards, err := queryCardsByRange(ctx, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildRangeRows(ctx, in, cards, stats), nil
|
||||
}
|
||||
|
||||
// setRangeNumber 按号段维度把号码写进对应列。
|
||||
func setRangeNumber(row *model.SimCardBaseQueryRow, dimension, number string) {
|
||||
switch dimension {
|
||||
case "msisdn":
|
||||
row.Msisdn = number
|
||||
case "imsi":
|
||||
row.Imsi = number
|
||||
default:
|
||||
row.Iccid = number
|
||||
}
|
||||
}
|
||||
|
||||
// rangeCardIndex 按号段维度值索引命中的卡。
|
||||
func rangeCardIndex(cards []*cardBrief, dimension string) map[string]*cardBrief {
|
||||
index := make(map[string]*cardBrief, len(cards))
|
||||
for _, card := range cards {
|
||||
var value string
|
||||
switch dimension {
|
||||
case "msisdn":
|
||||
value = card.Msisdn
|
||||
case "imsi":
|
||||
value = card.Imsi
|
||||
default:
|
||||
value = card.Iccid
|
||||
}
|
||||
if value != "" {
|
||||
index[value] = card
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// buildRangeRows 号段模式:按号段逐个号码输出一行,命中出行数据、未命中出失败行。
|
||||
//
|
||||
// 不能只输出命中的卡:号段里可能一部分在当前账号可见范围内、一部分不在,
|
||||
// 只输出命中部分会让导出条数与号段大小对不上,客户无法按号对账。
|
||||
func buildRangeRows(ctx context.Context, in *model.SimCardQueryExportParam, cards []*cardBrief, stats *resolveStats) []*model.SimCardBaseQueryRow {
|
||||
dimension := strings.TrimSpace(in.Dimension)
|
||||
index := rangeCardIndex(cards, dimension)
|
||||
size, err := rangeSize(in)
|
||||
if err != nil {
|
||||
size = int64(len(cards))
|
||||
}
|
||||
numbers := rangeNumbers(in, size)
|
||||
display := newDisplayResolver(ctx, cards)
|
||||
rows := make([]*model.SimCardBaseQueryRow, 0, len(numbers))
|
||||
for _, number := range numbers {
|
||||
if card, ok := index[number]; ok {
|
||||
row := &model.SimCardBaseQueryRow{QueryResult: queryResultHit}
|
||||
display.fill(row, card)
|
||||
stats.add(true)
|
||||
rows = append(rows, row)
|
||||
continue
|
||||
}
|
||||
row := &model.SimCardBaseQueryRow{
|
||||
QueryResult: queryResultMiss,
|
||||
FailReason: rangeMissReason,
|
||||
}
|
||||
setRangeNumber(row, dimension, number)
|
||||
stats.add(false)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// rangeMissReason 号段/号码查不到数据时的统一说明。
|
||||
// 不能写「无权访问」:是否因为权限被过滤属于内部口径,不应暴露给客户。
|
||||
const rangeMissReason = "未查询到数据"
|
||||
|
||||
// rangeSize 号段包含的号码个数,用于提交时给出「预计 N 条」。
|
||||
// ICCID 有 20 位,超过 int64 表示范围,用 big.Int 计算。
|
||||
func rangeSize(in *model.SimCardQueryExportParam) (int64, error) {
|
||||
start := strings.TrimSpace(in.StartNum)
|
||||
end := strings.TrimSpace(in.EndNum)
|
||||
s, ok := new(big.Int).SetString(start, 10)
|
||||
if !ok {
|
||||
return 0, gerror.New("起始号码格式不正确")
|
||||
}
|
||||
e, ok := new(big.Int).SetString(end, 10)
|
||||
if !ok {
|
||||
return 0, gerror.New("结束号码格式不正确")
|
||||
}
|
||||
if e.Cmp(s) < 0 {
|
||||
return 0, gerror.New("起始号码不能大于结束号码")
|
||||
}
|
||||
span := new(big.Int).Sub(e, s)
|
||||
span.Add(span, big.NewInt(1))
|
||||
if !span.IsInt64() {
|
||||
return math.MaxInt64, nil
|
||||
}
|
||||
return span.Int64(), nil
|
||||
}
|
||||
|
||||
// padNumber 保持与输入等长,避免前导零被 big.Int 吃掉后与用户输入对不上。
|
||||
func padNumber(num string, width int) string {
|
||||
if len(num) >= width {
|
||||
return num
|
||||
}
|
||||
return strings.Repeat("0", width-len(num)) + num
|
||||
}
|
||||
|
||||
// rangeNumbers 按号段依次产出号码,最多 limit 个。
|
||||
func rangeNumbers(in *model.SimCardQueryExportParam, limit int64) []string {
|
||||
start := strings.TrimSpace(in.StartNum)
|
||||
s, ok := new(big.Int).SetString(start, 10)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, limit)
|
||||
cur := new(big.Int).Set(s)
|
||||
for i := int64(0); i < limit; i++ {
|
||||
out = append(out, padNumber(cur.String(), len(start)))
|
||||
cur.Add(cur, big.NewInt(1))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rangeColumn 号段维度对应的表字段。
|
||||
// 只允许三码维度,避免把任意字段拼进 SQL。
|
||||
func rangeColumn(dimension string) (string, error) {
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
switch strings.TrimSpace(dimension) {
|
||||
case "iccid":
|
||||
return columns.Iccid, nil
|
||||
case "msisdn":
|
||||
return columns.Msisdn, nil
|
||||
case "imsi":
|
||||
return columns.Imsi, nil
|
||||
default:
|
||||
return "", gerror.New("连号维度只能选 ICCID、MSISDN 或 IMSI")
|
||||
}
|
||||
}
|
||||
|
||||
// queryCardsByRange 按号段取卡;命中超过上限时拒绝,避免静默截断。
|
||||
func queryCardsByRange(ctx context.Context, in *model.SimCardQueryExportParam) ([]*cardBrief, error) {
|
||||
column, err := rangeColumn(in.Dimension)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
result := make([]*cardBrief, 0)
|
||||
for start := 0; start < model.SimCardQueryMaxRows; start += chunkSize {
|
||||
list := make([]*cardBrief, 0, chunkSize)
|
||||
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(
|
||||
columns.Id, columns.Iccid, columns.Msisdn, columns.Imsi, columns.Imei,
|
||||
columns.UsedFlowMb, columns.PackageTotalFlowMb, columns.BossCardStatus,
|
||||
columns.NetModel, columns.ChanKey, columns.BossActivateAt, columns.OutStockTime,
|
||||
columns.FlowPkgEndAt, columns.OutboundBatchNo, columns.InboundBatchNo,
|
||||
columns.CurrentPackageCode, columns.DeptId, columns.Remark,
|
||||
).
|
||||
Fields(cardflow.TotalFlowExpr(dao.TSimCardInfo.Table(), "")+" AS query_total_flow_mb").
|
||||
Where(column+" >= ?", strings.TrimSpace(in.StartNum)).
|
||||
Where(column+" <= ?", strings.TrimSpace(in.EndNum)).
|
||||
Where(column+" != ?", "").
|
||||
OrderAsc(columns.Id).
|
||||
Limit(chunkSize).Offset(start).
|
||||
Scan(&list)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡信息失败,请稍后重试")
|
||||
}
|
||||
result = append(result, list...)
|
||||
if len(list) < chunkSize {
|
||||
break
|
||||
}
|
||||
if len(result) == model.SimCardQueryMaxRows {
|
||||
var extra int
|
||||
extra, err = handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).Where(column+" >= ?", strings.TrimSpace(in.StartNum)).
|
||||
Where(column+" <= ?", strings.TrimSpace(in.EndNum)).
|
||||
Where(column+" != ?", "").Count()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "校验号段查询上限失败")
|
||||
}
|
||||
if extra > model.SimCardQueryMaxRows {
|
||||
return nil, gerror.Newf("号段命中超过单次上限 %d 张卡,请缩小范围", model.SimCardQueryMaxRows)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type cardIndex map[string][]*cardBrief
|
||||
|
||||
func indexCards(cards []*cardBrief, field func(*cardBrief) string) cardIndex {
|
||||
index := make(cardIndex, len(cards))
|
||||
for _, card := range cards {
|
||||
if card != nil {
|
||||
if value := field(card); value != "" {
|
||||
index[value] = append(index[value], card)
|
||||
}
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// matchCard 对每个号码保留全部候选卡,再求交集;重复 IMSI 和跨字段冲突不按顺序择卡。
|
||||
func matchCard(line inputLine, byIccid, byMsisdn, byImsi cardIndex) (*cardBrief, string) {
|
||||
var candidates map[int64]*cardBrief
|
||||
for _, value := range line.Values {
|
||||
value = strings.TrimSpace(value)
|
||||
matches := make(map[int64]*cardBrief)
|
||||
for _, index := range []cardIndex{byIccid, byMsisdn, byImsi} {
|
||||
for _, card := range index[value] {
|
||||
matches[card.Id] = card
|
||||
}
|
||||
}
|
||||
if value == "" || len(matches) == 0 {
|
||||
return nil, "未查询到数据:" + value
|
||||
}
|
||||
if candidates == nil {
|
||||
candidates = matches
|
||||
continue
|
||||
}
|
||||
for id := range candidates {
|
||||
if _, ok := matches[id]; !ok {
|
||||
delete(candidates, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, "同一行内的号码未指向同一张卡"
|
||||
}
|
||||
if len(candidates) > 1 {
|
||||
return nil, "号码匹配到多张卡,请补充同一卡的 ICCID/MSISDN/IMSI 消除歧义"
|
||||
}
|
||||
for _, card := range candidates {
|
||||
return card, ""
|
||||
}
|
||||
return nil, rangeMissReason
|
||||
}
|
||||
|
||||
func inputFailure(line inputLine, reason string) string {
|
||||
return fmt.Sprintf("第 %d 行,原始输入 [%s]:%s", line.RowNo, strings.Join(line.Values, " | "), reason)
|
||||
}
|
||||
|
||||
func firstValue(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
// collectValues 汇总需要查库的号码值,去重后返回。
|
||||
func collectValues(lines []inputLine) []string {
|
||||
seen := make(map[string]struct{}, len(lines)*3)
|
||||
out := make([]string, 0, len(lines)*3)
|
||||
for _, line := range lines {
|
||||
for _, value := range line.Values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// queryCardsByValues 分批按号码值查卡。
|
||||
// 单值在三列之间 OR 匹配(iccid 唯一索引 + msisdn 唯一索引 + imsi 索引),
|
||||
// 因此用户不需要提前声明用的是 ICCID 还是三码。
|
||||
func queryCardsByValues(ctx context.Context, values []string) ([]*cardBrief, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
cardTable := dao.TSimCardInfo.Table()
|
||||
result := make([]*cardBrief, 0, len(values))
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
|
||||
for start := 0; start < len(values); start += chunkSize {
|
||||
end := start + chunkSize
|
||||
if end > len(values) {
|
||||
end = len(values)
|
||||
}
|
||||
chunk := values[start:end]
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(chunk)), ",")
|
||||
condition := fmt.Sprintf("(%s.%s IN (%s) OR %s.%s IN (%s) OR %s.%s IN (%s))",
|
||||
cardTable, columns.Iccid, placeholders,
|
||||
cardTable, columns.Msisdn, placeholders,
|
||||
cardTable, columns.Imsi, placeholders,
|
||||
)
|
||||
args := make([]interface{}, 0, len(chunk)*3)
|
||||
for repeat := 0; repeat < 3; repeat++ {
|
||||
for _, value := range chunk {
|
||||
args = append(args, value)
|
||||
}
|
||||
}
|
||||
|
||||
list := make([]*cardBrief, 0, len(chunk))
|
||||
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(
|
||||
columns.Id,
|
||||
columns.Iccid,
|
||||
columns.Msisdn,
|
||||
columns.Imsi,
|
||||
columns.Imei,
|
||||
columns.UsedFlowMb,
|
||||
columns.PackageTotalFlowMb,
|
||||
columns.BossCardStatus,
|
||||
columns.NetModel,
|
||||
columns.ChanKey,
|
||||
columns.BossActivateAt,
|
||||
columns.OutStockTime,
|
||||
columns.FlowPkgEndAt,
|
||||
columns.OutboundBatchNo,
|
||||
columns.InboundBatchNo,
|
||||
columns.CurrentPackageCode,
|
||||
columns.DeptId,
|
||||
columns.Remark,
|
||||
).
|
||||
Fields(cardflow.TotalFlowExpr(cardTable, "")+" AS query_total_flow_mb").
|
||||
Where(condition, args...).
|
||||
Scan(&list)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡信息失败,请稍后重试")
|
||||
}
|
||||
for _, card := range list {
|
||||
if _, ok := seen[card.Id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[card.Id] = struct{}{}
|
||||
result = append(result, card)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// displayResolver 把 chan_key / package_code / dept_id 批量翻译成展示名,避免逐行子查询。
|
||||
type displayResolver struct {
|
||||
chanNames map[string]string
|
||||
packageNames map[string]string
|
||||
packageFlows map[string]float64
|
||||
deptNames map[string]string
|
||||
}
|
||||
|
||||
func newDisplayResolver(ctx context.Context, cards []*cardBrief) *displayResolver {
|
||||
r := &displayResolver{
|
||||
chanNames: make(map[string]string),
|
||||
packageNames: make(map[string]string),
|
||||
packageFlows: make(map[string]float64),
|
||||
deptNames: make(map[string]string),
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
return r
|
||||
}
|
||||
chanKeys := make([]string, 0)
|
||||
packageCodes := make([]string, 0)
|
||||
deptIds := make([]int64, 0)
|
||||
seenChan := map[string]struct{}{}
|
||||
seenPackage := map[string]struct{}{}
|
||||
seenDept := map[int64]struct{}{}
|
||||
for _, card := range cards {
|
||||
if card.ChanKey != "" {
|
||||
if _, ok := seenChan[card.ChanKey]; !ok {
|
||||
seenChan[card.ChanKey] = struct{}{}
|
||||
chanKeys = append(chanKeys, card.ChanKey)
|
||||
}
|
||||
}
|
||||
if card.PackageCode != "" {
|
||||
if _, ok := seenPackage[card.PackageCode]; !ok {
|
||||
seenPackage[card.PackageCode] = struct{}{}
|
||||
packageCodes = append(packageCodes, card.PackageCode)
|
||||
}
|
||||
}
|
||||
if card.DeptId > 0 {
|
||||
if _, ok := seenDept[card.DeptId]; !ok {
|
||||
seenDept[card.DeptId] = struct{}{}
|
||||
deptIds = append(deptIds, card.DeptId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(chanKeys) > 0 {
|
||||
type chanRow struct {
|
||||
ChanKey string `orm:"chan_key"`
|
||||
Operator int `orm:"operator"`
|
||||
}
|
||||
rows := make([]*chanRow, 0, len(chanKeys))
|
||||
if err := dao.TSimChannels.Ctx(ctx).
|
||||
Fields("chan_key, operator").
|
||||
WhereIn("chan_key", chanKeys).
|
||||
Scan(&rows); err == nil {
|
||||
for _, row := range rows {
|
||||
switch row.Operator {
|
||||
case 1:
|
||||
r.chanNames[row.ChanKey] = "中国移动"
|
||||
case 2:
|
||||
r.chanNames[row.ChanKey] = "中国联通"
|
||||
case 3:
|
||||
r.chanNames[row.ChanKey] = "中国电信"
|
||||
default:
|
||||
r.chanNames[row.ChanKey] = "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(packageCodes) > 0 {
|
||||
type pkgRow struct {
|
||||
PackageCode string `orm:"package_code"`
|
||||
PackageName string `orm:"package_name"`
|
||||
FlowMb float64 `orm:"flow_mb"`
|
||||
}
|
||||
rows := make([]*pkgRow, 0, len(packageCodes))
|
||||
if err := dao.TSimFlowPackageInfo.Ctx(ctx).
|
||||
Fields("package_code, package_name, flow_mb").
|
||||
WhereIn("package_code", packageCodes).
|
||||
Scan(&rows); err == nil {
|
||||
for _, row := range rows {
|
||||
r.packageNames[row.PackageCode] = row.PackageName
|
||||
r.packageFlows[row.PackageCode] = row.FlowMb
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(deptIds) > 0 {
|
||||
type deptRow struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
}
|
||||
rows := make([]*deptRow, 0, len(deptIds))
|
||||
if err := dao.SysDept.Ctx(ctx).
|
||||
Fields("dept_id, dept_name").
|
||||
WhereIn("dept_id", deptIds).
|
||||
WhereNull("deleted_at").
|
||||
Scan(&rows); err == nil {
|
||||
for _, row := range rows {
|
||||
r.deptNames[fmt.Sprintf("%d", row.DeptId)] = row.DeptName
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// fill 把库里字段落到导出行。
|
||||
func (r *displayResolver) fill(row *model.SimCardBaseQueryRow, card *cardBrief) {
|
||||
row.Iccid = card.Iccid
|
||||
row.Msisdn = card.Msisdn
|
||||
row.Imsi = card.Imsi
|
||||
row.Imei = card.Imei
|
||||
row.UsedFlowMb = roundMb(card.UsedFlowMb)
|
||||
row.CardStatus = card.CardStatus
|
||||
row.NetModel = card.NetModel
|
||||
row.Operators = r.chanNames[card.ChanKey]
|
||||
row.OutboundBatch = card.OutboundBatch
|
||||
row.InboundBatch = card.InboundBatch
|
||||
row.PackageName = r.packageNames[card.PackageCode]
|
||||
row.DeptName = r.deptNames[fmt.Sprintf("%d", card.DeptId)]
|
||||
row.Remark = card.Remark
|
||||
row.ActivateAt = card.ActivateAt
|
||||
row.OutStockTime = card.OutStockTime
|
||||
row.ExpireAt = card.ExpireAt
|
||||
|
||||
// 复用 cardflow.TotalFlowExpr:当前主套餐(或卡冗余值)加生效中的单卡加油包。
|
||||
row.PackageTotalMb = roundMb(card.PackageTotalMb)
|
||||
remain := card.PackageTotalMb - card.UsedFlowMb
|
||||
if remain < 0 {
|
||||
remain = 0
|
||||
}
|
||||
row.RemainFlowMb = roundMb(remain)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestTemplateRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f, _, err := New().BaseTemplate(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
sheet := f.GetSheetName(0)
|
||||
if err := f.SetCellStr(sheet, "A2", "89860000000000000001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines, err := streamExcelInputs(ctx, b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lines) != 1 || lines[0].Values[0] != "89860000000000000001" {
|
||||
t.Fatalf("unexpected lines: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcelInputLimit(t *testing.T) {
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
sheet := f.GetSheetName(0)
|
||||
if err := f.SetCellStr(sheet, "A1", "ICCID"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 2; i <= model.SimCardQueryMaxRows+2; i++ {
|
||||
cell, _ := excelize.CoordinatesToCellName(1, i)
|
||||
if err := f.SetCellStr(sheet, cell, "89860000000000000001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
b, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := streamExcelInputs(context.Background(), b); err == nil {
|
||||
t.Fatal("expected over-limit error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCardAmbiguity(t *testing.T) {
|
||||
cards := []*cardBrief{
|
||||
{Id: 1, Iccid: "89860001", Msisdn: "10001", Imsi: "46000001"},
|
||||
{Id: 2, Iccid: "89860002", Msisdn: "10002", Imsi: "46000001"},
|
||||
}
|
||||
a := indexCards(cards, func(c *cardBrief) string { return c.Iccid })
|
||||
b := indexCards(cards, func(c *cardBrief) string { return c.Msisdn })
|
||||
c := indexCards(cards, func(c *cardBrief) string { return c.Imsi })
|
||||
if card, _ := matchCard(inputLine{Values: []string{"46000001"}}, a, b, c); card != nil {
|
||||
t.Fatal("ambiguous IMSI must fail")
|
||||
}
|
||||
if card, reason := matchCard(inputLine{Values: []string{"89860002", "46000001"}}, a, b, c); card == nil || card.Id != 2 {
|
||||
t.Fatalf("expected second card, got %+v %s", card, reason)
|
||||
}
|
||||
if card, _ := matchCard(inputLine{Values: []string{"89860001", "10002"}}, a, b, c); card != nil {
|
||||
t.Fatal("mixed card values must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeValidation(t *testing.T) {
|
||||
for _, pair := range [][3]string{{"1", "22", "iccid"}, {"aa", "bb", "iccid"}, {"22", "11", "iccid"}, {"11", "22", "imei"}} {
|
||||
if parseRangeInputs(&model.SimCardQueryExportParam{StartNum: pair[0], EndNum: pair[1], Dimension: pair[2]}) == nil {
|
||||
t.Fatalf("expected error: %v", pair)
|
||||
}
|
||||
}
|
||||
if err := parseRangeInputs(&model.SimCardQueryExportParam{StartNum: "0011", EndNum: "0022", Dimension: "iccid"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSafeIccid(t *testing.T) {
|
||||
ok := []string{"89860000000000000001", "8986abc001", "ABC123"}
|
||||
for _, v := range ok {
|
||||
if !isSafeIccid(v) {
|
||||
t.Fatalf("expected safe: %s", v)
|
||||
}
|
||||
}
|
||||
// 单引号、空格、分号、括号都必须拒绝,否则拼进 SQL 会破坏语句结构。
|
||||
bad := []string{"", "8986'001", "8986 001", "8986;001", "8986)001", strings.Repeat("1", 33)}
|
||||
for _, v := range bad {
|
||||
if isSafeIccid(v) {
|
||||
t.Fatalf("expected unsafe: %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMonthValuesThreeStates(t *testing.T) {
|
||||
months := []string{"2026-09", "2026-08", "2026-07"}
|
||||
|
||||
// 查询成功:9 月有量、8 月记录为 0、7 月无记录 → 数值 / 0 / "-"
|
||||
ok := &model.SimCardUsageHistoryRow{
|
||||
MonthValues: map[string]float64{"2026-09": 12.3456, "2026-08": 0},
|
||||
}
|
||||
got := formatMonthValues(ok, months)
|
||||
if got[0] != 12.346 {
|
||||
t.Fatalf("9 月应导出数值,实际 %v", got[0])
|
||||
}
|
||||
if got[1] != float64(0) {
|
||||
t.Fatalf("8 月零用量应导出 0,实际 %v", got[1])
|
||||
}
|
||||
if got[2] != usageNoDataText {
|
||||
t.Fatalf("7 月无记录应导出 %q,实际 %v", usageNoDataText, got[2])
|
||||
}
|
||||
|
||||
// 整卡查询失败:月份列一律留空(保证该列是纯数值),原因由「失败原因」列承载
|
||||
failed := &model.SimCardUsageHistoryRow{
|
||||
MonthValues: map[string]float64{"2026-09": 99},
|
||||
UsageQueryFailed: true,
|
||||
}
|
||||
got = formatMonthValues(failed, months)
|
||||
for i, v := range got {
|
||||
if v != "" {
|
||||
t.Fatalf("第 %d 列应留空,实际 %v", i+1, v)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(usageQueryFailedNote, "查询失败") {
|
||||
t.Fatalf("失败原因文案应说明查询失败:%s", usageQueryFailedNote)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMonthValuesPartialHistory 覆盖「平台只同步了几个月」和「新卡没历史」两种常见情况:
|
||||
// 这类卡不是查询失败,缺的月份必须显示 "-",不能整行留空或判失败。
|
||||
func TestFormatMonthValuesPartialHistory(t *testing.T) {
|
||||
months := []string{"2026-09", "2026-08", "2026-07", "2026-06", "2026-05", "2026-04"}
|
||||
|
||||
// 只有最近 3 个月有数据(新卡 / 平台同步只跑了 3 个月)
|
||||
partial := &model.SimCardUsageHistoryRow{
|
||||
MonthValues: map[string]float64{
|
||||
"2026-09": 120.5,
|
||||
"2026-08": 98,
|
||||
"2026-07": 0,
|
||||
},
|
||||
}
|
||||
got := formatMonthValues(partial, months)
|
||||
if len(got) != len(months) {
|
||||
t.Fatalf("列数应与月份数一致,实际 %d", len(got))
|
||||
}
|
||||
if got[0] != 120.5 {
|
||||
t.Fatalf("有数据的月份应导出数值,实际 %v", got[0])
|
||||
}
|
||||
if got[2] != float64(0) {
|
||||
t.Fatalf("有记录但用量为 0 应导出 0,实际 %v", got[2])
|
||||
}
|
||||
for i := 3; i < len(got); i++ {
|
||||
if got[i] != usageNoDataText {
|
||||
t.Fatalf("第 %d 列无记录应为 %q,实际 %v", i+1, usageNoDataText, got[i])
|
||||
}
|
||||
}
|
||||
// 部分缺失不能被当成查询失败
|
||||
if partial.UsageQueryFailed {
|
||||
t.Fatal("缺月份不等于查询失败")
|
||||
}
|
||||
|
||||
// 全新卡:12 个月一条记录都没有 → 全部 "-",仍然算查询成功
|
||||
brandNew := &model.SimCardUsageHistoryRow{MonthValues: map[string]float64{}}
|
||||
got = formatMonthValues(brandNew, months)
|
||||
for i, v := range got {
|
||||
if v != usageNoDataText {
|
||||
t.Fatalf("新卡第 %d 列应为 %q,实际 %v", i+1, usageNoDataText, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRangeRowsKeepsOneRowPerNumber 号段里只有部分号码可见时,
|
||||
// 导出必须仍然每个号码一行:可见的出行数据,不可见的出「未查询到数据」,
|
||||
// 总条数等于号段大小,客户才能按号对账。
|
||||
func TestBuildRangeRowsKeepsOneRowPerNumber(t *testing.T) {
|
||||
in := &model.SimCardQueryExportParam{
|
||||
Dimension: "iccid",
|
||||
StartNum: "89860000000000000001",
|
||||
EndNum: "89860000000000000010",
|
||||
}
|
||||
// 10 个号里只有第 3、5、8 个在可见范围内
|
||||
cards := []*cardBrief{
|
||||
{Id: 1, Iccid: "89860000000000000003"},
|
||||
{Id: 2, Iccid: "89860000000000000005"},
|
||||
{Id: 3, Iccid: "89860000000000000008"},
|
||||
}
|
||||
stats := &resolveStats{}
|
||||
rows := buildRangeRows(context.Background(), in, cards, stats)
|
||||
|
||||
if len(rows) != 10 {
|
||||
t.Fatalf("应输出 10 行(与号段大小一致),实际 %d", len(rows))
|
||||
}
|
||||
hit, miss := 0, 0
|
||||
seen := make(map[string]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
if _, dup := seen[row.Iccid]; dup {
|
||||
t.Fatalf("号码重复输出:%s", row.Iccid)
|
||||
}
|
||||
seen[row.Iccid] = struct{}{}
|
||||
switch row.QueryResult {
|
||||
case queryResultHit:
|
||||
hit++
|
||||
case queryResultMiss:
|
||||
miss++
|
||||
// 不能透露是因为权限查不到
|
||||
if row.FailReason != rangeMissReason {
|
||||
t.Fatalf("失败原因应为 %q,实际 %q", rangeMissReason, row.FailReason)
|
||||
}
|
||||
if strings.Contains(row.FailReason, "权限") {
|
||||
t.Fatalf("失败原因不能提及权限:%s", row.FailReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
if hit != 3 || miss != 7 {
|
||||
t.Fatalf("成功/失败应为 3/7,实际 %d/%d", hit, miss)
|
||||
}
|
||||
if stats.hit != 3 || stats.miss != 7 {
|
||||
t.Fatalf("统计应为 3/7,实际 %d/%d", stats.hit, stats.miss)
|
||||
}
|
||||
// 首尾号码要对得上,且保持等长(前导零不丢)
|
||||
if _, ok := seen["89860000000000000001"]; !ok {
|
||||
t.Fatal("缺少起始号码")
|
||||
}
|
||||
if _, ok := seen["89860000000000000010"]; !ok {
|
||||
t.Fatal("缺少结束号码")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeSizeAndLimit(t *testing.T) {
|
||||
in := &model.SimCardQueryExportParam{StartNum: "89860000000000000001", EndNum: "89860000000000000010"}
|
||||
size, err := rangeSize(in)
|
||||
if err != nil || size != 10 {
|
||||
t.Fatalf("号段大小应为 10,实际 %d err=%v", size, err)
|
||||
}
|
||||
// 超过单次上限的号段要在提交阶段直接拒绝,否则无法完整展开
|
||||
big := &model.SimCardQueryExportParam{
|
||||
Dimension: "iccid",
|
||||
StartNum: "89860000000000000001",
|
||||
EndNum: "89860000000000099999",
|
||||
}
|
||||
if err := parseRangeInputs(big); err == nil {
|
||||
t.Fatal("超限号段应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthsAndStats(t *testing.T) {
|
||||
months := LastCompleteMonths(time.Date(2026, 10, 23, 14, 0, 0, 0, time.FixedZone("CST", 8*3600)), 12)
|
||||
if len(months) != 12 || months[0] != "2026-09" || months[11] != "2025-10" {
|
||||
t.Fatalf("unexpected months: %v", months)
|
||||
}
|
||||
stats := &resolveStats{}
|
||||
stats.add(true)
|
||||
if result, summary := stats.resultSummary(1); result != consts.BatchRunResultSuccess || !strings.Contains(summary, "成功 1 条") {
|
||||
t.Fatalf("bad success: %d %s", result, summary)
|
||||
}
|
||||
stats.add(false)
|
||||
if result, _ := stats.resultSummary(2); result != consts.BatchRunResultPartialSuccess {
|
||||
t.Fatal("expected partial success")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/tdengine"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"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/gtime"
|
||||
)
|
||||
|
||||
// usageHistoryMonths 近一年月流水的月份个数(不含当月)。
|
||||
// 例:当前 2026-10,导出 2026-09 ~ 2025-10 共 12 个月。
|
||||
const usageHistoryMonths = 12
|
||||
|
||||
// usageHistoryTDEngineChunk 单次向 TDengine 查询的 ICCID 条数。
|
||||
// 父表按 iccid 标签过滤会剪枝到对应子表,500 条一批可把单次查询控制在秒级。
|
||||
const usageHistoryTDEngineChunk = 500
|
||||
|
||||
// dept 模式没有输入号码,靠公司范围圈卡,必须设上限,否则顶级账号一次拖全库。
|
||||
// deptExportMaxRows 公司模式(含下级)单次导出上限。
|
||||
//
|
||||
// 与号码模式的 20000 分开取值:号码模式受「用户手上有多少号码」约束,
|
||||
// 公司模式是整批圈卡,按 2 万设限只能靠不断切分下级公司,反而更容易出错。
|
||||
//
|
||||
// 取值依据(实测,见 export_cost_test.go):5 万行写 Excel 约 3.2s、文件 4.5MB,
|
||||
// 远低于导出中心 10 分钟超时 / 100MB 上限;真正的约束是当前实现会把整批行
|
||||
// 先构造成内存切片再分页写出,5 万行内存占用约 50MB 量级,10 万行开始偏高。
|
||||
// 若要支持 10 万以上,需要把公司模式改成「按页直接查库」的流式导出。
|
||||
const deptExportMaxRows = 50000
|
||||
|
||||
var usageHistoryFixedFields = []string{"iccid", "msisdn", "imsi", "outStockTime", "expireAt"}
|
||||
|
||||
// 月用量列的取值口径:
|
||||
//
|
||||
// 查询失败(服务异常)→ 留空,原因写进「失败原因」列
|
||||
// 查到了但该月无记录 → "-"
|
||||
// 该月有记录且用量为 0 → 0
|
||||
//
|
||||
// 失败格留空而不是写文字:这样整列是「数字 + 空 + 横杠」,Excel 求和/透视不会被文本干扰;
|
||||
// 同时「失败原因」列会写明该卡流量查询失败,信息不会丢。
|
||||
const usageNoDataText = "-"
|
||||
|
||||
// usageQueryFailedNote 流量查询失败时写入「失败原因」列的说明。
|
||||
const usageQueryFailedNote = "该卡近12个月流量数据本次查询失败,月份列留空,可稍后重新导出"
|
||||
|
||||
// cardUsageResult 单张卡的历史用量查询结果。
|
||||
type cardUsageResult struct {
|
||||
// Months 键为 "YYYY-MM";缺失表示查询成功但该月无记录。
|
||||
Months map[string]float64
|
||||
// Failed 表示该卡所属批次的流量查询失败,数据不可信。
|
||||
Failed bool
|
||||
}
|
||||
|
||||
// ExportUsageHistory 月历史用量查询导出。
|
||||
func (s *Service) ExportUsageHistory(ctx context.Context, in *model.SimCardUsageHistoryExportParam, input ExcelInput) (*exportcenter.SubmitResult, error) {
|
||||
months := LastCompleteMonths(time.Now(), usageHistoryMonths)
|
||||
columns := resolveUsageHistoryColumns(in, months)
|
||||
|
||||
// 公司模式的卡列表必须在后台任务中解析,避免提交请求同步读取最多 2 万张卡。
|
||||
// 号码模式仍在提交阶段校验输入格式,实际查库统一延迟到导出 runner。
|
||||
var lines []inputLine
|
||||
var err error
|
||||
if in.Mode != model.SimCardQueryModeDept {
|
||||
lines, err = parseInputs(ctx, &in.SimCardQueryExportParam, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
stats := &resolveStats{}
|
||||
once := &sync.Once{}
|
||||
var cached []*model.SimCardUsageHistoryRow
|
||||
var cacheErr error
|
||||
resolve := func(ctx context.Context) ([]*model.SimCardUsageHistoryRow, error) {
|
||||
once.Do(func() {
|
||||
switch in.Mode {
|
||||
case model.SimCardQueryModeDept:
|
||||
cached, cacheErr = s.resolveDeptRows(ctx, in, months, stats)
|
||||
case model.SimCardQueryModeRange:
|
||||
cached, cacheErr = s.resolveUsageRangeRows(ctx, &in.SimCardQueryExportParam, months, stats)
|
||||
default:
|
||||
cached, cacheErr = s.resolveUsageHistoryRows(ctx, lines, months, stats)
|
||||
}
|
||||
})
|
||||
return cached, cacheErr
|
||||
}
|
||||
probeTotal := len(lines)
|
||||
switch in.Mode {
|
||||
case model.SimCardQueryModeRange:
|
||||
// 号段模式:号段包含多少个号码就是预计条数,与是否查到无关。
|
||||
if size, sizeErr := rangeSize(&in.SimCardQueryExportParam); sizeErr == nil {
|
||||
probeTotal = int(size)
|
||||
} else {
|
||||
probeTotal = -1
|
||||
}
|
||||
case model.SimCardQueryModeDept:
|
||||
// 公司卡数只在后台 runner 中查询;提交探针不扫描业务表,条数未知。
|
||||
probeTotal = -1
|
||||
}
|
||||
|
||||
return exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimCardUsageHistory",
|
||||
BizName: "月历史用量查询",
|
||||
PermCode: "cpm:iot:tSimCardUsageHistory:view",
|
||||
SheetName: "月历史用量",
|
||||
QueryParam: usageHistorySnapshot(in, months, probeTotal),
|
||||
Columns: columns,
|
||||
RowFields: append(append([]string{}, usageHistoryFixedFields...), append(months, queryResultField, failReasonField)...),
|
||||
Tags: func() ([]string, error) {
|
||||
return usageHistoryTags(months), nil
|
||||
},
|
||||
ToRow: func(v interface{}) []interface{} {
|
||||
row, ok := v.(*model.SimCardUsageHistoryRow)
|
||||
if !ok || row == nil {
|
||||
return nil
|
||||
}
|
||||
values := make([]interface{}, 0, len(usageHistoryFixedFields)+len(months)+2)
|
||||
values = append(values, row.Iccid, row.Msisdn, row.Imsi, formatTime(row.OutStockTime), formatTime(row.ExpireAt))
|
||||
values = append(values, formatMonthValues(row, months)...)
|
||||
return append(values, row.QueryResult, row.FailReason)
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) ([]interface{}, int, error) {
|
||||
if pageSize <= 1 && page <= 1 {
|
||||
// probeTotal 为 -1 表示提交阶段无法确定条数(按公司模式),放行由后台实际取数。
|
||||
return nil, probeTotal, nil
|
||||
}
|
||||
rows, err := resolve(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total := len(rows)
|
||||
start := (page - 1) * pageSize
|
||||
if start >= total {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
out := make([]interface{}, 0, end-start)
|
||||
for _, row := range rows[start:end] {
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, total, nil
|
||||
},
|
||||
Result: stats.resultSummary,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveUsageHistoryRows 号码模式:解析输入 → 查卡 → 查近 12 个月用量。
|
||||
func (s *Service) resolveUsageHistoryRows(ctx context.Context, lines []inputLine, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) {
|
||||
values := collectValues(lines)
|
||||
cards, err := queryCardsByValues(ctx, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byIccid := indexCards(cards, func(card *cardBrief) string { return card.Iccid })
|
||||
byMsisdn := indexCards(cards, func(card *cardBrief) string { return card.Msisdn })
|
||||
byImsi := indexCards(cards, func(card *cardBrief) string { return card.Imsi })
|
||||
iccids := make([]string, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if card.Iccid != "" {
|
||||
iccids = append(iccids, card.Iccid)
|
||||
}
|
||||
}
|
||||
|
||||
usage, err := queryMonthlyUsage(ctx, iccids, months)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([]*model.SimCardUsageHistoryRow, 0, len(lines))
|
||||
// 同一张卡重复出现只导出一次(同号码重复填写,或 ICCID 与其 MSISDN 各写一行)。
|
||||
exported := make(map[int64]struct{}, len(lines))
|
||||
for _, line := range lines {
|
||||
row := &model.SimCardUsageHistoryRow{MonthValues: map[string]float64{}}
|
||||
card, reason := matchCard(line, byIccid, byMsisdn, byImsi)
|
||||
if card == nil {
|
||||
row.Iccid = firstValue(line.Values)
|
||||
row.QueryResult = queryResultMiss
|
||||
row.FailReason = inputFailure(line, reason)
|
||||
stats.add(false)
|
||||
rows = append(rows, row)
|
||||
continue
|
||||
}
|
||||
if _, ok := exported[card.Id]; ok {
|
||||
stats.addDup()
|
||||
continue
|
||||
}
|
||||
exported[card.Id] = struct{}{}
|
||||
rows = append(rows, newUsageRow(card, usage))
|
||||
stats.add(true)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// resolveUsageRangeRows 号段模式:按起止号段取卡,再补近 12 个月用量。
|
||||
func (s *Service) resolveUsageRangeRows(ctx context.Context, in *model.SimCardQueryExportParam, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) {
|
||||
cards, err := queryCardsByRange(ctx, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iccids := make([]string, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if card.Iccid != "" {
|
||||
iccids = append(iccids, card.Iccid)
|
||||
}
|
||||
}
|
||||
usage, err := queryMonthlyUsage(ctx, iccids, months)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 按号段逐个号码输出:命中出行数据,未命中出「未查询到数据」的失败行,
|
||||
// 保证导出条数与号段大小一致(号段里可能一部分可见、一部分不可见)。
|
||||
dimension := strings.TrimSpace(in.Dimension)
|
||||
index := rangeCardIndex(cards, dimension)
|
||||
size, sizeErr := rangeSize(in)
|
||||
if sizeErr != nil {
|
||||
size = int64(len(cards))
|
||||
}
|
||||
numbers := rangeNumbers(in, size)
|
||||
rows := make([]*model.SimCardUsageHistoryRow, 0, len(numbers))
|
||||
for _, number := range numbers {
|
||||
if card, ok := index[number]; ok {
|
||||
rows = append(rows, newUsageRow(card, usage))
|
||||
stats.add(true)
|
||||
continue
|
||||
}
|
||||
row := &model.SimCardUsageHistoryRow{
|
||||
MonthValues: map[string]float64{},
|
||||
QueryResult: queryResultMiss,
|
||||
FailReason: rangeMissReason,
|
||||
}
|
||||
switch dimension {
|
||||
case "msisdn":
|
||||
row.Msisdn = number
|
||||
case "imsi":
|
||||
row.Imsi = number
|
||||
default:
|
||||
row.Iccid = number
|
||||
}
|
||||
stats.add(false)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// resolveDeptRows 在后台按提交人的数据权限重新解析公司(含下级)卡列表。
|
||||
func (s *Service) resolveDeptRows(ctx context.Context, in *model.SimCardUsageHistoryExportParam, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) {
|
||||
iccids, err := s.cardsByDept(ctx, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards, err := queryCardsByIccids(ctx, iccids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usage, err := queryMonthlyUsage(ctx, iccids, months)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]*model.SimCardUsageHistoryRow, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
stats.add(true)
|
||||
rows = append(rows, newUsageRow(card, usage))
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// cardsByDept 取公司(含全部下级公司)名下的 ICCID 列表。
|
||||
//
|
||||
// 选中公司即包含下级,与 DeptOptions 的卡数统计口径一致:
|
||||
// 顶级公司自身通常不挂卡,只查本级会得到 0 条。
|
||||
func (s *Service) cardsByDept(ctx context.Context, in *model.SimCardUsageHistoryExportParam) ([]string, error) {
|
||||
if in.DeptId <= 0 {
|
||||
return nil, gerror.New("请选择所属公司")
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
base := func() *gdb.Model {
|
||||
mod := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).WhereIn(columns.DeptId, deptSubtreeIDs(ctx, in.DeptId))
|
||||
if start := strings.TrimSpace(in.ExpireStartTime); start != "" {
|
||||
mod = mod.WhereGTE(columns.FlowPkgEndAt, start)
|
||||
}
|
||||
if end := strings.TrimSpace(in.ExpireEndTime); end != "" {
|
||||
mod = mod.WhereLTE(columns.FlowPkgEndAt, end)
|
||||
}
|
||||
return mod
|
||||
}
|
||||
|
||||
count, err := base().Fields(columns.Iccid).Count()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "统计公司名下卡数量失败")
|
||||
}
|
||||
if count <= 0 {
|
||||
return nil, gerror.New("该公司(含下级)下没有符合条件可导出的卡")
|
||||
}
|
||||
if count > deptExportMaxRows {
|
||||
return nil, gerror.Newf(
|
||||
"该公司(含下级)共 %d 张卡,超过单次导出上限 %d 张,请改选下级公司或改用号码导入",
|
||||
count, deptExportMaxRows,
|
||||
)
|
||||
}
|
||||
values, err := base().Fields(columns.Iccid).OrderAsc(columns.Id).Limit(deptExportMaxRows).Array()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询公司名下卡失败")
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if iccid := strings.TrimSpace(value.String()); iccid != "" {
|
||||
out = append(out, iccid)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// queryCardsByIccids 按 ICCID 批量取导出所需字段。
|
||||
func queryCardsByIccids(ctx context.Context, iccids []string) ([]*cardBrief, error) {
|
||||
if len(iccids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
out := make([]*cardBrief, 0, len(iccids))
|
||||
for start := 0; start < len(iccids); start += chunkSize {
|
||||
end := start + chunkSize
|
||||
if end > len(iccids) {
|
||||
end = len(iccids)
|
||||
}
|
||||
list := make([]*cardBrief, 0, end-start)
|
||||
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(
|
||||
columns.Id, columns.Iccid, columns.Msisdn, columns.Imsi,
|
||||
columns.OutStockTime, columns.FlowPkgEndAt,
|
||||
).
|
||||
WhereIn(columns.Iccid, iccids[start:end]).
|
||||
Scan(&list)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡信息失败,请稍后重试")
|
||||
}
|
||||
out = append(out, list...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// queryMonthlyUsage 从 TDengine 取多卡的多月用量。
|
||||
//
|
||||
// 每张卡带上自己的查询状态,三种情况必须在导出里可区分:
|
||||
// - Failed=true:该批查询异常,月份格写「查询失败」
|
||||
// - Months 里缺该月:查询成功但没有该月记录,月份格写 "-"
|
||||
// - Months 里有该月且为 0:查询成功、零用量,月份格写 0
|
||||
//
|
||||
// 部分批次失败不拖垮整单,只有全部批次失败(服务整体不可用)才返回错误。
|
||||
func queryMonthlyUsage(ctx context.Context, iccids []string, months []string) (map[string]*cardUsageResult, error) {
|
||||
result := make(map[string]*cardUsageResult, len(iccids))
|
||||
if len(iccids) == 0 || len(months) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if !tdengine.Enabled(ctx) {
|
||||
g.Log().Warning(ctx, "[simcardquery] 流量库未启用")
|
||||
return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。")
|
||||
}
|
||||
cfg := tdengine.GetConfig(ctx)
|
||||
if err := validateTDEngineNames(cfg.DbName, cfg.MonthlyStable); err != nil {
|
||||
g.Log().Errorf(ctx, "[simcardquery] 流量库配置异常: %v", err)
|
||||
return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。")
|
||||
}
|
||||
|
||||
start, end, err := monthRange(months)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
table := fmt.Sprintf("%s.%s", cfg.DbName, cfg.MonthlyStable)
|
||||
// 用秒级时间字面量:月表写入点是当月最后一天 23:59:59,秒级精度已能覆盖。
|
||||
startText := start.Format("2006-01-02 15:04:05")
|
||||
endText := end.Format("2006-01-02 15:04:05")
|
||||
|
||||
batches, failedBatches := 0, 0
|
||||
for chunkStart := 0; chunkStart < len(iccids); chunkStart += usageHistoryTDEngineChunk {
|
||||
chunkEnd := chunkStart + usageHistoryTDEngineChunk
|
||||
if chunkEnd > len(iccids) {
|
||||
chunkEnd = len(iccids)
|
||||
}
|
||||
chunk := iccids[chunkStart:chunkEnd]
|
||||
// TDengine 驱动不会给字符串参数补引号,用 ? 绑定会让时间条件变成
|
||||
// `ts >= 2025-10-01 00:00:00` 这种非法表达式(报 syntax error)。
|
||||
// 因此这里把 ICCID 与时间都拼成带引号的字面量,
|
||||
// ICCID 必须经过字符白名单校验,杜绝拼接注入。
|
||||
quoted := make([]string, 0, len(chunk))
|
||||
safe := make([]string, 0, len(chunk))
|
||||
for _, iccid := range chunk {
|
||||
if !isSafeIccid(iccid) {
|
||||
continue
|
||||
}
|
||||
safe = append(safe, iccid)
|
||||
quoted = append(quoted, "'"+iccid+"'")
|
||||
}
|
||||
if len(quoted) == 0 {
|
||||
continue
|
||||
}
|
||||
batches++
|
||||
// 先按「查询成功、暂无记录」建条目;一旦本批出错再整体标记为查询失败。
|
||||
for _, iccid := range safe {
|
||||
if result[iccid] == nil {
|
||||
result[iccid] = &cardUsageResult{Months: make(map[string]float64, len(months))}
|
||||
}
|
||||
}
|
||||
markFailedBatch := func() {
|
||||
for _, iccid := range safe {
|
||||
if entry := result[iccid]; entry != nil {
|
||||
entry.Failed = true
|
||||
entry.Months = map[string]float64{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sql := fmt.Sprintf(
|
||||
"SELECT iccid, ts, usage_mb FROM %s WHERE iccid IN (%s) AND ts >= '%s' AND ts <= '%s'",
|
||||
table, strings.Join(quoted, ","), startText, endText,
|
||||
)
|
||||
rows, queryErr := tdengine.Query(ctx, sql)
|
||||
if queryErr != nil {
|
||||
// 单批失败不拖垮整个导出:本批卡片标记为「查询失败」,其余批次照常。
|
||||
failedBatches++
|
||||
markFailedBatch()
|
||||
g.Log().Errorf(ctx, "[simcardquery] 查询月流量失败 cards=%d range=%s~%s err=%v",
|
||||
len(chunk), startText, endText, queryErr)
|
||||
continue
|
||||
}
|
||||
latest := make(map[string]map[string]time.Time, len(chunk))
|
||||
scanFailed := false
|
||||
for rows.Next() {
|
||||
var iccid string
|
||||
var ts time.Time
|
||||
var usage float64
|
||||
if scanErr := rows.Scan(&iccid, &ts, &usage); scanErr != nil {
|
||||
scanFailed = true
|
||||
g.Log().Errorf(ctx, "[simcardquery] 解析月流量结果失败 err=%v", scanErr)
|
||||
break
|
||||
}
|
||||
key := ts.Format("2006-01")
|
||||
if latest[iccid] == nil {
|
||||
latest[iccid] = make(map[string]time.Time, len(months))
|
||||
}
|
||||
previous, exists := latest[iccid][key]
|
||||
if exists && !ts.After(previous) {
|
||||
continue
|
||||
}
|
||||
entry := result[iccid]
|
||||
if entry == nil {
|
||||
entry = &cardUsageResult{Months: make(map[string]float64, len(months))}
|
||||
result[iccid] = entry
|
||||
}
|
||||
latest[iccid][key] = ts
|
||||
entry.Months[key] = usage
|
||||
}
|
||||
if !scanFailed {
|
||||
if rowsErr := rows.Err(); rowsErr != nil {
|
||||
scanFailed = true
|
||||
g.Log().Errorf(ctx, "[simcardquery] 遍历月流量结果失败 err=%v", rowsErr)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if scanFailed {
|
||||
failedBatches++
|
||||
markFailedBatch()
|
||||
}
|
||||
}
|
||||
// 全部批次都失败才判定服务不可用。
|
||||
if batches > 0 && failedBatches == batches {
|
||||
g.Log().Errorf(ctx, "[simcardquery] 流量服务全部批次查询失败 batches=%d", batches)
|
||||
return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。")
|
||||
}
|
||||
if failedBatches > 0 {
|
||||
g.Log().Warningf(ctx, "[simcardquery] 流量服务部分批次失败 batches=%d failed=%d", batches, failedBatches)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// formatMonthValues 把某行的月度用量格式化成导出的单元格值,三种状态严格区分:
|
||||
//
|
||||
// 查询失败(服务异常)→ 留空(原因见「失败原因」列)
|
||||
// 查询正常、该月无记录 → "-"
|
||||
// 查询正常、有记录 → 数值(零用量即 0)
|
||||
func formatMonthValues(row *model.SimCardUsageHistoryRow, months []string) []interface{} {
|
||||
values := make([]interface{}, 0, len(months))
|
||||
for _, month := range months {
|
||||
if row.UsageQueryFailed || row.QueryResult == queryResultMiss {
|
||||
// 整卡流量查询失败,或这张卡本身没查到:月份列留空,
|
||||
// 原因由「失败原因」列说明,保证月份列始终是「数字 + 半角横杠 + 空」。
|
||||
values = append(values, "")
|
||||
continue
|
||||
}
|
||||
usage, exists := row.MonthValues[month]
|
||||
if !exists {
|
||||
values = append(values, usageNoDataText)
|
||||
continue
|
||||
}
|
||||
values = append(values, roundMb(usage))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// newUsageRow 组装月历史导出行,并统一处理「流量查询失败」的说明。
|
||||
func newUsageRow(card *cardBrief, usage map[string]*cardUsageResult) *model.SimCardUsageHistoryRow {
|
||||
row := &model.SimCardUsageHistoryRow{
|
||||
Iccid: card.Iccid,
|
||||
Msisdn: card.Msisdn,
|
||||
Imsi: card.Imsi,
|
||||
OutStockTime: card.OutStockTime,
|
||||
ExpireAt: card.ExpireAt,
|
||||
MonthValues: usageMonths(usage, card.Iccid),
|
||||
UsageQueryFailed: usageFailed(usage, card.Iccid),
|
||||
QueryResult: queryResultHit,
|
||||
}
|
||||
if row.UsageQueryFailed {
|
||||
row.FailReason = usageQueryFailedNote
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// usageMonths 取某卡的月度用量;该卡查询失败或没有条目时返回空 map。
|
||||
func usageMonths(usage map[string]*cardUsageResult, iccid string) map[string]float64 {
|
||||
if entry := usage[iccid]; entry != nil {
|
||||
return entry.Months
|
||||
}
|
||||
return map[string]float64{}
|
||||
}
|
||||
|
||||
// usageFailed 该卡的流量数据是否查询失败。
|
||||
func usageFailed(usage map[string]*cardUsageResult, iccid string) bool {
|
||||
if entry := usage[iccid]; entry != nil {
|
||||
return entry.Failed
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LastCompleteMonths 返回最近 N 个「已结束的月份」,按时间倒序(最近的在前)。
|
||||
// 当月不计入:当月数据仍在累积,导出口径会和下个月对不上。
|
||||
func LastCompleteMonths(now time.Time, count int) []string {
|
||||
months := make([]string, 0, count)
|
||||
cursor := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, -1, 0)
|
||||
for i := 0; i < count; i++ {
|
||||
months = append(months, cursor.Format("2006-01"))
|
||||
cursor = cursor.AddDate(0, -1, 0)
|
||||
}
|
||||
return months
|
||||
}
|
||||
|
||||
// monthRange 把月份清单换算成查询时间范围(含首尾)。
|
||||
func monthRange(months []string) (time.Time, time.Time, error) {
|
||||
if len(months) == 0 {
|
||||
return time.Time{}, time.Time{}, gerror.New("月份清单为空")
|
||||
}
|
||||
parsed := make([]time.Time, 0, len(months))
|
||||
for _, month := range months {
|
||||
value, err := time.ParseInLocation("2006-01", month, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, gerror.Wrapf(err, "月份格式错误:%s", month)
|
||||
}
|
||||
parsed = append(parsed, value)
|
||||
}
|
||||
sort.Slice(parsed, func(i, j int) bool { return parsed[i].Before(parsed[j]) })
|
||||
start := parsed[0]
|
||||
end := parsed[len(parsed)-1].AddDate(0, 1, 0).Add(-time.Second)
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// isSafeIccid ICCID 拼进 SQL 字面量前的白名单校验。
|
||||
// ICCID 正常只含数字,个别批次可能带字母;除此之外一律拒绝,避免拼接注入。
|
||||
func isSafeIccid(value string) bool {
|
||||
if value == "" || len(value) > 32 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateTDEngineNames(database, stable string) error {
|
||||
|
||||
if database == "" || stable == "" {
|
||||
return gerror.New("流量库配置不完整")
|
||||
}
|
||||
for _, value := range []string{database, stable} {
|
||||
for _, r := range value {
|
||||
if !(r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
||||
return gerror.Newf("流量库标识非法:%s", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatTime(value *gtime.Time) string {
|
||||
if value == nil || value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.Format("Y-m-d H:i:s")
|
||||
}
|
||||
Reference in New Issue
Block a user