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

This commit is contained in:
MiFaZhan
2026-10-29 14:36:49 +08:00
parent 743e8e6e6e
commit 2206a393f7
516 changed files with 18119 additions and 2253 deletions
@@ -0,0 +1,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")
}