858 lines
27 KiB
Go
858 lines
27 KiB
Go
// 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)
|
||
}
|