1590 lines
49 KiB
Go
1590 lines
49 KiB
Go
package inbound
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"math/big"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gogf/gf/v2/errors/gerror"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/xuri/excelize/v2"
|
||
|
||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
|
||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||
)
|
||
|
||
const (
|
||
cascadeFailureReason = "校验通过,但本批次存在失败明细,整批未执行入库"
|
||
maxIdentifierLength = 32
|
||
minICCIDLength = 19
|
||
minIMSILength = 15
|
||
minMSISDNLength = 11
|
||
)
|
||
|
||
const (
|
||
inboundUpstreamBatchSizeConfigKey = "batch.inbound.upstreamBatchSize"
|
||
inboundUpstreamConcurrencyKey = "batch.inbound.upstreamConcurrency"
|
||
defaultInboundUpstreamBatchSize = 1
|
||
defaultInboundUpstreamConcurrency = 8
|
||
)
|
||
|
||
var tokenSplitter = regexp.MustCompile(`[,\t,\s]+`)
|
||
|
||
type executor struct {
|
||
repository Repository
|
||
upstream Upstream
|
||
rateLimiter upstreamBasicInfoRateLimiter
|
||
}
|
||
|
||
func NewExecutor(repository Repository) *executor {
|
||
return &executor{repository: repository}
|
||
}
|
||
|
||
func NewExecutorWithUpstream(repository Repository, upstream Upstream) *executor {
|
||
return &executor{
|
||
repository: repository,
|
||
upstream: upstream,
|
||
rateLimiter: newUpstreamBasicInfoRateLimiter(),
|
||
}
|
||
}
|
||
|
||
func (e *executor) Execute(ctx context.Context, batchNo string) (err error) {
|
||
totalStarted := time.Now()
|
||
var task *Task
|
||
defer func() {
|
||
logInboundTotal(ctx, batchNo, task, totalStarted, err)
|
||
}()
|
||
|
||
stageStarted := time.Now()
|
||
task, err = e.repository.Load(ctx, batchNo)
|
||
if err != nil {
|
||
logInboundStageByBatchNo(ctx, batchNo, "load_batch", stageStarted, 0, 0, 0, err)
|
||
return gerror.Wrap(err, "load inbound batch failed")
|
||
}
|
||
logInboundStage(ctx, task, "load_batch", stageStarted, nil)
|
||
|
||
stageStarted = time.Now()
|
||
if err = e.populateRows(ctx, task); err != nil {
|
||
logInboundStage(ctx, task, "populate_rows", stageStarted, err)
|
||
return err
|
||
}
|
||
logInboundStage(ctx, task, "populate_rows", stageStarted, nil)
|
||
|
||
stageStarted = time.Now()
|
||
if err = e.validate(ctx, task); err != nil {
|
||
logInboundStage(ctx, task, "validate", stageStarted, err)
|
||
return err
|
||
}
|
||
logInboundStage(ctx, task, "validate", stageStarted, nil)
|
||
|
||
if len(task.Rows) == 0 {
|
||
stageStarted = time.Now()
|
||
if err = e.repository.SaveValidationFailure(ctx, task); err != nil {
|
||
logInboundStage(ctx, task, "save_validation_failure", stageStarted, err)
|
||
return gerror.Wrap(err, "save empty inbound failure failed")
|
||
}
|
||
logInboundStage(ctx, task, "save_validation_failure", stageStarted, nil)
|
||
_ = e.saveResultFile(ctx, task)
|
||
return nil
|
||
}
|
||
if hasFailedRows(task.Rows) {
|
||
applyCascadeFailure(task)
|
||
stageStarted = time.Now()
|
||
if err = e.repository.SaveValidationFailure(ctx, task); err != nil {
|
||
logInboundStage(ctx, task, "save_validation_failure", stageStarted, err)
|
||
return gerror.Wrap(err, "save inbound validation failure failed")
|
||
}
|
||
logInboundStage(ctx, task, "save_validation_failure", stageStarted, nil)
|
||
_ = e.saveResultFile(ctx, task)
|
||
return nil
|
||
}
|
||
|
||
stageStarted = time.Now()
|
||
if err = e.repository.SaveSuccess(ctx, task); err != nil {
|
||
logInboundStage(ctx, task, "save_success", stageStarted, err)
|
||
g.Log().Errorf(ctx, "inbound batch DB write failed, batchNo:%s, err:%+v", task.Batch.BatchNo, err)
|
||
stageStarted = time.Now()
|
||
if saveErr := e.repository.SaveBusinessFailure(ctx, task, "数据库写入失败,本次整批未入库"); saveErr != nil {
|
||
logInboundStage(ctx, task, "save_business_failure", stageStarted, saveErr)
|
||
return gerror.Wrap(saveErr, "save inbound DB failure failed")
|
||
}
|
||
logInboundStage(ctx, task, "save_business_failure", stageStarted, nil)
|
||
_ = e.saveResultFile(ctx, task)
|
||
return nil
|
||
}
|
||
logInboundStage(ctx, task, "save_success", stageStarted, nil)
|
||
_ = e.saveResultFile(ctx, task)
|
||
return nil
|
||
}
|
||
|
||
func (e *executor) saveResultFile(ctx context.Context, task *Task) error {
|
||
stageStarted := time.Now()
|
||
if err := e.repository.SaveResultFile(ctx, task, resultRequest(task)); err != nil {
|
||
g.Log().Errorf(ctx, "save inbound result file status failed, batchNo:%s, err:%+v", task.Batch.BatchNo, err)
|
||
logInboundStage(ctx, task, "result_file", stageStarted, err)
|
||
return err
|
||
}
|
||
logInboundStage(ctx, task, "result_file", stageStarted, nil)
|
||
return nil
|
||
}
|
||
|
||
func logInboundStage(ctx context.Context, task *Task, stage string, started time.Time, err error) {
|
||
batchNo := ""
|
||
rows, valid, failed := 0, 0, 0
|
||
if task != nil {
|
||
batchNo = task.Batch.BatchNo
|
||
rows = len(task.Rows)
|
||
valid = countValidRows(task.Rows)
|
||
failed = countFailedRows(task.Rows)
|
||
}
|
||
logInboundStageByBatchNo(ctx, batchNo, stage, started, rows, valid, failed, err)
|
||
}
|
||
|
||
func logInboundStageByBatchNo(ctx context.Context, batchNo, stage string, started time.Time, rows, valid, failed int, err error) {
|
||
durationMs := time.Since(started).Milliseconds()
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "inbound batch stage failed, batchNo:%s, stage:%s, rows:%d, valid:%d, failed:%d, durationMs:%d, err:%+v",
|
||
batchNo, stage, rows, valid, failed, durationMs, err)
|
||
return
|
||
}
|
||
g.Log().Infof(ctx, "inbound batch stage completed, batchNo:%s, stage:%s, rows:%d, valid:%d, failed:%d, durationMs:%d",
|
||
batchNo, stage, rows, valid, failed, durationMs)
|
||
}
|
||
|
||
func logInboundTotal(ctx context.Context, batchNo string, task *Task, started time.Time, err error) {
|
||
rows, valid, failed := 0, 0, 0
|
||
outcome := "success"
|
||
if task != nil {
|
||
if task.Batch.BatchNo != "" {
|
||
batchNo = task.Batch.BatchNo
|
||
}
|
||
rows = len(task.Rows)
|
||
valid = countValidRows(task.Rows)
|
||
failed = countFailedRows(task.Rows)
|
||
if task.FailureReason != "" || failed > 0 {
|
||
outcome = "business_failed"
|
||
}
|
||
}
|
||
if err != nil {
|
||
outcome = "error"
|
||
}
|
||
duration := time.Since(started)
|
||
rowsPerSecond := 0.0
|
||
if duration > 0 && rows > 0 {
|
||
rowsPerSecond = float64(rows) / duration.Seconds()
|
||
}
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "inbound batch finished, batchNo:%s, outcome:%s, rows:%d, valid:%d, failed:%d, durationMs:%d, rowsPerSecond:%.2f, err:%+v",
|
||
batchNo, outcome, rows, valid, failed, duration.Milliseconds(), rowsPerSecond, err)
|
||
return
|
||
}
|
||
g.Log().Infof(ctx, "inbound batch finished, batchNo:%s, outcome:%s, rows:%d, valid:%d, failed:%d, durationMs:%d, rowsPerSecond:%.2f",
|
||
batchNo, outcome, rows, valid, failed, duration.Milliseconds(), rowsPerSecond)
|
||
}
|
||
|
||
func (e *executor) populateRows(ctx context.Context, task *Task) error {
|
||
switch task.Batch.SourceType {
|
||
case consts.BatchSourceTypeFile:
|
||
return e.populateExcelRows(ctx, task)
|
||
case consts.BatchSourceTypeBatchInput:
|
||
return e.populateInputRows(ctx, task)
|
||
case consts.BatchSourceTypeRange:
|
||
return e.populateRangeRows(task)
|
||
default:
|
||
return e.populateRowsBySourceShape(ctx, task)
|
||
}
|
||
}
|
||
|
||
func (e *executor) populateRowsBySourceShape(ctx context.Context, task *Task) error {
|
||
switch {
|
||
case task.Source.ImportFileOssID > 0:
|
||
return e.populateExcelRows(ctx, task)
|
||
case task.Source.InputText != "" || task.Source.InputFileOssID > 0:
|
||
return e.populateInputRows(ctx, task)
|
||
case task.Source.Range.StartICCID != "" || task.Source.Range.EndICCID != "":
|
||
return e.populateRangeRows(task)
|
||
default:
|
||
return gerror.Newf("unsupported inbound source type %d", task.Batch.SourceType)
|
||
}
|
||
}
|
||
|
||
func (e *executor) populateExcelRows(ctx context.Context, task *Task) error {
|
||
if task.Source.ImportFileOssID <= 0 {
|
||
return gerror.New("inbound Excel source importFileOssId is empty")
|
||
}
|
||
content, name, err := e.repository.ReadOssFile(ctx, task.Source.ImportFileOssID)
|
||
if err != nil {
|
||
return batch.Retryable(gerror.Wrap(err, "read inbound Excel source failed"))
|
||
}
|
||
workbook, err := excelize.OpenReader(bytes.NewReader(content))
|
||
if err != nil {
|
||
return gerror.Wrapf(err, "open inbound Excel source %s failed", name)
|
||
}
|
||
defer func() { _ = workbook.Close() }()
|
||
sheets := workbook.GetSheetList()
|
||
if len(sheets) == 0 {
|
||
return gerror.New("inbound Excel source has no sheets")
|
||
}
|
||
lines, err := workbook.GetRows(sheets[0])
|
||
if err != nil {
|
||
return gerror.Wrap(err, "read inbound Excel rows failed")
|
||
}
|
||
if len(lines) == 0 || !hasInboundIdentifierHeader(headerIndex(lines[0])) {
|
||
return gerror.New("入库文件表头无效:缺少 ICCID、MSISDN 或 IMSI 列")
|
||
}
|
||
task.Rows = parseTableRows(lines)
|
||
return nil
|
||
}
|
||
|
||
func (e *executor) populateInputRows(ctx context.Context, task *Task) error {
|
||
text := task.Source.InputText
|
||
if strings.TrimSpace(text) == "" && task.Source.InputFileOssID > 0 {
|
||
content, name, err := e.repository.ReadOssFile(ctx, task.Source.InputFileOssID)
|
||
if err != nil {
|
||
return batch.Retryable(gerror.Wrap(err, "read inbound input source failed"))
|
||
}
|
||
if strings.ToLower(filepath.Ext(name)) != ".txt" {
|
||
g.Log().Warningf(ctx, "inbound input source file is not .txt, batchNo:%s, file:%s", task.Batch.BatchNo, name)
|
||
}
|
||
text = string(content)
|
||
}
|
||
task.Rows = parseInputText(text)
|
||
return nil
|
||
}
|
||
|
||
func (e *executor) populateRangeRows(task *Task) error {
|
||
rows, err := expandRange(task.Source.Range)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
task.Rows = rows
|
||
return nil
|
||
}
|
||
|
||
func (e *executor) validate(ctx context.Context, task *Task) error {
|
||
if len(task.Rows) == 0 {
|
||
task.FailureReason = "未解析到有效入库数据"
|
||
return nil
|
||
}
|
||
useOptionFallback := shouldUseInboundOptionFallback(task)
|
||
if !useOptionFallback {
|
||
task.Options = Options{ValidateUpstreamBasicInfo: task.Options.ValidateUpstreamBasicInfo}
|
||
}
|
||
for _, row := range task.Rows {
|
||
normalizeRow(row, task.Options, useOptionFallback)
|
||
validateRow(row, task.Source.Range.RangeType)
|
||
}
|
||
syncInboundOptionsFromRows(task)
|
||
markSameBatchDuplicates(task.Rows)
|
||
if hasFailedRows(task.Rows) {
|
||
markIncompleteRows(task.Rows)
|
||
task.ValidCount = countValidRows(task.Rows)
|
||
return nil
|
||
}
|
||
if err := e.markStockDuplicates(ctx, task); err != nil {
|
||
return err
|
||
}
|
||
if hasFailedRows(task.Rows) {
|
||
markIncompleteRows(task.Rows)
|
||
task.ValidCount = countValidRows(task.Rows)
|
||
return nil
|
||
}
|
||
task.Options.ValidateUpstreamBasicInfo = normalizeUpstreamBasicInfoValidationOption(task.Options.ValidateUpstreamBasicInfo)
|
||
if e.upstream != nil && task.Options.ValidateUpstreamBasicInfo == "1" {
|
||
if err := e.validateUpstreamBasicInfo(ctx, task); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
markIncompleteRows(task.Rows)
|
||
markSameBatchDuplicates(task.Rows)
|
||
if err := e.markStockDuplicates(ctx, task); err != nil {
|
||
return err
|
||
}
|
||
task.ValidCount = countValidRows(task.Rows)
|
||
return nil
|
||
}
|
||
|
||
func (e *executor) markStockDuplicates(ctx context.Context, task *Task) error {
|
||
iccids, msisdns, imsis := rowKeys(task.Rows)
|
||
stocks, err := e.repository.FindExistingStocks(ctx, task.Batch.TenantID, iccids, msisdns, imsis)
|
||
if err != nil {
|
||
return gerror.Wrap(err, "query inbound stock duplicates failed")
|
||
}
|
||
markStockDuplicates(task.Rows, stocks)
|
||
return nil
|
||
}
|
||
|
||
func parseInputText(text string) []*Row {
|
||
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||
rows := make([]*Row, 0, len(lines))
|
||
for index, line := range lines {
|
||
fields := splitFields(line)
|
||
if len(fields) == 0 {
|
||
continue
|
||
}
|
||
if len(fields) == 1 {
|
||
rows = append(rows, rowFromSingleIdentifier(index+1, fields[0]))
|
||
continue
|
||
}
|
||
rows = append(rows, rowFromFields(index+1, fields, nil))
|
||
}
|
||
return rows
|
||
}
|
||
|
||
func parseTableRows(lines [][]string) []*Row {
|
||
if len(lines) == 0 {
|
||
return nil
|
||
}
|
||
header := headerIndex(lines[0])
|
||
start := 0
|
||
if len(header) > 0 {
|
||
start = 1
|
||
}
|
||
rows := make([]*Row, 0, len(lines)-start)
|
||
for index := start; index < len(lines); index++ {
|
||
fields := trimRow(lines[index])
|
||
if rowEmpty(fields) {
|
||
continue
|
||
}
|
||
if len(header) > 0 {
|
||
rows = append(rows, rowFromHeader(index+1, fields, header))
|
||
continue
|
||
}
|
||
rows = append(rows, rowFromFields(index+1, fields, nil))
|
||
}
|
||
return rows
|
||
}
|
||
|
||
func hasInboundIdentifierHeader(header map[string]int) bool {
|
||
for _, key := range []string{"iccid", "msisdn", "imsi"} {
|
||
if _, ok := header[key]; ok {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func rowFromHeader(rowNo int, fields []string, header map[string]int) *Row {
|
||
raw := make(map[string]string, len(header))
|
||
for key, index := range header {
|
||
if index >= 0 && index < len(fields) {
|
||
raw[key] = fields[index]
|
||
}
|
||
}
|
||
return &Row{
|
||
RowNo: rowNo,
|
||
ICCID: valueForHeader(fields, header, "iccid"),
|
||
MSISDN: valueForHeader(fields, header, "msisdn"),
|
||
IMSI: valueForHeader(fields, header, "imsi"),
|
||
IMEI: valueForHeader(fields, header, "imei"),
|
||
ChanKey: firstNonEmpty(
|
||
valueForHeader(fields, header, "chanKey"),
|
||
valueForHeader(fields, header, "chan_key"),
|
||
),
|
||
CardType: valueForHeader(fields, header, "cardType"),
|
||
UpPkgCode: valueForHeader(fields, header, "upPkgCode"),
|
||
SuppliersCode: valueForHeader(fields, header, "suppliersCode"),
|
||
UpstreamActivationTime: valueForHeader(fields, header, "upstreamActivationTime"),
|
||
InsertToPlugEnabled: valueForHeader(fields, header, "insertToPlugEnabled"),
|
||
CarrierTrafficCutoffEnabled: valueForHeader(fields, header, "carrierTrafficCutoffEnabled"),
|
||
AgreementExpiryDate: valueForHeader(fields, header, "agreementExpiryDate"),
|
||
HasTestPeriod: valueForHeader(fields, header, "hasTestPeriod"),
|
||
TestPeriodDays: valueForHeader(fields, header, "testPeriodDays"),
|
||
TestPeriodLatestExpiryDate: valueForHeader(fields, header, "testPeriodLatestExpiryDate"),
|
||
HasSilentPeriod: valueForHeader(fields, header, "hasSilentPeriod"),
|
||
SilentPeriodDays: valueForHeader(fields, header, "silentPeriodDays"),
|
||
SilentPeriodLatestExpiryDate: valueForHeader(fields, header, "silentPeriodLatestExpiryDate"),
|
||
Raw: raw,
|
||
}
|
||
}
|
||
|
||
func rowFromFields(rowNo int, fields []string, raw map[string]string) *Row {
|
||
return &Row{
|
||
RowNo: rowNo,
|
||
InputCode: valueAt(fields, 0),
|
||
ICCID: valueAt(fields, 0),
|
||
MSISDN: valueAt(fields, 1),
|
||
IMSI: valueAt(fields, 2),
|
||
IMEI: valueAt(fields, 3),
|
||
ChanKey: valueAt(fields, 4),
|
||
Raw: raw,
|
||
}
|
||
}
|
||
|
||
func rowFromSingleIdentifier(rowNo int, code string) *Row {
|
||
code = strings.TrimSpace(code)
|
||
row := &Row{
|
||
RowNo: rowNo,
|
||
InputCode: code,
|
||
Raw: map[string]string{"inputCode": code},
|
||
}
|
||
switch inferIdentifierType(code) {
|
||
case "iccid":
|
||
row.ICCID = code
|
||
case "imsi":
|
||
row.IMSI = code
|
||
case "msisdn":
|
||
row.MSISDN = code
|
||
}
|
||
return row
|
||
}
|
||
|
||
func inferIdentifierType(code string) string {
|
||
code = strings.TrimSpace(code)
|
||
switch {
|
||
case code == "":
|
||
return ""
|
||
case len(code) >= minICCIDLength && len(code) <= maxIdentifierLength && allAlphaNumeric(code):
|
||
return "iccid"
|
||
case len(code) >= minIMSILength && len(code) < minICCIDLength && allDigits(code):
|
||
return "imsi"
|
||
case len(code) >= minMSISDNLength && len(code) < minIMSILength && allDigits(code):
|
||
return "msisdn"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func headerIndex(fields []string) map[string]int {
|
||
indexes := make(map[string]int)
|
||
for index, field := range fields {
|
||
key := normalizeHeader(field)
|
||
switch key {
|
||
case "iccid", "msisdn", "imsi", "imei", "chankey", "chan_key",
|
||
"cardType", "upPkgCode", "suppliersCode", "upstreamActivationTime",
|
||
"insertToPlugEnabled", "carrierTrafficCutoffEnabled", "agreementExpiryDate",
|
||
"hasTestPeriod", "testPeriodDays", "testPeriodLatestExpiryDate",
|
||
"hasSilentPeriod", "silentPeriodDays", "silentPeriodLatestExpiryDate":
|
||
if key == "chankey" {
|
||
key = "chanKey"
|
||
}
|
||
indexes[key] = index
|
||
}
|
||
}
|
||
if !hasInboundIdentifierHeader(indexes) {
|
||
return nil
|
||
}
|
||
return indexes
|
||
}
|
||
|
||
func normalizeHeader(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
value = strings.Trim(value, "\ufeff")
|
||
value = strings.TrimRight(value, "**")
|
||
value = strings.TrimSpace(value)
|
||
value = strings.ToLower(value)
|
||
value = strings.NewReplacer(
|
||
" ", "",
|
||
"_", "",
|
||
"-", "",
|
||
"/", "",
|
||
"(", "",
|
||
")", "",
|
||
"(", "",
|
||
")", "",
|
||
":", "",
|
||
":", "",
|
||
).Replace(value)
|
||
switch value {
|
||
case "iccid", "iccid号", "集成电路卡识别码":
|
||
return "iccid"
|
||
case "msisdn", "卡号", "手机号", "接入号", "号码":
|
||
return "msisdn"
|
||
case "imsi", "国际移动用户识别码":
|
||
return "imsi"
|
||
case "imei", "国际移动设备识别码":
|
||
return "imei"
|
||
case "chankey", "通道", "通道编号", "通道编码", "通道标识":
|
||
return "chankey"
|
||
case "cardtype", "卡类型", "卡片类型":
|
||
return "cardType"
|
||
case "uppkgcode", "上游套餐", "上游套餐编码", "上游套餐编号":
|
||
return "upPkgCode"
|
||
case "supplierscode", "suppliercode", "供应商", "供应商编码", "供应商编号":
|
||
return "suppliersCode"
|
||
case "upstreamactivationtime", "上游激活时间", "上游给出激活时间", "上游开户时间":
|
||
return "upstreamActivationTime"
|
||
case "inserttoplugenabled", "是否贴片转插拔", "贴片转插拔":
|
||
return "insertToPlugEnabled"
|
||
case "carriertrafficcutoffenabled", "是否运营商达量断网", "运营商达量断网":
|
||
return "carrierTrafficCutoffEnabled"
|
||
case "agreementexpirydate", "协议到期时间", "协议期到期时间":
|
||
return "agreementExpiryDate"
|
||
case "hastestperiod", "是否有测试期", "有测试期":
|
||
return "hasTestPeriod"
|
||
case "testperioddays", "测试期天数":
|
||
return "testPeriodDays"
|
||
case "testperiodlatestexpirydate", "测试期最晚过期日期", "测试期最晚到期日期":
|
||
return "testPeriodLatestExpiryDate"
|
||
case "hassilentperiod", "是否有沉默期", "有沉默期":
|
||
return "hasSilentPeriod"
|
||
case "silentperioddays", "沉默期天数":
|
||
return "silentPeriodDays"
|
||
case "silentperiodlatestexpirydate", "沉默期最晚过期日期", "沉默期最晚到期日期":
|
||
return "silentPeriodLatestExpiryDate"
|
||
default:
|
||
return value
|
||
}
|
||
}
|
||
|
||
func splitFields(line string) []string {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
return nil
|
||
}
|
||
parts := tokenSplitter.Split(line, -1)
|
||
fields := make([]string, 0, len(parts))
|
||
for _, part := range parts {
|
||
if value := strings.TrimSpace(part); value != "" {
|
||
fields = append(fields, value)
|
||
}
|
||
}
|
||
return fields
|
||
}
|
||
|
||
func trimRow(values []string) []string {
|
||
fields := make([]string, len(values))
|
||
for index, value := range values {
|
||
fields[index] = strings.TrimSpace(value)
|
||
}
|
||
return fields
|
||
}
|
||
|
||
func rowEmpty(fields []string) bool {
|
||
for _, field := range fields {
|
||
if strings.TrimSpace(field) != "" {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func expandRange(input Range) ([]*Row, error) {
|
||
rangeType := strings.ToLower(strings.TrimSpace(input.RangeType))
|
||
if rangeType == "" {
|
||
rangeType = "iccid"
|
||
}
|
||
if rangeType != "iccid" && rangeType != "msisdn" && rangeType != "imsi" {
|
||
return nil, gerror.New("rangeType must be iccid, msisdn or imsi")
|
||
}
|
||
|
||
if rangeType == "msisdn" {
|
||
return expandSelectedRange(input.StartMSISDN, input.EndMSISDN, "msisdn")
|
||
}
|
||
if rangeType == "imsi" {
|
||
return expandSelectedRange(input.StartIMSI, input.EndIMSI, "imsi")
|
||
}
|
||
|
||
iccidValues, err := iccidRange(input.StartICCID, input.EndICCID, true)
|
||
if err != nil {
|
||
return nil, gerror.Wrap(err, "invalid ICCID range")
|
||
}
|
||
msisdnValues, err := numericRange(input.StartMSISDN, input.EndMSISDN, false)
|
||
if err != nil {
|
||
return nil, gerror.Wrap(err, "invalid MSISDN range")
|
||
}
|
||
imsiValues, err := numericRange(input.StartIMSI, input.EndIMSI, false)
|
||
if err != nil {
|
||
return nil, gerror.Wrap(err, "invalid IMSI range")
|
||
}
|
||
if err = ensureRangeLength(len(iccidValues), len(msisdnValues), "MSISDN"); err != nil {
|
||
return nil, err
|
||
}
|
||
if err = ensureRangeLength(len(iccidValues), len(imsiValues), "IMSI"); err != nil {
|
||
return nil, err
|
||
}
|
||
rows := make([]*Row, 0, len(iccidValues))
|
||
for index, iccid := range iccidValues {
|
||
row := &Row{RowNo: index + 1, ICCID: iccid}
|
||
if len(msisdnValues) > 0 {
|
||
row.MSISDN = msisdnValues[index]
|
||
}
|
||
if len(imsiValues) > 0 {
|
||
row.IMSI = imsiValues[index]
|
||
}
|
||
rows = append(rows, row)
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
func expandSelectedRange(start, end, rangeType string) ([]*Row, error) {
|
||
values, err := numericRange(start, end, true)
|
||
if err != nil {
|
||
return nil, gerror.Wrapf(err, "invalid %s range", strings.ToUpper(rangeType))
|
||
}
|
||
rows := make([]*Row, 0, len(values))
|
||
for index, value := range values {
|
||
row := &Row{RowNo: index + 1}
|
||
switch rangeType {
|
||
case "msisdn":
|
||
row.MSISDN = value
|
||
case "imsi":
|
||
row.IMSI = value
|
||
}
|
||
rows = append(rows, row)
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
func iccidRange(start, end string, required bool) ([]string, error) {
|
||
start = strings.TrimSpace(start)
|
||
end = strings.TrimSpace(end)
|
||
if start == "" && end == "" && !required {
|
||
return nil, nil
|
||
}
|
||
if start == "" || end == "" {
|
||
return nil, gerror.New("start and end are required together")
|
||
}
|
||
if !allAlphaNumeric(start) || !allAlphaNumeric(end) {
|
||
return nil, gerror.New("range values must be alphanumeric")
|
||
}
|
||
if len(start) != len(end) {
|
||
return nil, gerror.New("range values must have the same length")
|
||
}
|
||
if start == end {
|
||
return []string{start}, nil
|
||
}
|
||
startPrefix, startTail := splitTrailingDigits(start)
|
||
endPrefix, endTail := splitTrailingDigits(end)
|
||
if startTail == "" || endTail == "" {
|
||
return nil, gerror.New("range values must contain incrementable numeric suffixes")
|
||
}
|
||
if startPrefix != endPrefix {
|
||
return nil, gerror.New("range value prefixes must match")
|
||
}
|
||
tails, err := numericRange(startTail, endTail, true)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
values := make([]string, 0, len(tails))
|
||
for _, tail := range tails {
|
||
values = append(values, startPrefix+tail)
|
||
}
|
||
return values, nil
|
||
}
|
||
|
||
func numericRange(start, end string, required bool) ([]string, error) {
|
||
start = strings.TrimSpace(start)
|
||
end = strings.TrimSpace(end)
|
||
if start == "" && end == "" && !required {
|
||
return nil, nil
|
||
}
|
||
if start == "" || end == "" {
|
||
return nil, gerror.New("start and end are required together")
|
||
}
|
||
if !allDigits(start) || !allDigits(end) {
|
||
return nil, gerror.New("range values must be numeric")
|
||
}
|
||
startInt, ok := new(big.Int).SetString(start, 10)
|
||
if !ok {
|
||
return nil, gerror.New("invalid start value")
|
||
}
|
||
endInt, ok := new(big.Int).SetString(end, 10)
|
||
if !ok {
|
||
return nil, gerror.New("invalid end value")
|
||
}
|
||
if startInt.Cmp(endInt) > 0 {
|
||
return nil, gerror.New("start must be less than or equal to end")
|
||
}
|
||
count := new(big.Int).Sub(endInt, startInt)
|
||
count.Add(count, big.NewInt(1))
|
||
if !count.IsInt64() || count.Int64() > 100000 {
|
||
return nil, gerror.New("range is too large")
|
||
}
|
||
width := len(start)
|
||
if len(end) > width {
|
||
width = len(end)
|
||
}
|
||
values := make([]string, 0, int(count.Int64()))
|
||
current := new(big.Int).Set(startInt)
|
||
for current.Cmp(endInt) <= 0 {
|
||
value := current.String()
|
||
if len(value) < width {
|
||
value = strings.Repeat("0", width-len(value)) + value
|
||
}
|
||
values = append(values, value)
|
||
current.Add(current, big.NewInt(1))
|
||
}
|
||
return values, nil
|
||
}
|
||
|
||
func ensureRangeLength(iccidLength, otherLength int, name string) error {
|
||
if otherLength > 0 && otherLength != iccidLength {
|
||
return gerror.Newf("%s range count must match ICCID range count", name)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeRow(row *Row, options Options, useOptionFallback bool) {
|
||
if row == nil {
|
||
return
|
||
}
|
||
row.ICCID = strings.TrimSpace(row.ICCID)
|
||
row.MSISDN = strings.TrimSpace(row.MSISDN)
|
||
row.IMSI = strings.TrimSpace(row.IMSI)
|
||
row.IMEI = strings.TrimSpace(row.IMEI)
|
||
row.ChanKey = strings.TrimSpace(firstNonEmpty(row.ChanKey, fallbackOption(useOptionFallback, options.ChanKey)))
|
||
row.CardType = normalizeCardType(firstNonEmpty(row.CardType, fallbackOption(useOptionFallback, options.CardType)))
|
||
row.UpPkgCode = strings.TrimSpace(firstNonEmpty(row.UpPkgCode, fallbackOption(useOptionFallback, options.UpPkgCode)))
|
||
row.SuppliersCode = strings.TrimSpace(firstNonEmpty(row.SuppliersCode, fallbackOption(useOptionFallback, options.SuppliersCode)))
|
||
row.UpstreamActivationTime = strings.TrimSpace(firstNonEmpty(row.UpstreamActivationTime, fallbackOption(useOptionFallback, options.UpstreamActivationTime)))
|
||
row.InsertToPlugEnabled = normalizeYesNo(firstNonEmpty(row.InsertToPlugEnabled, fallbackOption(useOptionFallback, options.InsertToPlugEnabled)))
|
||
row.CarrierTrafficCutoffEnabled = normalizeYesNo(firstNonEmpty(row.CarrierTrafficCutoffEnabled, fallbackOption(useOptionFallback, options.CarrierTrafficCutoffEnabled)))
|
||
row.AgreementExpiryDate = strings.TrimSpace(firstNonEmpty(row.AgreementExpiryDate, fallbackOption(useOptionFallback, options.AgreementExpiryDate)))
|
||
row.HasTestPeriod = normalizeYesNo(firstNonEmpty(row.HasTestPeriod, fallbackOption(useOptionFallback, options.HasTestPeriod)))
|
||
row.TestPeriodDays = strings.TrimSpace(firstNonEmpty(row.TestPeriodDays, fallbackOption(useOptionFallback, options.TestPeriodDays)))
|
||
row.TestPeriodLatestExpiryDate = strings.TrimSpace(firstNonEmpty(row.TestPeriodLatestExpiryDate, fallbackOption(useOptionFallback, options.TestPeriodLatestExpiryDate)))
|
||
row.HasSilentPeriod = normalizeYesNo(firstNonEmpty(row.HasSilentPeriod, fallbackOption(useOptionFallback, options.HasSilentPeriod)))
|
||
row.SilentPeriodDays = strings.TrimSpace(firstNonEmpty(row.SilentPeriodDays, fallbackOption(useOptionFallback, options.SilentPeriodDays)))
|
||
row.SilentPeriodLatestExpiryDate = strings.TrimSpace(firstNonEmpty(row.SilentPeriodLatestExpiryDate, fallbackOption(useOptionFallback, options.SilentPeriodLatestExpiryDate)))
|
||
}
|
||
|
||
func fallbackOption(enabled bool, value string) string {
|
||
if !enabled {
|
||
return ""
|
||
}
|
||
return value
|
||
}
|
||
|
||
func shouldUseInboundOptionFallback(task *Task) bool {
|
||
if task == nil {
|
||
return false
|
||
}
|
||
return task.Batch.SourceType != consts.BatchSourceTypeFile && task.Source.ImportFileOssID <= 0
|
||
}
|
||
|
||
func validateRow(row *Row, rangeType string) {
|
||
if row == nil {
|
||
return
|
||
}
|
||
var reasons []string
|
||
if row.ICCID == "" && row.MSISDN == "" && row.IMSI == "" {
|
||
reasons = append(reasons, "ICCID/MSISDN/IMSI至少填写一个")
|
||
}
|
||
if row.ICCID != "" && (len(row.ICCID) < minICCIDLength || len(row.ICCID) > maxIdentifierLength || !allAlphaNumeric(row.ICCID)) {
|
||
reasons = append(reasons, "ICCID格式不正确")
|
||
}
|
||
if row.MSISDN != "" && (len(row.MSISDN) < minMSISDNLength || len(row.MSISDN) > maxIdentifierLength || !allDigits(row.MSISDN)) {
|
||
reasons = append(reasons, "MSISDN格式不正确")
|
||
}
|
||
if row.IMSI != "" && (len(row.IMSI) < minIMSILength || len(row.IMSI) > maxIdentifierLength || !allDigits(row.IMSI)) {
|
||
reasons = append(reasons, "IMSI格式不正确")
|
||
}
|
||
if row.IMEI != "" && (len(row.IMEI) > maxIdentifierLength || !allDigits(row.IMEI)) {
|
||
reasons = append(reasons, "IMEI格式不正确")
|
||
}
|
||
if row.ChanKey == "" {
|
||
reasons = append(reasons, "chanKey不能为空")
|
||
}
|
||
if len(reasons) > 0 {
|
||
row.Reason = strings.Join(reasons, ";")
|
||
}
|
||
}
|
||
|
||
func markSameBatchDuplicates(rows []*Row) {
|
||
seenICCID := make(map[string]struct{}, len(rows))
|
||
seenMSISDN := make(map[string]struct{}, len(rows))
|
||
seenIMSI := make(map[string]struct{}, len(rows))
|
||
for _, row := range rows {
|
||
if row == nil || row.Reason != "" {
|
||
continue
|
||
}
|
||
if row.ICCID != "" {
|
||
if _, ok := seenICCID[row.ICCID]; ok {
|
||
appendReason(row, "同批重复ICCID")
|
||
} else {
|
||
seenICCID[row.ICCID] = struct{}{}
|
||
}
|
||
}
|
||
if row.MSISDN != "" {
|
||
if _, ok := seenMSISDN[row.MSISDN]; ok {
|
||
appendReason(row, "同批重复MSISDN")
|
||
} else {
|
||
seenMSISDN[row.MSISDN] = struct{}{}
|
||
}
|
||
}
|
||
if row.IMSI != "" {
|
||
if _, ok := seenIMSI[row.IMSI]; ok {
|
||
appendReason(row, "同批重复IMSI")
|
||
} else {
|
||
seenIMSI[row.IMSI] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func markStockDuplicates(rows []*Row, stocks []Stock) {
|
||
existingICCID := make(map[string]struct{}, len(stocks))
|
||
existingMSISDN := make(map[string]struct{}, len(stocks))
|
||
existingIMSI := make(map[string]struct{}, len(stocks))
|
||
for _, stock := range stocks {
|
||
if stock.ICCID != "" {
|
||
existingICCID[strings.TrimSpace(stock.ICCID)] = struct{}{}
|
||
}
|
||
if stock.MSISDN != "" {
|
||
existingMSISDN[strings.TrimSpace(stock.MSISDN)] = struct{}{}
|
||
}
|
||
if stock.IMSI != "" {
|
||
existingIMSI[strings.TrimSpace(stock.IMSI)] = struct{}{}
|
||
}
|
||
}
|
||
for _, row := range rows {
|
||
if row == nil || row.Reason != "" {
|
||
continue
|
||
}
|
||
if _, ok := existingICCID[row.ICCID]; ok && row.ICCID != "" {
|
||
appendReason(row, "ICCID已存在库存")
|
||
}
|
||
if _, ok := existingMSISDN[row.MSISDN]; ok && row.MSISDN != "" {
|
||
appendReason(row, "MSISDN已存在库存")
|
||
}
|
||
if _, ok := existingIMSI[row.IMSI]; ok && row.IMSI != "" {
|
||
appendReason(row, "IMSI已存在库存")
|
||
}
|
||
}
|
||
}
|
||
|
||
func markIncompleteRows(rows []*Row) {
|
||
for _, row := range rows {
|
||
if row == nil || row.Reason != "" {
|
||
continue
|
||
}
|
||
missing := make([]string, 0, 3)
|
||
if strings.TrimSpace(row.ICCID) == "" {
|
||
missing = append(missing, "ICCID")
|
||
}
|
||
if strings.TrimSpace(row.MSISDN) == "" {
|
||
missing = append(missing, "MSISDN")
|
||
}
|
||
if strings.TrimSpace(row.IMSI) == "" {
|
||
missing = append(missing, "IMSI")
|
||
}
|
||
if len(missing) > 0 {
|
||
appendReason(row, "三码信息不完整: "+strings.Join(missing, ","))
|
||
}
|
||
}
|
||
}
|
||
|
||
func normalizeUpstreamBasicInfoValidationOption(value string) string {
|
||
normalized := normalizeYesNo(value)
|
||
if normalized == "1" {
|
||
return "1"
|
||
}
|
||
return "0"
|
||
}
|
||
|
||
func syncInboundOptionsFromRows(task *Task) {
|
||
if task == nil {
|
||
return
|
||
}
|
||
checks := []struct {
|
||
name string
|
||
get func(*Row) string
|
||
assign func(string)
|
||
}{
|
||
{name: "通道编号", get: func(row *Row) string { return row.ChanKey }, assign: func(value string) { task.Options.ChanKey = value }},
|
||
{name: "卡类型", get: func(row *Row) string { return row.CardType }, assign: func(value string) { task.Options.CardType = value }},
|
||
{name: "上游套餐编码", get: func(row *Row) string { return row.UpPkgCode }, assign: func(value string) { task.Options.UpPkgCode = value }},
|
||
{name: "供应商编码", get: func(row *Row) string { return row.SuppliersCode }, assign: func(value string) { task.Options.SuppliersCode = value }},
|
||
{name: "上游激活时间", get: func(row *Row) string { return row.UpstreamActivationTime }, assign: func(value string) { task.Options.UpstreamActivationTime = value }},
|
||
{name: "是否贴片转插拔", get: func(row *Row) string { return row.InsertToPlugEnabled }, assign: func(value string) { task.Options.InsertToPlugEnabled = value }},
|
||
{name: "是否运营商达量断网", get: func(row *Row) string { return row.CarrierTrafficCutoffEnabled }, assign: func(value string) { task.Options.CarrierTrafficCutoffEnabled = value }},
|
||
{name: "协议到期时间", get: func(row *Row) string { return row.AgreementExpiryDate }, assign: func(value string) { task.Options.AgreementExpiryDate = value }},
|
||
{name: "是否有测试期", get: func(row *Row) string { return row.HasTestPeriod }, assign: func(value string) { task.Options.HasTestPeriod = value }},
|
||
{name: "测试期天数", get: func(row *Row) string { return row.TestPeriodDays }, assign: func(value string) { task.Options.TestPeriodDays = value }},
|
||
{name: "测试期最晚过期日期", get: func(row *Row) string { return row.TestPeriodLatestExpiryDate }, assign: func(value string) { task.Options.TestPeriodLatestExpiryDate = value }},
|
||
{name: "是否有沉默期", get: func(row *Row) string { return row.HasSilentPeriod }, assign: func(value string) { task.Options.HasSilentPeriod = value }},
|
||
{name: "沉默期天数", get: func(row *Row) string { return row.SilentPeriodDays }, assign: func(value string) { task.Options.SilentPeriodDays = value }},
|
||
{name: "沉默期最晚过期日期", get: func(row *Row) string { return row.SilentPeriodLatestExpiryDate }, assign: func(value string) { task.Options.SilentPeriodLatestExpiryDate = value }},
|
||
}
|
||
for _, check := range checks {
|
||
value, ok := sameBatchOptionValue(task.Rows, check.get)
|
||
if !ok {
|
||
markRowsWithReason(task.Rows, check.name+"必须同批一致")
|
||
if task.FailureReason == "" {
|
||
task.FailureReason = "同批入库配置必须一致"
|
||
}
|
||
continue
|
||
}
|
||
if value != "" {
|
||
check.assign(value)
|
||
}
|
||
}
|
||
fillRowOptionsFromTask(task.Rows, task.Options)
|
||
}
|
||
|
||
func sameBatchOptionValue(rows []*Row, get func(*Row) string) (string, bool) {
|
||
value := ""
|
||
for _, row := range rows {
|
||
if row == nil {
|
||
continue
|
||
}
|
||
current := strings.TrimSpace(get(row))
|
||
if current == "" {
|
||
continue
|
||
}
|
||
if value == "" {
|
||
value = current
|
||
continue
|
||
}
|
||
if value != current {
|
||
return "", false
|
||
}
|
||
}
|
||
return value, true
|
||
}
|
||
|
||
func markRowsWithReason(rows []*Row, reason string) {
|
||
for _, row := range rows {
|
||
if row != nil {
|
||
appendReason(row, reason)
|
||
}
|
||
}
|
||
}
|
||
|
||
func fillRowOptionsFromTask(rows []*Row, options Options) {
|
||
for _, row := range rows {
|
||
if row == nil {
|
||
continue
|
||
}
|
||
row.ChanKey = firstNonEmpty(row.ChanKey, options.ChanKey)
|
||
row.CardType = firstNonEmpty(row.CardType, options.CardType)
|
||
row.UpPkgCode = firstNonEmpty(row.UpPkgCode, options.UpPkgCode)
|
||
row.SuppliersCode = firstNonEmpty(row.SuppliersCode, options.SuppliersCode)
|
||
row.UpstreamActivationTime = firstNonEmpty(row.UpstreamActivationTime, options.UpstreamActivationTime)
|
||
row.InsertToPlugEnabled = firstNonEmpty(row.InsertToPlugEnabled, options.InsertToPlugEnabled)
|
||
row.CarrierTrafficCutoffEnabled = firstNonEmpty(row.CarrierTrafficCutoffEnabled, options.CarrierTrafficCutoffEnabled)
|
||
row.AgreementExpiryDate = firstNonEmpty(row.AgreementExpiryDate, options.AgreementExpiryDate)
|
||
row.HasTestPeriod = firstNonEmpty(row.HasTestPeriod, options.HasTestPeriod)
|
||
row.TestPeriodDays = firstNonEmpty(row.TestPeriodDays, options.TestPeriodDays)
|
||
row.TestPeriodLatestExpiryDate = firstNonEmpty(row.TestPeriodLatestExpiryDate, options.TestPeriodLatestExpiryDate)
|
||
row.HasSilentPeriod = firstNonEmpty(row.HasSilentPeriod, options.HasSilentPeriod)
|
||
row.SilentPeriodDays = firstNonEmpty(row.SilentPeriodDays, options.SilentPeriodDays)
|
||
row.SilentPeriodLatestExpiryDate = firstNonEmpty(row.SilentPeriodLatestExpiryDate, options.SilentPeriodLatestExpiryDate)
|
||
}
|
||
}
|
||
|
||
func (e *executor) validateUpstreamBasicInfo(ctx context.Context, task *Task) error {
|
||
started := time.Now()
|
||
groups := groupRowsForUpstream(task.Rows)
|
||
batchSize := inboundUpstreamBatchSize(ctx)
|
||
jobs := make([]upstreamBasicInfoJob, 0, len(groups))
|
||
upstreamRows := 0
|
||
for chanKey, rows := range groups {
|
||
upstreamRows += len(rows)
|
||
for _, chunk := range chunkRows(rows, batchSize) {
|
||
if len(chunk) == 0 {
|
||
continue
|
||
}
|
||
jobs = append(jobs, upstreamBasicInfoJob{
|
||
chanKey: chanKey,
|
||
rows: chunk,
|
||
})
|
||
}
|
||
}
|
||
if len(jobs) == 0 {
|
||
return nil
|
||
}
|
||
workers := inboundUpstreamConcurrency(ctx)
|
||
if workers < 1 {
|
||
workers = 1
|
||
}
|
||
if workers > len(jobs) {
|
||
workers = len(jobs)
|
||
}
|
||
g.Log().Infof(ctx, "inbound upstream basic-info started, batchNo:%s, rows:%d, groups:%d, jobs:%d, batchSize:%d, concurrency:%d",
|
||
task.Batch.BatchNo, upstreamRows, len(groups), len(jobs), batchSize, workers)
|
||
jobCh := make(chan upstreamBasicInfoJob)
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < workers; i++ {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
for job := range jobCh {
|
||
e.validateUpstreamBasicInfoChunk(ctx, task.Batch.BatchNo, job.chanKey, job.rows)
|
||
}
|
||
}()
|
||
}
|
||
for _, job := range jobs {
|
||
jobCh <- job
|
||
}
|
||
close(jobCh)
|
||
wg.Wait()
|
||
g.Log().Infof(ctx, "inbound upstream basic-info completed, batchNo:%s, rows:%d, groups:%d, jobs:%d, batchSize:%d, concurrency:%d, failed:%d, durationMs:%d",
|
||
task.Batch.BatchNo, upstreamRows, len(groups), len(jobs), batchSize, workers, countFailedRows(task.Rows), time.Since(started).Milliseconds())
|
||
return nil
|
||
}
|
||
|
||
func (e *executor) validateUpstreamBasicInfoChunk(ctx context.Context, batchNo, chanKey string, rows []*Row) {
|
||
if len(rows) == 0 {
|
||
return
|
||
}
|
||
started := time.Now()
|
||
simKeys := make([]*model.SimKey, 0, len(rows))
|
||
for _, row := range rows {
|
||
simKeys = append(simKeys, simKeyFromRow(row, chanKey))
|
||
}
|
||
if e.rateLimiter != nil {
|
||
// The limiter controls upstream HTTP requests. One chunk produces one
|
||
// request, regardless of how many SIM keys it contains.
|
||
if err := e.rateLimiter.Wait(ctx, chanKey, 1); err != nil {
|
||
for _, row := range rows {
|
||
appendReason(row, "上游三码校验限流失败: "+err.Error())
|
||
}
|
||
g.Log().Warningf(ctx, "inbound upstream basic-info rate limit failed, batchNo:%s, chanKey:%s, rows:%d, durationMs:%d, err:%+v",
|
||
batchNo, chanKey, len(rows), time.Since(started).Milliseconds(), err)
|
||
return
|
||
}
|
||
}
|
||
response, err := e.upstream.QuerySimBasicInfo(ctx, &model.SimBasicInfoInput{
|
||
SimKeys: simKeys,
|
||
ReqData: model.SimInputReqNullDate{},
|
||
})
|
||
if err != nil {
|
||
for _, row := range rows {
|
||
appendReason(row, "上游三码校验失败: "+err.Error())
|
||
}
|
||
g.Log().Warningf(ctx, "inbound upstream basic-info chunk failed, batchNo:%s, chanKey:%s, rows:%d, durationMs:%d, err:%+v",
|
||
batchNo, chanKey, len(rows), time.Since(started).Milliseconds(), err)
|
||
return
|
||
}
|
||
applyUpstreamBasicInfoResponse(rows, response)
|
||
if duration := time.Since(started); duration >= time.Second {
|
||
g.Log().Infof(ctx, "inbound upstream basic-info chunk completed, batchNo:%s, chanKey:%s, rows:%d, response:%d, failed:%d, durationMs:%d",
|
||
batchNo, chanKey, len(rows), len(response), countFailedRows(rows), duration.Milliseconds())
|
||
}
|
||
}
|
||
|
||
func simKeyFromRow(row *Row, chanKey string) *model.SimKey {
|
||
if row == nil {
|
||
return &model.SimKey{ChanKey: chanKey}
|
||
}
|
||
if row.ICCID != "" {
|
||
return &model.SimKey{
|
||
ChanKey: chanKey,
|
||
ICCID: row.ICCID,
|
||
}
|
||
}
|
||
return &model.SimKey{
|
||
ChanKey: chanKey,
|
||
MSISDN: row.MSISDN,
|
||
IMSI: row.IMSI,
|
||
}
|
||
}
|
||
|
||
func groupRowsForUpstream(rows []*Row) map[string][]*Row {
|
||
groups := make(map[string][]*Row)
|
||
for _, row := range rows {
|
||
if row == nil || row.Reason != "" || row.ChanKey == "" {
|
||
continue
|
||
}
|
||
groups[row.ChanKey] = append(groups[row.ChanKey], row)
|
||
}
|
||
return groups
|
||
}
|
||
|
||
type upstreamBasicInfoJob struct {
|
||
chanKey string
|
||
rows []*Row
|
||
}
|
||
|
||
func chunkRows(rows []*Row, size int) [][]*Row {
|
||
if size <= 0 {
|
||
size = defaultInboundUpstreamBatchSize
|
||
}
|
||
if len(rows) == 0 {
|
||
return nil
|
||
}
|
||
chunks := make([][]*Row, 0, (len(rows)+size-1)/size)
|
||
for start := 0; start < len(rows); start += size {
|
||
end := start + size
|
||
if end > len(rows) {
|
||
end = len(rows)
|
||
}
|
||
chunks = append(chunks, rows[start:end])
|
||
}
|
||
return chunks
|
||
}
|
||
|
||
func inboundUpstreamBatchSize(ctx context.Context) int {
|
||
value := g.Cfg().MustGet(ctx, inboundUpstreamBatchSizeConfigKey, defaultInboundUpstreamBatchSize).Int()
|
||
if value <= 0 {
|
||
return defaultInboundUpstreamBatchSize
|
||
}
|
||
return value
|
||
}
|
||
|
||
func inboundUpstreamConcurrency(ctx context.Context) int {
|
||
value := g.Cfg().MustGet(ctx, inboundUpstreamConcurrencyKey, defaultInboundUpstreamConcurrency).Int()
|
||
if value <= 0 {
|
||
return defaultInboundUpstreamConcurrency
|
||
}
|
||
return value
|
||
}
|
||
|
||
func applyUpstreamBasicInfoResponse(rows []*Row, response []model.SimBaseRes[model.SimBasicInfoItem]) {
|
||
if len(response) == 0 {
|
||
for _, row := range rows {
|
||
appendReason(row, "上游未返回SIM卡信息")
|
||
}
|
||
return
|
||
}
|
||
index := newBasicInfoResponseIndex(response)
|
||
for _, row := range rows {
|
||
item, ok := index.take(row)
|
||
if !ok {
|
||
appendReason(row, "上游未返回SIM卡信息")
|
||
continue
|
||
}
|
||
if item.Code != 0 {
|
||
reason := strings.TrimSpace(item.Message)
|
||
if reason == "" {
|
||
reason = "上游返回失败"
|
||
}
|
||
appendReason(row, "上游三码校验失败: "+reason)
|
||
continue
|
||
}
|
||
info := normalizeBasicInfo(item.ResData)
|
||
if reason := upstreamBasicInfoEmptyReason(item, info); reason != "" {
|
||
appendReason(row, reason)
|
||
continue
|
||
}
|
||
if reason := compareBasicInfo(row, info); reason != "" {
|
||
appendReason(row, reason)
|
||
continue
|
||
}
|
||
if reason := completeBasicInfoReason(info); reason != "" {
|
||
appendReason(row, reason)
|
||
continue
|
||
}
|
||
applyBasicInfo(row, info)
|
||
}
|
||
}
|
||
|
||
type basicInfoResponseIndex struct {
|
||
response []model.SimBaseRes[model.SimBasicInfoItem]
|
||
infos []model.SimBasicInfoItem
|
||
used []bool
|
||
byICCID map[string][]int
|
||
byMSISDN map[string][]int
|
||
byIMSI map[string][]int
|
||
}
|
||
|
||
func newBasicInfoResponseIndex(response []model.SimBaseRes[model.SimBasicInfoItem]) *basicInfoResponseIndex {
|
||
index := &basicInfoResponseIndex{
|
||
response: response,
|
||
infos: make([]model.SimBasicInfoItem, len(response)),
|
||
used: make([]bool, len(response)),
|
||
byICCID: make(map[string][]int, len(response)),
|
||
byMSISDN: make(map[string][]int, len(response)),
|
||
byIMSI: make(map[string][]int, len(response)),
|
||
}
|
||
for i := range response {
|
||
info := normalizeBasicInfo(response[i].ResData)
|
||
index.infos[i] = info
|
||
index.addInfoKeys(i, info)
|
||
if response[i].SimKey != nil {
|
||
index.addSimKeyKeys(i, response[i].SimKey)
|
||
}
|
||
}
|
||
return index
|
||
}
|
||
|
||
func (i *basicInfoResponseIndex) addInfoKeys(index int, info model.SimBasicInfoItem) {
|
||
appendResponseIndex(i.byICCID, iccidIndexKey(info.ICCID), index)
|
||
appendResponseIndex(i.byMSISDN, identifierIndexKey(info.MSISDN), index)
|
||
appendResponseIndex(i.byIMSI, identifierIndexKey(info.IMSI), index)
|
||
}
|
||
|
||
func (i *basicInfoResponseIndex) addSimKeyKeys(index int, simKey *model.SimKey) {
|
||
if simKey == nil {
|
||
return
|
||
}
|
||
appendResponseIndex(i.byICCID, iccidIndexKey(simKey.ICCID), index)
|
||
appendResponseIndex(i.byMSISDN, identifierIndexKey(simKey.MSISDN), index)
|
||
appendResponseIndex(i.byIMSI, identifierIndexKey(simKey.IMSI), index)
|
||
}
|
||
|
||
func appendResponseIndex(values map[string][]int, key string, index int) {
|
||
if key == "" {
|
||
return
|
||
}
|
||
values[key] = append(values[key], index)
|
||
}
|
||
|
||
func (i *basicInfoResponseIndex) take(row *Row) (model.SimBaseRes[model.SimBasicInfoItem], bool) {
|
||
if i == nil || row == nil {
|
||
return model.SimBaseRes[model.SimBasicInfoItem]{}, false
|
||
}
|
||
for _, candidates := range [][]int{
|
||
i.byICCID[iccidIndexKey(row.ICCID)],
|
||
i.byMSISDN[identifierIndexKey(row.MSISDN)],
|
||
i.byIMSI[identifierIndexKey(row.IMSI)],
|
||
} {
|
||
for _, candidate := range candidates {
|
||
if candidate < 0 || candidate >= len(i.response) || i.used[candidate] {
|
||
continue
|
||
}
|
||
if rowMatchesBasicInfo(row, i.infos[candidate]) || rowMatchesSimKey(row, i.response[candidate].SimKey) {
|
||
i.used[candidate] = true
|
||
return i.response[candidate], true
|
||
}
|
||
}
|
||
}
|
||
return model.SimBaseRes[model.SimBasicInfoItem]{}, false
|
||
}
|
||
|
||
func iccidIndexKey(value string) string {
|
||
return strings.ToUpper(strings.TrimSpace(value))
|
||
}
|
||
|
||
func identifierIndexKey(value string) string {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
|
||
func compareBasicInfo(row *Row, info model.SimBasicInfoItem) string {
|
||
missing := make([]string, 0, 3)
|
||
mismatches := make([]string, 0, 3)
|
||
if row.ICCID != "" {
|
||
actual := strings.TrimSpace(info.ICCID)
|
||
if actual == "" {
|
||
missing = append(missing, "ICCID")
|
||
} else if !sameICCID(actual, row.ICCID) {
|
||
mismatches = append(mismatches, "ICCID")
|
||
}
|
||
}
|
||
if row.MSISDN != "" {
|
||
actual := strings.TrimSpace(info.MSISDN)
|
||
if actual == "" {
|
||
missing = append(missing, "MSISDN")
|
||
} else if actual != row.MSISDN {
|
||
mismatches = append(mismatches, "MSISDN")
|
||
}
|
||
}
|
||
if row.IMSI != "" {
|
||
actual := strings.TrimSpace(info.IMSI)
|
||
if actual == "" {
|
||
missing = append(missing, "IMSI")
|
||
} else if actual != row.IMSI {
|
||
mismatches = append(mismatches, "IMSI")
|
||
}
|
||
}
|
||
if len(missing) > 0 {
|
||
return "上游未返回三码信息: " + strings.Join(missing, ",")
|
||
}
|
||
if len(mismatches) == 0 {
|
||
return ""
|
||
}
|
||
return "上游三码校验不一致: " + strings.Join(mismatches, ",")
|
||
}
|
||
|
||
func normalizeBasicInfo(info model.SimBasicInfoItem) model.SimBasicInfoItem {
|
||
info.ICCID = strings.TrimSpace(info.ICCID)
|
||
info.MSISDN = strings.TrimSpace(info.MSISDN)
|
||
info.IMSI = strings.TrimSpace(info.IMSI)
|
||
info.ActiveDate = strings.TrimSpace(info.ActiveDate)
|
||
info.OpenDate = strings.TrimSpace(info.OpenDate)
|
||
info.Remark = strings.TrimSpace(info.Remark)
|
||
return info
|
||
}
|
||
|
||
func upstreamBasicInfoEmptyReason(item model.SimBaseRes[model.SimBasicInfoItem], info model.SimBasicInfoItem) string {
|
||
if info.ICCID != "" || info.MSISDN != "" || info.IMSI != "" {
|
||
return ""
|
||
}
|
||
message := strings.TrimSpace(item.Message)
|
||
if message == "" {
|
||
return ""
|
||
}
|
||
if strings.Contains(message, "未查询") || strings.Contains(message, "不存在") {
|
||
return "上游三码校验失败: " + message
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func completeBasicInfoReason(info model.SimBasicInfoItem) string {
|
||
missing := make([]string, 0, 3)
|
||
if strings.TrimSpace(info.ICCID) == "" {
|
||
missing = append(missing, "ICCID")
|
||
}
|
||
if strings.TrimSpace(info.MSISDN) == "" {
|
||
missing = append(missing, "MSISDN")
|
||
}
|
||
if strings.TrimSpace(info.IMSI) == "" {
|
||
missing = append(missing, "IMSI")
|
||
}
|
||
if len(missing) == 0 {
|
||
return ""
|
||
}
|
||
return "上游未返回完整三码信息: " + strings.Join(missing, ",")
|
||
}
|
||
|
||
func applyBasicInfo(row *Row, info model.SimBasicInfoItem) {
|
||
if row == nil {
|
||
return
|
||
}
|
||
row.ICCID = strings.TrimSpace(info.ICCID)
|
||
row.MSISDN = strings.TrimSpace(info.MSISDN)
|
||
row.IMSI = strings.TrimSpace(info.IMSI)
|
||
}
|
||
|
||
func rowMatchesBasicInfo(row *Row, info model.SimBasicInfoItem) bool {
|
||
if row == nil {
|
||
return false
|
||
}
|
||
if row.ICCID != "" && info.ICCID != "" && sameICCID(info.ICCID, row.ICCID) {
|
||
return true
|
||
}
|
||
if row.MSISDN != "" && info.MSISDN != "" && sameIdentifier(info.MSISDN, row.MSISDN) {
|
||
return true
|
||
}
|
||
if row.IMSI != "" && info.IMSI != "" && sameIdentifier(info.IMSI, row.IMSI) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func rowMatchesSimKey(row *Row, simKey *model.SimKey) bool {
|
||
if row == nil || simKey == nil {
|
||
return false
|
||
}
|
||
return rowMatchesBasicInfo(row, model.SimBasicInfoItem{
|
||
ICCID: simKey.ICCID,
|
||
MSISDN: simKey.MSISDN,
|
||
IMSI: simKey.IMSI,
|
||
})
|
||
}
|
||
|
||
func sameICCID(a, b string) bool {
|
||
return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b))
|
||
}
|
||
|
||
func sameIdentifier(a, b string) bool {
|
||
return strings.TrimSpace(a) == strings.TrimSpace(b)
|
||
}
|
||
|
||
func applyCascadeFailure(task *Task) {
|
||
for _, row := range task.Rows {
|
||
if row != nil && row.Reason == "" {
|
||
row.Reason = cascadeFailureReason
|
||
}
|
||
}
|
||
if task.FailureReason == "" {
|
||
task.FailureReason = "同批存在校验失败,本次整批未入库"
|
||
}
|
||
}
|
||
|
||
func hasFailedRows(rows []*Row) bool {
|
||
for _, row := range rows {
|
||
if row != nil && row.Reason != "" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func countValidRows(rows []*Row) int {
|
||
count := 0
|
||
for _, row := range rows {
|
||
if row != nil && row.Reason == "" {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func countFailedRows(rows []*Row) int {
|
||
count := 0
|
||
for _, row := range rows {
|
||
if row != nil && row.Reason != "" && row.Reason != cascadeFailureReason {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func inboundRowStatus(row *Row) string {
|
||
if IsCascadeFailure(row) {
|
||
return "未执行"
|
||
}
|
||
if row.Reason != "" {
|
||
return "失败"
|
||
}
|
||
return "成功"
|
||
}
|
||
|
||
func IsCascadeFailure(row *Row) bool {
|
||
return row != nil && row.Reason == cascadeFailureReason
|
||
}
|
||
|
||
func rowKeys(rows []*Row) ([]string, []string, []string) {
|
||
iccidSet := make(map[string]struct{}, len(rows))
|
||
msisdnSet := make(map[string]struct{}, len(rows))
|
||
imsiSet := make(map[string]struct{}, len(rows))
|
||
for _, row := range rows {
|
||
if row == nil || row.Reason != "" {
|
||
continue
|
||
}
|
||
if row.ICCID != "" {
|
||
iccidSet[row.ICCID] = struct{}{}
|
||
}
|
||
if row.MSISDN != "" {
|
||
msisdnSet[row.MSISDN] = struct{}{}
|
||
}
|
||
if row.IMSI != "" {
|
||
imsiSet[row.IMSI] = struct{}{}
|
||
}
|
||
}
|
||
return setKeys(iccidSet), setKeys(msisdnSet), setKeys(imsiSet)
|
||
}
|
||
|
||
func setKeys(values map[string]struct{}) []string {
|
||
keys := make([]string, 0, len(values))
|
||
for key := range values {
|
||
keys = append(keys, key)
|
||
}
|
||
return keys
|
||
}
|
||
|
||
func appendReason(row *Row, reason string) {
|
||
if row.Reason == "" {
|
||
row.Reason = reason
|
||
return
|
||
}
|
||
row.Reason += ";" + reason
|
||
}
|
||
|
||
func allDigits(value string) bool {
|
||
if value == "" {
|
||
return false
|
||
}
|
||
for _, r := range value {
|
||
if r < '0' || r > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func splitTrailingDigits(value string) (prefix, digits string) {
|
||
index := len(value)
|
||
for index > 0 {
|
||
ch := value[index-1]
|
||
if ch < '0' || ch > '9' {
|
||
break
|
||
}
|
||
index--
|
||
}
|
||
return value[:index], value[index:]
|
||
}
|
||
|
||
func allAlphaNumeric(value string) bool {
|
||
if value == "" {
|
||
return false
|
||
}
|
||
for _, r := range value {
|
||
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||
continue
|
||
}
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
if strings.TrimSpace(value) != "" {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func normalizeYesNo(value string) string {
|
||
value = strings.TrimSpace(strings.ToLower(value))
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
value = strings.NewReplacer(" ", "", " ", "").Replace(value)
|
||
switch value {
|
||
case "0", "是", "true", "y", "yes", "启用", "开启", "有":
|
||
return "0"
|
||
case "1", "否", "false", "n", "no", "禁用", "关闭", "无":
|
||
return "1"
|
||
default:
|
||
return value
|
||
}
|
||
}
|
||
|
||
func normalizeCardType(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
compact := strings.NewReplacer(" ", "", " ", "", "-", "", "_", "", "‑", "", "-", "", "×", "x", "X", "x").Replace(strings.ToLower(value))
|
||
switch compact {
|
||
case "1", "通用插拔sim卡", "通用插拔卡", "插拔卡":
|
||
return "1"
|
||
case "2", "贴片m2m卡5x6mm", "m2m5x6", "贴片卡5x6":
|
||
return "2"
|
||
case "3", "贴片m2m卡2x2mm", "m2m2x2", "贴片卡2x2":
|
||
return "3"
|
||
case "4", "mp1消费级插拔物联卡", "mp1":
|
||
return "4"
|
||
case "5", "mp2工业级插拔物联卡", "mp2":
|
||
return "5"
|
||
case "6", "ms0消费级贴片物联卡", "ms0":
|
||
return "6"
|
||
case "7", "ms1工业级贴片物联卡", "ms1":
|
||
return "7"
|
||
default:
|
||
return value
|
||
}
|
||
}
|
||
|
||
func valueAt(values []string, index int) string {
|
||
if index < 0 || index >= len(values) {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(values[index])
|
||
}
|
||
|
||
func valueForHeader(values []string, header map[string]int, key string) string {
|
||
index, ok := header[key]
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return valueAt(values, index)
|
||
}
|
||
|
||
func resultRequest(task *Task) batchresult.Request {
|
||
rows := make([]batchresult.Row, 0, len(task.Rows))
|
||
for _, row := range task.Rows {
|
||
if row == nil {
|
||
continue
|
||
}
|
||
rows = append(rows, batchresult.Row{
|
||
RowNo: row.RowNo,
|
||
ICCID: row.ICCID,
|
||
MSISDN: row.MSISDN,
|
||
IMSI: row.IMSI,
|
||
Success: row.Reason == "",
|
||
Status: inboundRowStatus(row),
|
||
Reason: row.Reason,
|
||
})
|
||
}
|
||
return batchresult.Request{BatchNo: task.Batch.BatchNo, BatchType: task.Batch.BatchType, TenantID: task.Batch.TenantID, Rows: rows}
|
||
}
|
||
|
||
func (r Range) String() string {
|
||
return fmt.Sprintf("%s-%s", r.StartICCID, r.EndICCID)
|
||
}
|