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,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)
}