chore: 并入 iotlcm-worker-svr 历史到 worker-svr/(原提交 96f0f4e)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,831 @@
|
||||
package addpackage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"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/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
renewchangeservice "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/renewchange"
|
||||
)
|
||||
|
||||
const addPackagePrepareProcessingRetryAfter = 2 * time.Minute
|
||||
|
||||
type PrepareExecutor struct {
|
||||
repo *renewchangeservice.Repository
|
||||
inner *executor
|
||||
targetBatchType int
|
||||
nowFunc NowFunc
|
||||
}
|
||||
|
||||
func NewCardPrepareExecutor(repo *renewchangeservice.Repository) *PrepareExecutor {
|
||||
if repo == nil {
|
||||
repo = renewchangeservice.NewRepository()
|
||||
}
|
||||
return &PrepareExecutor{
|
||||
repo: repo,
|
||||
inner: &executor{repo: repo, batchType: consts.BatchTypePrepare, nowFunc: timeNow},
|
||||
targetBatchType: consts.BatchTypeCardAddPackage,
|
||||
nowFunc: timeNow,
|
||||
}
|
||||
}
|
||||
|
||||
func NewPoolPrepareExecutor(repo *renewchangeservice.Repository) *PrepareExecutor {
|
||||
if repo == nil {
|
||||
repo = renewchangeservice.NewRepository()
|
||||
}
|
||||
return &PrepareExecutor{
|
||||
repo: repo,
|
||||
inner: &executor{repo: repo, batchType: consts.BatchTypePrepare, poolMode: true, nowFunc: timeNow},
|
||||
targetBatchType: consts.BatchTypePoolAddPackage,
|
||||
nowFunc: timeNow,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) CanExecuteProcessing(_ context.Context, main *entity.BatchMain) (bool, error) {
|
||||
if main == nil || main.BatchType != consts.BatchTypePrepare || main.BatchAsyncStatus != consts.BatchAsyncStatusProcessing {
|
||||
return false, nil
|
||||
}
|
||||
if targetBatchTypeFromInfo(main.BatchInfo) != p.targetBatchType {
|
||||
return false, nil
|
||||
}
|
||||
if main.StartTime == nil || main.StartTime.IsZero() {
|
||||
return true, nil
|
||||
}
|
||||
return p.now().Sub(main.StartTime.Time) >= addPackagePrepareProcessingRetryAfter, nil
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) Execute(ctx context.Context, batchNo string) error {
|
||||
batchNo = strings.TrimSpace(batchNo)
|
||||
if batchNo == "" {
|
||||
return gerror.New("add package prepare batch number is empty")
|
||||
}
|
||||
main, err := p.repo.LoadProcessingBatch(ctx, batchNo, consts.BatchTypePrepare)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if targetBatchTypeFromInfo(main.BatchInfo) != p.targetBatchType {
|
||||
return p.finalizePrepareFailure(ctx, main, "加油包预处理批次类型不匹配", nil)
|
||||
}
|
||||
details, err := p.repo.LoadRunningDetails(ctx, batchNo, consts.BatchTypePrepare)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load add package prepare details failed")
|
||||
}
|
||||
task := &batchTask{
|
||||
main: main,
|
||||
total: len(details),
|
||||
plans: make([]*detailPlan, 0, len(details)),
|
||||
paymentIntent: readPaymentIntent(main.BatchInfo),
|
||||
paymentSnapshot: make(map[int64]*PaymentSnapshot),
|
||||
prepareOnly: true,
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return p.finalizePrepareFailure(ctx, main, "加油包预处理批次没有可处理明细", task)
|
||||
}
|
||||
p.inner.nowFunc = p.now
|
||||
if err := p.inner.buildTask(ctx, task, details); err != nil {
|
||||
return p.finalizePrepareFailure(ctx, main, err.Error(), task)
|
||||
}
|
||||
if task.fail > 0 || task.valid != len(details) {
|
||||
reason := strings.TrimSpace(task.reason)
|
||||
if reason == "" {
|
||||
reason = "加油包预处理存在不满足条件的明细"
|
||||
}
|
||||
return p.finalizePrepareFailure(ctx, main, reason, task)
|
||||
}
|
||||
return p.finalizePrepareSuccess(ctx, task)
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) finalizePrepareFailure(ctx context.Context, main *rc.BatchMainRow, reason string, task *batchTask) error {
|
||||
if main == nil {
|
||||
return gerror.New("add package prepare batch is empty")
|
||||
}
|
||||
if isAutoConfirmPrepare(main) {
|
||||
return p.finalizeAutoPrepareFailure(ctx, main, reason, task)
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
reason = "加油包预处理失败"
|
||||
}
|
||||
now := p.now()
|
||||
failCount := main.TotalCount
|
||||
err := p.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if task != nil {
|
||||
for _, plan := range task.plans {
|
||||
if plan == nil || plan.detail == nil {
|
||||
continue
|
||||
}
|
||||
rowReason := strings.TrimSpace(plan.reason)
|
||||
if rowReason == "" {
|
||||
rowReason = reason
|
||||
}
|
||||
if err := p.repo.Tables().UpdateBatchDetailSnapshot(ctx, tx, main.BatchNo, main.BatchType, plan.detail.RowNo, consts.BatchDetailStatusFailed, buildDetailSnapshot(plan, ""), rowReason, main.CreatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := p.repo.MarkAllDetailsFailed(ctx, tx, main.BatchNo, main.BatchType, reason, main.CreatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.repo.FinalizeBatchMain(ctx, tx, main.BatchNo, main.BatchType, &rc.BatchMainRow{
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
|
||||
RunResult: consts.BatchRunResultFailed,
|
||||
FailReason: reason,
|
||||
TotalCount: main.TotalCount,
|
||||
ValidCount: 0,
|
||||
SuccessCount: 0,
|
||||
FailCount: failCount,
|
||||
EndTime: gtime.NewFromTime(now),
|
||||
UpdatedBy: main.CreatedBy,
|
||||
})
|
||||
})
|
||||
return gerror.Wrap(err, "finalize add package prepare failure failed")
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) finalizePrepareSuccess(ctx context.Context, task *batchTask) error {
|
||||
if task == nil || task.main == nil {
|
||||
return gerror.New("add package prepare task is empty")
|
||||
}
|
||||
now := p.now()
|
||||
info, err := p.buildPrepareInfo(ctx, task)
|
||||
if err != nil {
|
||||
if isAutoConfirmPrepare(task.main) {
|
||||
return p.finalizeAutoPrepareFailure(ctx, task.main, err.Error(), task)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if isAutoConfirmPrepare(task.main) {
|
||||
return p.finalizeAutoPrepareSuccess(ctx, task, info, now)
|
||||
}
|
||||
err = p.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
for _, plan := range task.plans {
|
||||
if plan == nil || plan.detail == nil {
|
||||
continue
|
||||
}
|
||||
if err := p.repo.Tables().UpdateBatchDetailSnapshot(ctx, tx, task.main.BatchNo, task.main.BatchType, plan.detail.RowNo, consts.BatchDetailStatusSuccess, buildDetailSnapshot(plan, ""), "", task.main.CreatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := p.repo.MarkAllDetailsSuccess(ctx, tx, task.main.BatchNo, task.main.BatchType, task.main.CreatedBy, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.repo.UpdateBatchMainInfo(ctx, tx, task.main.BatchNo, task.main.BatchType, &rc.BatchMainRow{
|
||||
BatchType: task.main.BatchType,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
|
||||
RunResult: consts.BatchRunResultSuccess,
|
||||
TotalCount: task.total,
|
||||
ValidCount: task.valid,
|
||||
SuccessCount: task.valid,
|
||||
FailCount: 0,
|
||||
FailReason: "",
|
||||
BatchInfo: gjson.New(info),
|
||||
EndTime: gtime.NewFromTime(now),
|
||||
UpdatedBy: task.main.CreatedBy,
|
||||
})
|
||||
})
|
||||
return gerror.Wrap(err, "finalize add package prepare success failed")
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) finalizeAutoPrepareSuccess(ctx context.Context, task *batchTask, info map[string]any, now time.Time) error {
|
||||
if task == nil || task.main == nil {
|
||||
return gerror.New("add package auto prepare task is empty")
|
||||
}
|
||||
targetBatchType := p.targetBatchType
|
||||
info["autoConfirmed"] = true
|
||||
info["prepareBatchType"] = consts.BatchTypePrepare
|
||||
info["status"] = "pending_execute"
|
||||
err := p.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
for _, plan := range task.plans {
|
||||
if plan == nil || plan.detail == nil {
|
||||
continue
|
||||
}
|
||||
if err := p.repo.Tables().UpdateBatchDetailSnapshot(ctx, tx, task.main.BatchNo, task.main.BatchType, plan.detail.RowNo, consts.BatchDetailStatusSuccess, buildDetailSnapshot(plan, ""), "", task.main.CreatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := convertAddPackagePrepareDetails(ctx, tx, task.main.BatchNo, targetBatchType, consts.BatchDetailStatusPending, "", task.main.CreatedBy, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return convertAddPackagePrepareMain(ctx, tx, task.main.BatchNo, targetBatchType, &rc.BatchMainRow{
|
||||
BatchAsyncStatus: batch.AsyncStatusPending,
|
||||
RunResult: 0,
|
||||
TotalCount: task.total,
|
||||
ValidCount: 0,
|
||||
SuccessCount: 0,
|
||||
FailCount: 0,
|
||||
FailReason: "",
|
||||
BatchInfo: gjson.New(info),
|
||||
UpdatedBy: task.main.CreatedBy,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "auto confirm add package prepare failed")
|
||||
}
|
||||
p.publishExecution(ctx, task.main.BatchNo, targetBatchType)
|
||||
g.Log().Infof(ctx, "add package prepare auto confirmed, batchNo:%s, targetType:%d", task.main.BatchNo, targetBatchType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) finalizeAutoPrepareFailure(ctx context.Context, main *rc.BatchMainRow, reason string, task *batchTask) error {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
reason = "加油包预处理失败"
|
||||
}
|
||||
now := p.now()
|
||||
targetBatchType := p.targetBatchType
|
||||
info := mergeBatchInfo(main.BatchInfo, map[string]any{
|
||||
"targetBatchType": targetBatchType,
|
||||
"prepareBatchType": consts.BatchTypePrepare,
|
||||
"autoConfirm": true,
|
||||
"autoConfirmed": false,
|
||||
"status": "failed",
|
||||
})
|
||||
err := p.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if task != nil {
|
||||
for _, plan := range task.plans {
|
||||
if plan == nil || plan.detail == nil {
|
||||
continue
|
||||
}
|
||||
rowReason := strings.TrimSpace(plan.reason)
|
||||
if rowReason == "" {
|
||||
rowReason = reason
|
||||
}
|
||||
if err := p.repo.Tables().UpdateBatchDetailSnapshot(ctx, tx, main.BatchNo, main.BatchType, plan.detail.RowNo, consts.BatchDetailStatusFailed, buildDetailSnapshot(plan, ""), rowReason, main.CreatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := p.repo.MarkAllDetailsFailed(ctx, tx, main.BatchNo, main.BatchType, reason, main.CreatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
detailReason := reason
|
||||
if task != nil && len(task.plans) > 0 {
|
||||
detailReason = ""
|
||||
}
|
||||
if err := convertAddPackagePrepareDetails(ctx, tx, main.BatchNo, targetBatchType, consts.BatchDetailStatusFailed, detailReason, main.CreatedBy, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return convertAddPackagePrepareMain(ctx, tx, main.BatchNo, targetBatchType, &rc.BatchMainRow{
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
|
||||
RunResult: consts.BatchRunResultFailed,
|
||||
FailReason: reason,
|
||||
TotalCount: main.TotalCount,
|
||||
ValidCount: 0,
|
||||
SuccessCount: 0,
|
||||
FailCount: main.TotalCount,
|
||||
BatchInfo: gjson.New(info),
|
||||
EndTime: gtime.NewFromTime(now),
|
||||
UpdatedBy: main.CreatedBy,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "finalize auto add package prepare failure failed")
|
||||
}
|
||||
rows, _ := p.repo.ResolveResultSnapshot(ctx, main.BatchNo, targetBatchType)
|
||||
_ = p.repo.WriteResultFileSafe(ctx, main.BatchNo, targetBatchType, main.TenantId, main.CreatedDept, main.CreatedBy, rows)
|
||||
g.Log().Warningf(ctx, "add package prepare auto failed, batchNo:%s, targetType:%d, reason:%s", main.BatchNo, targetBatchType, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) publishExecution(ctx context.Context, batchNo string, batchType int) {
|
||||
if err := rabbitmq.Publish(ctx, consts.QueueBatchExecute, batch.Message{BatchNo: batchNo, BatchType: batchType}); err != nil {
|
||||
g.Log().Errorf(ctx, "publish add package execution after prepare failed, batchNo:%s, batchType:%d, err:%+v", batchNo, batchType, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) buildPrepareInfo(ctx context.Context, task *batchTask) (map[string]any, error) {
|
||||
info := mergeBatchInfo(task.main.BatchInfo, map[string]any{
|
||||
"targetBatchType": p.targetBatchType,
|
||||
"batchMode": map[bool]string{true: "pool", false: "card"}[p.inner.poolMode],
|
||||
})
|
||||
quantity := 0
|
||||
totalAmount := 0.0
|
||||
var sample *detailPlan
|
||||
for _, plan := range task.plans {
|
||||
if plan == nil || plan.reason != "" {
|
||||
continue
|
||||
}
|
||||
if sample == nil {
|
||||
sample = plan
|
||||
}
|
||||
quantity++
|
||||
if plan.price == nil {
|
||||
continue
|
||||
}
|
||||
totalAmount = roundAmount(totalAmount + plan.amount)
|
||||
}
|
||||
if task.prepareOnly && sample != nil && strings.TrimSpace(sample.packageCode) == "" {
|
||||
options, err := p.prepareAvailablePackages(ctx, task)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(options) == 0 {
|
||||
return nil, gerror.New("当前没有共同可购的加油包套餐")
|
||||
}
|
||||
info["quantity"] = quantity
|
||||
info["basePackageKey"] = sample.basePackageKey
|
||||
info["availablePackages"] = options
|
||||
info["packageOptions"] = options
|
||||
if _, err := p.fillPreparePaymentView(ctx, task, info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info["prepareTime"] = p.now().Format("2006-01-02 15:04:05")
|
||||
info["status"] = "success"
|
||||
return info, nil
|
||||
}
|
||||
if sample == nil || sample.price == nil {
|
||||
return nil, gerror.New("加油包预处理没有可确认的明细")
|
||||
}
|
||||
info["quantity"] = quantity
|
||||
info["basePackageKey"] = sample.basePackageKey
|
||||
info["packageCode"] = sample.packageCode
|
||||
info["packageName"] = sample.price.PackageName
|
||||
info["flowMb"] = sample.price.FlowMb
|
||||
info["unitPrice"] = sample.price.UnitPrice
|
||||
info["totalAmount"] = totalAmount
|
||||
info["effectiveMode"] = sample.effectiveMode
|
||||
info["effectiveModeText"] = effectiveModeText(sample.effectiveMode)
|
||||
info["effectiveTime"] = gtime.NewFromTime(sample.effectiveTime).Format("Y-m-d H:i:s")
|
||||
info["expectedExpireTime"] = gtime.NewFromTime(sample.expireTime).Format("Y-m-d H:i:s")
|
||||
info["priceSnapshot"] = sample.price
|
||||
accounts, err := p.preparePaymentAccounts(ctx, task)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info["paymentAccounts"] = accounts
|
||||
isTop, err := p.fillPreparePaymentView(ctx, task, info)
|
||||
if isAutoConfirmPrepare(task.main) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
intent, err := normalizePaymentIntent(task.paymentIntent, isTop)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task.paymentIntent = intent
|
||||
info["paymentMode"] = intent.PaymentMode
|
||||
info["offlineContractCompleted"] = intent.OfflineContractCompleted
|
||||
if intent.PaymentMode == "balance" {
|
||||
if err := validatePrepareBalanceEnough(accounts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
info["prepareTime"] = p.now().Format("2006-01-02 15:04:05")
|
||||
info["status"] = "success"
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) fillPreparePaymentView(ctx context.Context, task *batchTask, info map[string]any) (bool, error) {
|
||||
isTop, err := p.inner.isTopDept(ctx, task.main.TenantId, task.main.CreatedDept)
|
||||
if err == nil {
|
||||
info["isTopDept"] = isTop
|
||||
info["defaultPaymentMode"] = "balance"
|
||||
}
|
||||
balance, balanceErr := p.availableBalance(ctx, task.main.TenantId, task.main.CreatedDept)
|
||||
if balanceErr != nil {
|
||||
return isTop, balanceErr
|
||||
}
|
||||
info["availableBalance"] = balance
|
||||
return isTop, err
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) prepareAvailablePackages(ctx context.Context, task *batchTask) ([]map[string]any, error) {
|
||||
sample := firstValidAddPackagePlan(task)
|
||||
if sample == nil {
|
||||
return nil, gerror.New("加油包预处理没有可确认的明细")
|
||||
}
|
||||
codes, err := p.availablePackageCodes(ctx, task.main.TenantId, sample)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := make([]map[string]any, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
option, err := p.buildAvailablePackageOption(ctx, task, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if option != nil {
|
||||
options = append(options, option)
|
||||
}
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func firstValidAddPackagePlan(task *batchTask) *detailPlan {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
for _, plan := range task.plans {
|
||||
if plan != nil && plan.reason == "" {
|
||||
return plan
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) availablePackageCodes(ctx context.Context, tenantID string, sample *detailPlan) ([]string, error) {
|
||||
if p.inner.poolMode {
|
||||
return p.availablePoolPackageCodes(ctx, tenantID, sample)
|
||||
}
|
||||
basePkg, err := p.repo.Tables().LoadPackageByCode(ctx, tenantID, sample.basePackageKey)
|
||||
if err != nil || basePkg == nil {
|
||||
return nil, gerror.New("当前卡基础套餐不存在")
|
||||
}
|
||||
codes := packageCodesFromJSON(basePkg.TopupPackageCodes)
|
||||
if len(codes) == 0 {
|
||||
return nil, gerror.New("当前卡基础套餐未配置可购加油包")
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) availablePoolPackageCodes(ctx context.Context, tenantID string, sample *detailPlan) ([]string, error) {
|
||||
if sample.pool == nil || sample.pool.PoolPackageCode <= 0 {
|
||||
return nil, gerror.New("流量池未配置基础套餐")
|
||||
}
|
||||
var packages []*rc.FlowPackageInfo
|
||||
if err := p.repo.DB().Model(rc.TableSimFlowPackageInfo).Safe().Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("pool_package_code", sample.pool.PoolPackageCode).
|
||||
Where("package_type", consts.PackageTypePoolAddPackage).
|
||||
Where("enabled", 1).
|
||||
OrderAsc("sort_no").
|
||||
Scan(&packages); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询流量池加油包套餐失败")
|
||||
}
|
||||
codes := make([]string, 0, len(packages))
|
||||
for _, pkg := range packages {
|
||||
if pkg == nil {
|
||||
continue
|
||||
}
|
||||
if code := strings.TrimSpace(pkg.PackageCode); code != "" {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
return nil, gerror.New("所选流量池没有共同可购的加油包套餐")
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) buildAvailablePackageOption(ctx context.Context, task *batchTask, packageCode string) (map[string]any, error) {
|
||||
packageCode = strings.TrimSpace(packageCode)
|
||||
if packageCode == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var (
|
||||
samplePrice *PriceSnapshot
|
||||
totalAmount float64
|
||||
unitPrice float64
|
||||
priceAvailable = true
|
||||
priceReason string
|
||||
accounts = make(map[int64]*prepareAccount)
|
||||
deptIDs = make([]int64, 0)
|
||||
)
|
||||
for _, plan := range task.plans {
|
||||
if plan == nil || plan.reason != "" {
|
||||
continue
|
||||
}
|
||||
pkg, price, err := p.inner.resolvePrice(ctx, task.main.TenantId, plan.customerDeptID, packageCode)
|
||||
if err != nil {
|
||||
priceAvailable = false
|
||||
if priceReason == "" {
|
||||
priceReason = err.Error()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if p.inner.poolMode {
|
||||
if pkg.PackageType != consts.PackageTypePoolAddPackage || plan.pool == nil || pkg.PoolPackageCode != plan.pool.PoolPackageCode {
|
||||
priceAvailable = false
|
||||
priceReason = "加油包不属于当前流量池套餐"
|
||||
continue
|
||||
}
|
||||
} else if pkg.PackageType != consts.PackageTypeCardAddPackage {
|
||||
priceAvailable = false
|
||||
priceReason = "目标套餐类型不是单卡加油包"
|
||||
continue
|
||||
}
|
||||
if samplePrice == nil {
|
||||
samplePrice = price
|
||||
unitPrice = price.UnitPrice
|
||||
} else if !sameAddPackageAmount(unitPrice, price.UnitPrice) {
|
||||
priceAvailable = false
|
||||
priceReason = addPackageSamePriceFailureReason
|
||||
}
|
||||
totalAmount = roundAmount(totalAmount + price.UnitPrice)
|
||||
account := accounts[plan.customerDeptID]
|
||||
if account == nil {
|
||||
account = &prepareAccount{payerDeptID: plan.customerDeptID}
|
||||
accounts[plan.customerDeptID] = account
|
||||
deptIDs = append(deptIDs, plan.customerDeptID)
|
||||
}
|
||||
account.count++
|
||||
account.required = roundAmount(account.required + price.UnitPrice)
|
||||
}
|
||||
if samplePrice == nil {
|
||||
return nil, nil
|
||||
}
|
||||
paymentAccounts, err := p.prepareAccountItems(ctx, task.main.TenantId, accounts, deptIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
option := map[string]any{
|
||||
"packageCode": samplePrice.PackageCode,
|
||||
"packageName": samplePrice.PackageName,
|
||||
"flowMb": samplePrice.FlowMb,
|
||||
"unitPrice": unitPrice,
|
||||
"totalAmount": totalAmount,
|
||||
"priceAvailable": priceAvailable,
|
||||
"paymentAccounts": paymentAccounts,
|
||||
}
|
||||
if priceReason != "" {
|
||||
option["priceReason"] = priceReason
|
||||
}
|
||||
return option, nil
|
||||
}
|
||||
|
||||
type prepareAccount struct {
|
||||
payerDeptID int64
|
||||
count int
|
||||
required float64
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) prepareAccountItems(ctx context.Context, tenantID string, accounts map[int64]*prepareAccount, deptIDs []int64) ([]map[string]any, error) {
|
||||
sort.Slice(deptIDs, func(i, j int) bool { return deptIDs[i] < deptIDs[j] })
|
||||
out := make([]map[string]any, 0, len(deptIDs))
|
||||
for _, deptID := range deptIDs {
|
||||
item := accounts[deptID]
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
balance, err := p.availableBalance(ctx, tenantID, deptID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"payerDeptId": item.payerDeptID,
|
||||
"targetCount": item.count,
|
||||
"requiredAmount": item.required,
|
||||
"availableBalance": balance,
|
||||
"balanceEnough": balance+0.0001 >= item.required,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validatePrepareBalanceEnough(accounts []map[string]any) error {
|
||||
for _, account := range accounts {
|
||||
if account == nil {
|
||||
continue
|
||||
}
|
||||
if enough, _ := account["balanceEnough"].(bool); enough {
|
||||
continue
|
||||
}
|
||||
return gerror.Newf(
|
||||
"账户余额不足,付款部门%v需支付%.2f元,当前余额%.2f元",
|
||||
account["payerDeptId"],
|
||||
floatFromAny(account["requiredAmount"]),
|
||||
floatFromAny(account["availableBalance"]),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func floatFromAny(value any) float64 {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return v
|
||||
case float32:
|
||||
return float64(v)
|
||||
case int:
|
||||
return float64(v)
|
||||
case int64:
|
||||
return float64(v)
|
||||
case int32:
|
||||
return float64(v)
|
||||
case uint:
|
||||
return float64(v)
|
||||
case uint64:
|
||||
return float64(v)
|
||||
case uint32:
|
||||
return float64(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) preparePaymentAccounts(ctx context.Context, task *batchTask) ([]map[string]any, error) {
|
||||
type account struct {
|
||||
payerDeptID int64
|
||||
count int
|
||||
required float64
|
||||
}
|
||||
byDept := make(map[int64]*account)
|
||||
deptIDs := make([]int64, 0)
|
||||
for _, plan := range task.plans {
|
||||
if plan == nil || plan.reason != "" {
|
||||
continue
|
||||
}
|
||||
payerDeptID := plan.customerDeptID
|
||||
if payerDeptID <= 0 {
|
||||
return nil, gerror.New("加油包付款部门不能为空")
|
||||
}
|
||||
item := byDept[payerDeptID]
|
||||
if item == nil {
|
||||
item = &account{payerDeptID: payerDeptID}
|
||||
byDept[payerDeptID] = item
|
||||
deptIDs = append(deptIDs, payerDeptID)
|
||||
}
|
||||
item.count++
|
||||
item.required = roundAmount(item.required + plan.amount)
|
||||
}
|
||||
sort.Slice(deptIDs, func(i, j int) bool { return deptIDs[i] < deptIDs[j] })
|
||||
out := make([]map[string]any, 0, len(deptIDs))
|
||||
for _, deptID := range deptIDs {
|
||||
item := byDept[deptID]
|
||||
balance, err := p.availableBalance(ctx, task.main.TenantId, deptID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"payerDeptId": item.payerDeptID,
|
||||
"targetCount": item.count,
|
||||
"requiredAmount": item.required,
|
||||
"availableBalance": balance,
|
||||
"balanceEnough": balance+0.0001 >= item.required,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) availableBalance(ctx context.Context, tenantID string, deptID int64) (float64, error) {
|
||||
if deptID <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var row agentBalanceRow
|
||||
if err := p.repo.DB().Model("t_agent_balance").Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("dept_id", deptID).
|
||||
Scan(&row); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, gerror.Wrap(err, "query add package balance failed")
|
||||
}
|
||||
if row.Id == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return roundAmount(row.Balance), nil
|
||||
}
|
||||
|
||||
func (p *PrepareExecutor) now() time.Time {
|
||||
if p == nil || p.nowFunc == nil {
|
||||
return time.Now()
|
||||
}
|
||||
return p.nowFunc()
|
||||
}
|
||||
|
||||
type PrepareMux struct {
|
||||
repo *renewchangeservice.Repository
|
||||
renew batch.Executor
|
||||
card *PrepareExecutor
|
||||
pool *PrepareExecutor
|
||||
}
|
||||
|
||||
func NewPrepareMux(repo *renewchangeservice.Repository, renew batch.Executor) *PrepareMux {
|
||||
if repo == nil {
|
||||
repo = renewchangeservice.NewRepository()
|
||||
}
|
||||
return &PrepareMux{
|
||||
repo: repo,
|
||||
renew: renew,
|
||||
card: NewCardPrepareExecutor(repo),
|
||||
pool: NewPoolPrepareExecutor(repo),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *PrepareMux) Execute(ctx context.Context, batchNo string) error {
|
||||
main, err := m.repo.LoadProcessingBatch(ctx, strings.TrimSpace(batchNo), consts.BatchTypePrepare)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch targetBatchTypeFromInfo(main.BatchInfo) {
|
||||
case consts.BatchTypeCardAddPackage:
|
||||
return m.card.Execute(ctx, batchNo)
|
||||
case consts.BatchTypePoolAddPackage:
|
||||
return m.pool.Execute(ctx, batchNo)
|
||||
default:
|
||||
if m.renew != nil {
|
||||
return m.renew.Execute(ctx, batchNo)
|
||||
}
|
||||
return gerror.Newf("unsupported prepare target batch type: %d", targetBatchTypeFromInfo(main.BatchInfo))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *PrepareMux) CanExecuteProcessing(ctx context.Context, main *entity.BatchMain) (bool, error) {
|
||||
if main == nil || main.BatchType != consts.BatchTypePrepare || main.BatchAsyncStatus != consts.BatchAsyncStatusProcessing {
|
||||
return false, nil
|
||||
}
|
||||
switch targetBatchTypeFromInfo(main.BatchInfo) {
|
||||
case consts.BatchTypeCardAddPackage:
|
||||
return m.card.CanExecuteProcessing(ctx, main)
|
||||
case consts.BatchTypePoolAddPackage:
|
||||
return m.pool.CanExecuteProcessing(ctx, main)
|
||||
default:
|
||||
if processing, ok := m.renew.(batch.ProcessingExecutor); ok {
|
||||
return processing.CanExecuteProcessing(ctx, main)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func targetBatchTypeFromInfo(info *gjson.Json) int {
|
||||
if info == nil || info.IsNil() {
|
||||
return 0
|
||||
}
|
||||
if v := info.Get("targetBatchType"); v != nil && !v.IsNil() {
|
||||
return v.Int()
|
||||
}
|
||||
if v := info.Get("target_batch_type"); v != nil && !v.IsNil() {
|
||||
return v.Int()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isAutoConfirmPrepare(main *rc.BatchMainRow) bool {
|
||||
if main == nil || main.SourceType != consts.BatchSourceTypeFile || main.BatchInfo == nil || main.BatchInfo.IsNil() {
|
||||
return false
|
||||
}
|
||||
return main.BatchInfo.Get("autoConfirm").Bool()
|
||||
}
|
||||
|
||||
func convertAddPackagePrepareDetails(ctx context.Context, tx gdb.TX, batchNo string, targetBatchType int, detailStatus int, reason string, userID int64, now time.Time) error {
|
||||
data := map[string]any{
|
||||
"batch_type": targetBatchType,
|
||||
"detail_status": detailStatus,
|
||||
"updated_by": userID,
|
||||
"updated_at": gtime.NewFromTime(now),
|
||||
}
|
||||
if strings.TrimSpace(reason) != "" {
|
||||
data["error_reason"] = reason
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(rc.TableBatchDetail).
|
||||
Where("batch_no", batchNo).
|
||||
Where("batch_type", consts.BatchTypePrepare).
|
||||
Data(data).
|
||||
Update()
|
||||
return gerror.Wrap(err, "convert add package prepare details failed")
|
||||
}
|
||||
|
||||
func convertAddPackagePrepareMain(ctx context.Context, tx gdb.TX, batchNo string, targetBatchType int, data *rc.BatchMainRow) error {
|
||||
if data == nil {
|
||||
return gerror.New("add package batch conversion data is empty")
|
||||
}
|
||||
update := map[string]any{
|
||||
"batch_type": targetBatchType,
|
||||
"batch_async_status": data.BatchAsyncStatus,
|
||||
"run_result": data.RunResult,
|
||||
"total_count": data.TotalCount,
|
||||
"valid_count": data.ValidCount,
|
||||
"success_count": data.SuccessCount,
|
||||
"fail_count": data.FailCount,
|
||||
"fail_reason": data.FailReason,
|
||||
"batch_info": data.BatchInfo,
|
||||
"start_time": nil,
|
||||
"end_time": data.EndTime,
|
||||
"updated_by": data.UpdatedBy,
|
||||
"updated_at": gtime.Now(),
|
||||
}
|
||||
if data.EndTime == nil {
|
||||
update["end_time"] = nil
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(rc.TableBatchMain).
|
||||
Where("batch_no", batchNo).
|
||||
Where("batch_type", consts.BatchTypePrepare).
|
||||
Data(update).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "convert add package prepare main failed")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "read add package prepare main conversion result failed")
|
||||
}
|
||||
if affected == 0 {
|
||||
return gerror.New("加油包预处理批次已变化,请重试")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package addpackage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
const (
|
||||
effectiveModeCurrentMonth = "current_month"
|
||||
effectiveModeCurrentText = "本月生效"
|
||||
)
|
||||
|
||||
type NowFunc func() time.Time
|
||||
|
||||
type PriceSnapshot struct {
|
||||
PackageID int64 `json:"packageId"`
|
||||
PackageCode string `json:"packageCode"`
|
||||
PackageName string `json:"packageName"`
|
||||
PackageType int `json:"packageType"`
|
||||
PriceSource string `json:"priceSource"`
|
||||
AgentDefaultPriceID int64 `json:"agentDefaultPriceId"`
|
||||
TargetPriceID int64 `json:"targetPriceId"`
|
||||
UnitPrice float64 `json:"unitPrice"`
|
||||
FlowMb float64 `json:"flowMb"`
|
||||
}
|
||||
|
||||
type DetailSnapshot struct {
|
||||
TargetCode string `json:"targetCode"`
|
||||
PackageCode string `json:"packageCode"`
|
||||
Remark string `json:"remark"`
|
||||
EffectiveMode string `json:"effectiveMode"`
|
||||
}
|
||||
|
||||
type PaymentIntent struct {
|
||||
PaymentMode string `json:"paymentMode"`
|
||||
OfflineContractCompleted bool `json:"offlineContractCompleted"`
|
||||
}
|
||||
|
||||
type PaymentSnapshot struct {
|
||||
PaymentMode string `json:"paymentMode"`
|
||||
PayType int `json:"payType"`
|
||||
PayStatus int `json:"payStatus"`
|
||||
PayAmount float64 `json:"payAmount"`
|
||||
BalanceAmount float64 `json:"balanceAmount"`
|
||||
OfflinePaidAmount float64 `json:"offlinePaidAmount"`
|
||||
BalanceBefore float64 `json:"balanceBefore"`
|
||||
BalanceAfter float64 `json:"balanceAfter"`
|
||||
BalanceDetailSerialNo string `json:"balanceDetailSerialNo"`
|
||||
PaymentTime time.Time `json:"paymentTime"`
|
||||
PayerDeptID int64 `json:"payerDeptId"`
|
||||
PayerUserID int64 `json:"payerUserId"`
|
||||
IsTopDept bool `json:"isTopDept"`
|
||||
}
|
||||
|
||||
type detailPlan struct {
|
||||
detail *renewchange.BatchDetailRow
|
||||
targetCode string
|
||||
packageCode string
|
||||
basePackageKey string
|
||||
effectiveMode string
|
||||
remark string
|
||||
customerDeptID int64
|
||||
orderSN string
|
||||
price *PriceSnapshot
|
||||
unitAmount float64
|
||||
amount float64
|
||||
effectiveTime time.Time
|
||||
expireTime time.Time
|
||||
effectiveNow bool
|
||||
reason string
|
||||
card *renewchange.SimCardInfo
|
||||
pool *flowPoolRow
|
||||
}
|
||||
|
||||
type batchTask struct {
|
||||
main *renewchange.BatchMainRow
|
||||
plans []*detailPlan
|
||||
paymentIntent PaymentIntent
|
||||
paymentSnapshot map[int64]*PaymentSnapshot
|
||||
prepareOnly bool
|
||||
reason string
|
||||
total int
|
||||
valid int
|
||||
success int
|
||||
fail int
|
||||
}
|
||||
|
||||
func timeNow() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func readPaymentIntent(info *gjson.Json) PaymentIntent {
|
||||
intent := PaymentIntent{PaymentMode: "balance"}
|
||||
if info == nil || info.IsNil() {
|
||||
return intent
|
||||
}
|
||||
if mode := strings.TrimSpace(info.Get("paymentMode").String()); mode != "" {
|
||||
intent.PaymentMode = mode
|
||||
}
|
||||
if intent.PaymentMode == "" {
|
||||
intent.PaymentMode = "balance"
|
||||
}
|
||||
intent.OfflineContractCompleted = info.Get("offlineContractCompleted").Bool()
|
||||
return intent
|
||||
}
|
||||
|
||||
func readDetailSnapshot(info *gjson.Json) *DetailSnapshot {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
raw := info.Map()
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
snapshot := &DetailSnapshot{
|
||||
TargetCode: strings.TrimSpace(info.Get("targetCode").String()),
|
||||
PackageCode: strings.TrimSpace(info.Get("packageCode").String()),
|
||||
Remark: strings.TrimSpace(info.Get("remark").String()),
|
||||
EffectiveMode: strings.TrimSpace(info.Get("effectiveMode").String()),
|
||||
}
|
||||
if snapshot.TargetCode == "" && snapshot.PackageCode == "" && snapshot.Remark == "" && snapshot.EffectiveMode == "" {
|
||||
return nil
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func (r *Repository) loadParentTaskTx(ctx context.Context, tx gdb.TX, parent *entity.BatchMain) (*Task, error) {
|
||||
var batchDetails []*entity.BatchDetail
|
||||
if err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where("batch_no IN (SELECT batch_no FROM batch_main WHERE source_batch_no = ? AND batch_type = ? AND tenant_id = ?)", parent.BatchNo, consts.BatchTypeAllocate, parent.TenantId).
|
||||
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeAllocate).
|
||||
OrderAsc(dao.BatchDetail.Columns().RowNo).
|
||||
OrderAsc(dao.BatchDetail.Columns().BatchNo).
|
||||
OrderAsc(dao.BatchDetail.Columns().Id).
|
||||
Scan(&batchDetails); err != nil {
|
||||
return nil, gerror.Wrap(err, "load allocate parent details failed")
|
||||
}
|
||||
if len(batchDetails) == 0 {
|
||||
return nil, gerror.New("allocate parent has no child details")
|
||||
}
|
||||
|
||||
task := &Task{
|
||||
Batch: Batch{
|
||||
ID: parent.Id, BatchNo: parent.BatchNo, BatchType: consts.BatchTypeAllocate,
|
||||
TenantID: parent.TenantId, TargetDeptID: targetDeptIDFromBatch(parent),
|
||||
CreatedDept: parent.CreatedDept, CreatedBy: parent.CreatedBy,
|
||||
StartTime: parent.StartTime, BatchInfo: parent.BatchInfo,
|
||||
},
|
||||
Details: make([]*Detail, 0, len(batchDetails)),
|
||||
TargetsByID: make(map[int64]*Target), TargetsByKey: make(map[string]*Target), TargetsByName: make(map[string][]*Target),
|
||||
}
|
||||
cardIDs := make([]int64, 0, len(batchDetails))
|
||||
targetIDs := make([]int64, 0, len(batchDetails))
|
||||
cardIDByDetailID := make(map[int64]int64, len(batchDetails))
|
||||
for _, batchDetail := range batchDetails {
|
||||
if batchDetail == nil {
|
||||
continue
|
||||
}
|
||||
detail := &Detail{OriginalSnapshot: batchDetail.Snapshot, ID: batchDetail.Id, BatchNo: batchDetail.BatchNo, RowNo: batchDetail.RowNo, DetailStatus: batchDetail.DetailStatus, ICCID: strings.TrimSpace(batchDetail.ItemId), Reason: strings.TrimSpace(batchDetail.ErrorReason)}
|
||||
if detail.DetailStatus != consts.BatchDetailStatusSuccess && detail.DetailStatus != consts.BatchDetailStatusRunning && detail.Reason == "" {
|
||||
detail.Reason = "子批次明细尚未全部验证成功"
|
||||
}
|
||||
if snapshot := batchDetail.Snapshot; snapshot != nil {
|
||||
detail.Remark = firstSnapshot(snapshot, "remark")
|
||||
detail.MSISDN = firstSnapshot(snapshot, "MSISDN", "msisdn")
|
||||
detail.IMSI = firstSnapshot(snapshot, "IMSI", "imsi")
|
||||
detail.TargetDeptID = snapshot.Get("targetDeptId").Int64()
|
||||
detail.TargetDeptKey = firstSnapshot(snapshot, "targetDeptKey", "target_dept_key")
|
||||
detail.TargetDeptName = firstSnapshot(snapshot, "targetDeptName", "target_dept_name")
|
||||
if cardID := snapshot.Get("cardId").Int64(); cardID > 0 {
|
||||
cardIDs = append(cardIDs, cardID)
|
||||
cardIDByDetailID[batchDetail.Id] = cardID
|
||||
} else if detail.Reason == "" {
|
||||
detail.Reason = "卡片快照缺少有效卡片ID"
|
||||
}
|
||||
}
|
||||
if detail.TargetDeptID > 0 {
|
||||
targetIDs = append(targetIDs, detail.TargetDeptID)
|
||||
}
|
||||
task.Details = append(task.Details, detail)
|
||||
}
|
||||
|
||||
cardsByID := make(map[int64]*entity.TSimCardInfo, len(cardIDs))
|
||||
cardIDs = uniqueInt64s(cardIDs)
|
||||
sort.Slice(cardIDs, func(i, j int) bool { return cardIDs[i] < cardIDs[j] })
|
||||
for start := 0; start < len(cardIDs); start += allocateCardQueryBatchSize {
|
||||
end := min(start+allocateCardQueryBatchSize, len(cardIDs))
|
||||
var cards []*entity.TSimCardInfo
|
||||
if err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).Safe().
|
||||
Where(dao.TSimCardInfo.Columns().TenantId, parent.TenantId).
|
||||
WhereIn(dao.TSimCardInfo.Columns().Id, cardIDs[start:end]).
|
||||
LockUpdate().Scan(&cards); err != nil {
|
||||
return nil, gerror.Wrap(err, "lock allocate parent cards failed")
|
||||
}
|
||||
for _, card := range cards {
|
||||
if card != nil {
|
||||
cardsByID[card.Id] = card
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetsByID := make(map[int64]*Target, len(targetIDs))
|
||||
targetIDs = uniqueInt64s(targetIDs)
|
||||
sort.Slice(targetIDs, func(i, j int) bool { return targetIDs[i] < targetIDs[j] })
|
||||
for start := 0; start < len(targetIDs); start += allocateCardQueryBatchSize {
|
||||
end := min(start+allocateCardQueryBatchSize, len(targetIDs))
|
||||
var depts []*entity.SysDept
|
||||
if err := tx.Ctx(ctx).Model(dao.SysDept.Table()).
|
||||
Where(dao.SysDept.Columns().TenantId, parent.TenantId).
|
||||
Where(dao.SysDept.Columns().ParentId, parent.CreatedDept).
|
||||
Where(dao.SysDept.Columns().Status, "0").
|
||||
WhereNull(dao.SysDept.Columns().DeletedAt).
|
||||
WhereIn(dao.SysDept.Columns().DeptId, targetIDs[start:end]).
|
||||
LockUpdate().Scan(&depts); err != nil {
|
||||
return nil, gerror.Wrap(err, "lock allocate parent targets failed")
|
||||
}
|
||||
for _, dept := range depts {
|
||||
if dept == nil {
|
||||
continue
|
||||
}
|
||||
target := &Target{DeptID: dept.DeptId, ParentID: dept.ParentId, DeptKey: strings.TrimSpace(dept.DeptKey), DeptName: strings.TrimSpace(dept.DeptName)}
|
||||
targetsByID[target.DeptID] = target
|
||||
task.TargetsByID[target.DeptID] = target
|
||||
if target.DeptKey != "" {
|
||||
task.TargetsByKey[target.DeptKey] = target
|
||||
}
|
||||
if target.DeptName != "" {
|
||||
task.TargetsByName[target.DeptName] = append(task.TargetsByName[target.DeptName], target)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
if detail.Target == nil {
|
||||
detail.Target = targetsByID[detail.TargetDeptID]
|
||||
}
|
||||
cardID := cardIDByDetailID[detail.ID]
|
||||
if card := cardsByID[cardID]; card != nil {
|
||||
detail.Card = &Card{ID: card.Id, ICCID: strings.TrimSpace(card.Iccid), MSISDN: strings.TrimSpace(card.Msisdn), IMSI: strings.TrimSpace(card.Imsi), DeptID: card.DeptId, AllocationBatchNo: strings.TrimSpace(card.AllocationBatchNo), PackageStatus: card.PackageStatus, BossCardStatus: card.BossCardStatus, FlowPkgEndAt: card.FlowPkgEndAt}
|
||||
}
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func uniqueInt64s(values []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
unique := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
unique = append(unique, value)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAllocateDeadlockMaxRetries = 3
|
||||
defaultAllocateDeadlockRetryDelay = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
type allocateParentLock struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
}
|
||||
|
||||
var allocateParentLocks = struct {
|
||||
mu sync.Mutex
|
||||
values map[string]*allocateParentLock
|
||||
}{values: make(map[string]*allocateParentLock)}
|
||||
|
||||
func acquireAllocateParentLock(batchNo string) func() {
|
||||
allocateParentLocks.mu.Lock()
|
||||
lock := allocateParentLocks.values[batchNo]
|
||||
if lock == nil {
|
||||
lock = &allocateParentLock{}
|
||||
allocateParentLocks.values[batchNo] = lock
|
||||
}
|
||||
lock.refs++
|
||||
allocateParentLocks.mu.Unlock()
|
||||
|
||||
lock.mu.Lock()
|
||||
return func() {
|
||||
lock.mu.Unlock()
|
||||
allocateParentLocks.mu.Lock()
|
||||
lock.refs--
|
||||
if lock.refs == 0 {
|
||||
delete(allocateParentLocks.values, batchNo)
|
||||
}
|
||||
allocateParentLocks.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func allocateDeadlockMaxRetries(ctx context.Context) int {
|
||||
retries := g.Cfg().MustGet(ctx, "batch.allocate.deadlockMaxRetries", defaultAllocateDeadlockMaxRetries).Int()
|
||||
if retries < 0 {
|
||||
return 0
|
||||
}
|
||||
return retries
|
||||
}
|
||||
|
||||
func allocateDeadlockRetryDelay(ctx context.Context, attempt int) time.Duration {
|
||||
delay := g.Cfg().MustGet(ctx, "batch.allocate.deadlockRetryDelay", defaultAllocateDeadlockRetryDelay).Duration()
|
||||
if delay <= 0 {
|
||||
delay = defaultAllocateDeadlockRetryDelay
|
||||
}
|
||||
for i := 0; i < attempt; i++ {
|
||||
if delay >= time.Second {
|
||||
return time.Second
|
||||
}
|
||||
delay *= 2
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func waitAllocateDeadlockRetry(ctx context.Context, attempt int) error {
|
||||
timer := time.NewTimer(allocateDeadlockRetryDelay(ctx, attempt))
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func isAllocateDeadlock(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "deadlock found") || strings.Contains(message, "error 1213") || strings.Contains(message, "1213 (40001)")
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPersistedAllocateDetailsRequireExactBusinessResult(t *testing.T) {
|
||||
task := &Task{Batch: Batch{BatchNo: "ALC-child", TenantID: "000000", CreatedBy: 133, CreatedDept: 175}}
|
||||
detail := &Detail{ID: 1, ICCID: "89860401791508277308", TargetDeptID: 185}
|
||||
good := entity.BatchDetail{Id: 1, BatchNo: task.Batch.BatchNo, BatchType: consts.BatchTypeAllocate, TenantId: task.Batch.TenantID, DetailStatus: consts.BatchDetailStatusSuccess, UpdatedBy: 133, Snapshot: gjson.New(detail.Snapshot(task.Batch).String())}
|
||||
if err := validatePersistedDetailResults(task, []*Detail{detail}, []*entity.BatchDetail{&good}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"missing", "tenant", "batch", "type", "status", "reason", "snapshot", "operator", "duplicate"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
row := good
|
||||
rows := []*entity.BatchDetail{&row}
|
||||
details := []*Detail{detail}
|
||||
switch name {
|
||||
case "missing":
|
||||
rows = nil
|
||||
case "tenant":
|
||||
row.TenantId = "other"
|
||||
case "batch":
|
||||
row.BatchNo = "other"
|
||||
case "type":
|
||||
row.BatchType = 4
|
||||
case "status":
|
||||
row.DetailStatus = consts.BatchDetailStatusRunning
|
||||
case "reason":
|
||||
row.ErrorReason = "unexpected"
|
||||
case "snapshot":
|
||||
row.Snapshot = gjson.New(map[string]any{"targetDeptId": 999})
|
||||
case "operator":
|
||||
row.UpdatedBy = 999
|
||||
case "duplicate":
|
||||
details = append(details, detail)
|
||||
rows = append(rows, &row)
|
||||
}
|
||||
if validatePersistedDetailResults(task, details, rows) == nil {
|
||||
t.Fatal("inconsistent detail accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
detail.Reason = "整批校验失败"
|
||||
good.DetailStatus = consts.BatchDetailStatusFailed
|
||||
good.ErrorReason = detail.Reason
|
||||
good.Snapshot = gjson.New(detail.Snapshot(task.Batch).String())
|
||||
if err := validatePersistedDetailResults(task, []*Detail{detail}, []*entity.BatchDetail{&good}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailChunkZeroAffectedChecksPersistedResult(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(:memory:)"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close(ctx)
|
||||
for _, sql := range []string{
|
||||
"CREATE TABLE batch_detail (id INTEGER PRIMARY KEY, batch_no TEXT, batch_type INTEGER, tenant_id TEXT, detail_status INTEGER, error_reason TEXT, snapshot TEXT, updated_by INTEGER, updated_at DATETIME)",
|
||||
"CREATE TABLE card_test (id INTEGER PRIMARY KEY, dept_id INTEGER)",
|
||||
"INSERT INTO card_test VALUES (1,175)",
|
||||
} {
|
||||
if _, err = db.Exec(ctx, sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
task := &Task{Batch: Batch{BatchNo: "ALC-child", TenantID: "000000", CreatedBy: 133, CreatedDept: 175}}
|
||||
detail := &Detail{ID: 1, ICCID: "CARD", TargetDeptID: 185, Remark: "保留备注"}
|
||||
if _, err = db.Model("batch_detail").Data(do.BatchDetail{Id: 1, BatchNo: task.Batch.BatchNo, BatchType: 3, TenantId: task.Batch.TenantID, DetailStatus: 2, ErrorReason: "", Snapshot: detail.Snapshot(task.Batch), UpdatedBy: 133}).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 强制模拟驱动返回零修改行,不依赖 SQLite 与 MySQL 行数语义差异。
|
||||
if _, err = db.Exec(ctx, "CREATE TRIGGER ignore_update BEFORE UPDATE ON batch_detail BEGIN SELECT RAISE(IGNORE); END"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &Repository{db: db}
|
||||
if err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return repo.updateDetailChunk(ctx, tx, task, []*Detail{detail}, gtime.Now())
|
||||
}); err != nil {
|
||||
t.Fatalf("identical persisted result rejected: %v", err)
|
||||
}
|
||||
for _, name := range []string{"mismatch", "missing", "tenant"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
changed := *detail
|
||||
changed.Reason = "unexpected"
|
||||
if name == "missing" {
|
||||
changed.ID = 999
|
||||
}
|
||||
scopedTask := *task
|
||||
if name == "tenant" {
|
||||
scopedTask.Batch.TenantID = "other"
|
||||
}
|
||||
err := db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if _, err := tx.Exec("UPDATE card_test SET dept_id=185 WHERE id=1"); err != nil {
|
||||
return err
|
||||
}
|
||||
return repo.updateDetailChunk(ctx, tx, &scopedTask, []*Detail{&changed}, gtime.Now())
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("unsafe update accepted")
|
||||
}
|
||||
dept, err := db.Model("card_test").Value("dept_id")
|
||||
if err != nil || dept.Int() != 175 {
|
||||
t.Fatalf("transaction failed to rollback ownership: %v %v", dept, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/packagecustomer"
|
||||
)
|
||||
|
||||
const defaultAllocateProcessingRetryAfter = 30 * time.Minute
|
||||
|
||||
const allocateParentJobRole = "allocate_parent"
|
||||
|
||||
type executor struct {
|
||||
repository *Repository
|
||||
}
|
||||
|
||||
func NewExecutor() *executor {
|
||||
return &executor{repository: NewRepository()}
|
||||
}
|
||||
|
||||
func (e *executor) CanExecuteProcessing(ctx context.Context, main *entity.BatchMain) (bool, error) {
|
||||
if main == nil || main.BatchAsyncStatus != consts.BatchAsyncStatusProcessing {
|
||||
return false, nil
|
||||
}
|
||||
if isAllocateParent(main) {
|
||||
return true, nil
|
||||
}
|
||||
if main.BatchType != consts.BatchTypeAllocate {
|
||||
return false, nil
|
||||
}
|
||||
if main.StartTime == nil || main.StartTime.IsZero() {
|
||||
return true, nil
|
||||
}
|
||||
return gtime.Now().Sub(main.StartTime) >= allocateProcessingRetryAfter(ctx), nil
|
||||
}
|
||||
|
||||
func (e *executor) HandleExecutionFailure(ctx context.Context, batchNo string, batchType int, _ string) error {
|
||||
if batchType != consts.BatchTypeAllocate {
|
||||
return nil
|
||||
}
|
||||
main, err := e.repository.LoadMain(ctx, batchNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if main == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(main.SourceBatchNo) == "" {
|
||||
return nil
|
||||
}
|
||||
return e.repository.AggregateParent(ctx, main.SourceBatchNo)
|
||||
}
|
||||
|
||||
func allocateProcessingRetryAfter(ctx context.Context) time.Duration {
|
||||
duration := g.Cfg().MustGet(ctx, "batch.allocate.processingRetryAfter", defaultAllocateProcessingRetryAfter).Duration()
|
||||
if duration <= 0 {
|
||||
return defaultAllocateProcessingRetryAfter
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
func (e *executor) Execute(ctx context.Context, batchNo string) error {
|
||||
main, err := e.repository.LoadMain(ctx, strings.TrimSpace(batchNo))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load allocate batch main failed")
|
||||
}
|
||||
if isAllocateParent(main) {
|
||||
return e.repository.AggregateParent(ctx, main.BatchNo)
|
||||
}
|
||||
task, err := e.repository.Load(ctx, strings.TrimSpace(batchNo))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load allocate batch failed")
|
||||
}
|
||||
validateTask(task)
|
||||
if err = e.repository.Commit(ctx, task); err != nil {
|
||||
return gerror.Wrap(err, "commit allocate batch failed")
|
||||
}
|
||||
if strings.TrimSpace(task.Batch.ParentBatchNo) == "" {
|
||||
publishPackageCustomerSync(ctx, task)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isAllocateParent(main *entity.BatchMain) bool {
|
||||
if main == nil || main.BatchType != consts.BatchTypeAllocate || strings.TrimSpace(main.SourceBatchNo) != "" {
|
||||
return false
|
||||
}
|
||||
return main.BatchInfo != nil && main.BatchInfo.Get("jobRole").String() == allocateParentJobRole
|
||||
}
|
||||
|
||||
func validateTask(task *Task) {
|
||||
if task == nil || len(task.Details) == 0 {
|
||||
if task != nil {
|
||||
task.FailureReason = "划拨批次没有可处理明细"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
iccidCounts := make(map[string]int, len(task.Details))
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
detail.ICCID = strings.TrimSpace(detail.ICCID)
|
||||
detail.MSISDN = strings.TrimSpace(detail.MSISDN)
|
||||
detail.IMSI = strings.TrimSpace(detail.IMSI)
|
||||
detail.TargetDeptKey = strings.TrimSpace(detail.TargetDeptKey)
|
||||
detail.TargetDeptName = strings.TrimSpace(detail.TargetDeptName)
|
||||
if detail.ICCID != "" {
|
||||
iccidCounts[detail.ICCID]++
|
||||
}
|
||||
}
|
||||
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
if detail.Reason == "" && iccidCounts[detail.ICCID] > 1 {
|
||||
detail.Reason = "ICCID在批次内重复"
|
||||
}
|
||||
validateDetail(task, detail)
|
||||
}
|
||||
_, failCount := countResult(task)
|
||||
if failCount > 0 {
|
||||
markAtomicFailure(task, "划拨批次存在失败明细,整批未执行")
|
||||
}
|
||||
}
|
||||
|
||||
func publishPackageCustomerSync(ctx context.Context, task *Task) {
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
deptIds := []int64{task.Batch.CreatedDept}
|
||||
hasSuccess := false
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil || !detail.Success() {
|
||||
continue
|
||||
}
|
||||
hasSuccess = true
|
||||
if detail.TargetDeptID > 0 {
|
||||
deptIds = append(deptIds, detail.TargetDeptID)
|
||||
}
|
||||
}
|
||||
if !hasSuccess {
|
||||
return
|
||||
}
|
||||
packagecustomer.PublishSyncForDeptsSafe(ctx, task.Batch.TenantID, task.Batch.CreatedBy, deptIds, "allocate", task.Batch.BatchNo)
|
||||
}
|
||||
|
||||
func markAtomicFailure(task *Task, reason string) {
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
reason = "本批次存在失败明细,整批未执行"
|
||||
}
|
||||
task.FailureReason = reason
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && strings.TrimSpace(detail.Reason) == "" {
|
||||
detail.Reason = atomicNotExecutedReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const atomicNotExecutedReason = "校验通过,但本批次存在失败明细,整批未执行划拨"
|
||||
|
||||
func allocationDetailStatus(detail *Detail) int {
|
||||
if detail.Reason == atomicNotExecutedReason {
|
||||
return consts.BatchDetailStatusNotExecuted
|
||||
}
|
||||
if detail.Success() {
|
||||
return consts.BatchDetailStatusSuccess
|
||||
}
|
||||
return consts.BatchDetailStatusFailed
|
||||
}
|
||||
|
||||
func countValidationFailures(task *Task) int {
|
||||
count := 0
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && !detail.Success() && detail.Reason != atomicNotExecutedReason {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func persistedAllocationDetailStatus(task *Task, detail *Detail) int {
|
||||
status := allocationDetailStatus(detail)
|
||||
if strings.TrimSpace(task.Batch.ParentBatchNo) != "" && status == consts.BatchDetailStatusSuccess {
|
||||
return consts.BatchDetailStatusRunning
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func validateDetail(task *Task, detail *Detail) {
|
||||
if task == nil || detail == nil {
|
||||
return
|
||||
}
|
||||
var reasons []string
|
||||
if reason := strings.TrimSpace(detail.Reason); reason != "" {
|
||||
reasons = append(reasons, reason)
|
||||
}
|
||||
defer func() { detail.Reason = strings.Join(reasons, ";") }()
|
||||
if detail.ICCID == "" {
|
||||
reasons = append(reasons, "ICCID不能为空")
|
||||
return
|
||||
}
|
||||
detail.Reason = ""
|
||||
target := resolveTarget(task, detail)
|
||||
if target == nil {
|
||||
reasons = append(reasons, detail.Reason)
|
||||
} else {
|
||||
detail.Target = target
|
||||
detail.TargetDeptID = target.DeptID
|
||||
if detail.TargetDeptKey == "" {
|
||||
detail.TargetDeptKey = strings.TrimSpace(target.DeptKey)
|
||||
}
|
||||
if detail.TargetDeptName == "" {
|
||||
detail.TargetDeptName = strings.TrimSpace(target.DeptName)
|
||||
}
|
||||
}
|
||||
if detail.Card == nil {
|
||||
reasons = append(reasons, "ICCID不存在或不属于当前租户")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(detail.ICCID) != strings.TrimSpace(detail.Card.ICCID) {
|
||||
reasons = append(reasons, "ICCID与卡档案不一致")
|
||||
return
|
||||
}
|
||||
detail.MSISDN = coalesce(detail.MSISDN, detail.Card.MSISDN)
|
||||
detail.IMSI = coalesce(detail.IMSI, detail.Card.IMSI)
|
||||
if detail.Card.DeptID != task.Batch.CreatedDept {
|
||||
reasons = append(reasons, "ICCID不在当前操作员授权范围内")
|
||||
}
|
||||
if strings.TrimSpace(detail.MSISDN) != "" && strings.TrimSpace(detail.MSISDN) != detail.Card.MSISDN {
|
||||
reasons = append(reasons, "MSISDN与卡档案不一致")
|
||||
}
|
||||
if strings.TrimSpace(detail.IMSI) != "" && strings.TrimSpace(detail.IMSI) != detail.Card.IMSI {
|
||||
reasons = append(reasons, "IMSI与卡档案不一致")
|
||||
}
|
||||
if detail.Card.BossCardStatus == consts.SimBossCardStatusStopped {
|
||||
reasons = append(reasons, "卡已停机,不能划拨")
|
||||
}
|
||||
if detail.Card.BossCardStatus == consts.SimBossCardStatusCanceled {
|
||||
reasons = append(reasons, "卡已销户,不能划拨")
|
||||
}
|
||||
if detail.Card.PackageStatus == consts.PackageStatusExpired ||
|
||||
(detail.Card.FlowPkgEndAt != nil && !detail.Card.FlowPkgEndAt.IsZero() && detail.Card.FlowPkgEndAt.Before(gtime.Now())) {
|
||||
reasons = append(reasons, "套餐已过期,不能划拨")
|
||||
}
|
||||
if target != nil && detail.Card.DeptID == target.DeptID {
|
||||
reasons = append(reasons, "卡已属于目标客户,无需重复划拨")
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTarget(task *Task, detail *Detail) *Target {
|
||||
if task == nil || detail == nil {
|
||||
return nil
|
||||
}
|
||||
targetDeptID := detail.TargetDeptID
|
||||
if targetDeptID <= 0 {
|
||||
targetDeptID = task.Batch.TargetDeptID
|
||||
}
|
||||
if targetDeptID > 0 {
|
||||
target := task.TargetsByID[targetDeptID]
|
||||
if target == nil {
|
||||
detail.Reason = "划拨目标客户不存在或不在当前部门的下级范围内"
|
||||
return nil
|
||||
}
|
||||
return target
|
||||
}
|
||||
if detail.TargetDeptKey != "" {
|
||||
target := task.TargetsByKey[detail.TargetDeptKey]
|
||||
if target == nil {
|
||||
detail.Reason = "划拨目标客户编码不存在或不在当前部门的下级范围内"
|
||||
return nil
|
||||
}
|
||||
return target
|
||||
}
|
||||
if detail.TargetDeptName != "" {
|
||||
targets := task.TargetsByName[detail.TargetDeptName]
|
||||
if len(targets) == 0 {
|
||||
detail.Reason = "划拨目标客户名称不存在或不在当前部门的下级范围内"
|
||||
return nil
|
||||
}
|
||||
if len(targets) > 1 {
|
||||
detail.Reason = "划拨目标客户名称不唯一,请使用目标客户编码"
|
||||
return nil
|
||||
}
|
||||
return targets[0]
|
||||
}
|
||||
detail.Reason = "目标客户不能为空"
|
||||
return nil
|
||||
}
|
||||
|
||||
func countResult(task *Task) (successCount, failCount int) {
|
||||
if task == nil {
|
||||
return 0, 0
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && detail.Success() {
|
||||
successCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
return successCount, failCount
|
||||
}
|
||||
|
||||
func coalesce(value, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestAllocateProcessingRetryAfterHasLargeBatchDefault(t *testing.T) {
|
||||
if got := allocateProcessingRetryAfter(context.Background()); got < 30*time.Minute {
|
||||
t.Fatalf("allocateProcessingRetryAfter() = %s, want at least 30m", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllocateParentRequiresTypeAndRole(t *testing.T) {
|
||||
parent := &entity.BatchMain{
|
||||
BatchType: consts.BatchTypeAllocate,
|
||||
BatchInfo: gjson.New(`{"jobRole":"allocate_parent"}`),
|
||||
}
|
||||
if !isAllocateParent(parent) {
|
||||
t.Fatal("expected allocate parent metadata to be recognized")
|
||||
}
|
||||
parent.SourceBatchNo = "ALC-CHILD"
|
||||
if isAllocateParent(parent) {
|
||||
t.Fatal("child batch must not be recognized as allocate parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllocateDeadlock(t *testing.T) {
|
||||
if !isAllocateDeadlock(errors.New("Error 1213 (40001): Deadlock found when trying to get lock")) {
|
||||
t.Fatal("expected MySQL deadlock to be retryable")
|
||||
}
|
||||
if isAllocateDeadlock(errors.New("validation failed")) {
|
||||
t.Fatal("validation errors must not be treated as deadlocks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAllocateParentStartTime(t *testing.T) {
|
||||
parentStart := gtime.New("2026-09-29 12:59:00")
|
||||
childStart := gtime.New("2026-09-29 12:59:32")
|
||||
fallback := gtime.New("2026-09-29 13:00:21")
|
||||
|
||||
if got := resolveAllocateParentStartTime(parentStart, childStart, fallback); got != parentStart {
|
||||
t.Fatalf("parent start time = %v, want existing parent time %v", got, parentStart)
|
||||
}
|
||||
if got := resolveAllocateParentStartTime(nil, childStart, fallback); got != childStart {
|
||||
t.Fatalf("parent start time = %v, want earliest child time %v", got, childStart)
|
||||
}
|
||||
if got := resolveAllocateParentStartTime(nil, nil, fallback); got != fallback {
|
||||
t.Fatalf("parent start time = %v, want fallback time %v", got, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateBatchSizesRemainBounded(t *testing.T) {
|
||||
if allocateCardQueryBatchSize <= 0 || allocateCardQueryBatchSize > 1000 {
|
||||
t.Fatalf("allocateCardQueryBatchSize = %d", allocateCardQueryBatchSize)
|
||||
}
|
||||
if allocateCardUpdateBatchSize <= 0 || allocateCardUpdateBatchSize > 1000 {
|
||||
t.Fatalf("allocateCardUpdateBatchSize = %d", allocateCardUpdateBatchSize)
|
||||
}
|
||||
if allocateDetailUpdateBatchSize <= 0 || allocateDetailUpdateBatchSize > 1000 {
|
||||
t.Fatalf("allocateDetailUpdateBatchSize = %d", allocateDetailUpdateBatchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDetailUpdateStatementUsesBoundParameters(t *testing.T) {
|
||||
task := &Task{Batch: Batch{BatchNo: "ALC-1", CreatedBy: 7}}
|
||||
details := []*Detail{
|
||||
{ID: 11, DetailStatus: consts.BatchDetailStatusPending, Reason: atomicNotExecutedReason},
|
||||
{ID: 12, DetailStatus: consts.BatchDetailStatusRunning, Reason: "second"},
|
||||
}
|
||||
statement, args := buildDetailUpdateStatement(task, details, gtime.Now())
|
||||
if strings.Contains(statement, details[0].Reason) {
|
||||
t.Fatal("detail reason was interpolated into SQL")
|
||||
}
|
||||
if !strings.Contains(statement, "CASE") || !strings.Contains(statement, "IN (?,?)") {
|
||||
t.Fatalf("unexpected statement: %s", statement)
|
||||
}
|
||||
if len(args) != len(details)*7+10 {
|
||||
t.Fatalf("argument count = %d, want %d", len(args), len(details)*7+10)
|
||||
}
|
||||
if args[1] != consts.BatchDetailStatusNotExecuted || args[3] != consts.BatchDetailStatusFailed {
|
||||
t.Fatalf("status parameters = %v, %v; want not executed and failed", args[1], args[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAtomicParentResultRejectsPartialSuccess(t *testing.T) {
|
||||
task := &Task{Details: []*Detail{
|
||||
{ICCID: "8986000000000000001"},
|
||||
{ICCID: "8986000000000000002", Reason: "目标客户不存在"},
|
||||
}}
|
||||
|
||||
successCount, failCount := normalizeAtomicParentResult(task)
|
||||
if successCount != 0 || failCount != 1 {
|
||||
t.Fatalf("normalizeAtomicParentResult() = %d, %d; want 0, 1", successCount, failCount)
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail.Success() {
|
||||
t.Fatalf("detail %s remained successful", detail.ICCID)
|
||||
}
|
||||
}
|
||||
if allocationDetailStatus(task.Details[0]) != consts.BatchDetailStatusNotExecuted || allocationDetailStatus(task.Details[1]) != consts.BatchDetailStatusFailed {
|
||||
t.Fatalf("wrong detail statuses: %+v", task.Details)
|
||||
}
|
||||
rows := task.ResultRows()
|
||||
if rows[0].Status != "未执行" || rows[1].Status != "失败" {
|
||||
t.Fatalf("wrong result file statuses: %+v", rows)
|
||||
}
|
||||
status, result, _ := parentBatchResult(successCount, failCount)
|
||||
if status != consts.BatchAsyncStatusFailed || result != consts.BatchRunResultFailed {
|
||||
t.Fatalf("parentBatchResult() = %d, %d; want failed, failed", status, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateChildValidationDoesNotReportExecutedSuccess(t *testing.T) {
|
||||
task := &Task{Batch: Batch{BatchNo: "child", ParentBatchNo: "parent"}}
|
||||
detail := &Detail{ID: 1, ICCID: "valid"}
|
||||
_, args := buildDetailUpdateStatement(task, []*Detail{detail}, gtime.Now())
|
||||
if args[1] != consts.BatchDetailStatusRunning {
|
||||
t.Fatalf("validated child status = %v, want running until parent commits", args[1])
|
||||
}
|
||||
task.Batch.ParentBatchNo = ""
|
||||
_, args = buildDetailUpdateStatement(task, []*Detail{detail}, gtime.Now())
|
||||
if args[1] != consts.BatchDetailStatusSuccess {
|
||||
t.Fatalf("committed parent status = %v, want success", args[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTaskCollectsAllCardFailuresBeforeBlockingBatch(t *testing.T) {
|
||||
task := &Task{
|
||||
Batch: Batch{CreatedDept: 100, TargetDeptID: 200},
|
||||
TargetsByID: map[int64]*Target{200: {DeptID: 200, ParentID: 100}},
|
||||
Details: []*Detail{
|
||||
{ICCID: "missing"},
|
||||
{ICCID: "valid", Card: &Card{ICCID: "valid", DeptID: 100}},
|
||||
{ICCID: "stopped", Card: &Card{ICCID: "stopped", DeptID: 100, BossCardStatus: consts.SimBossCardStatusStopped, PackageStatus: consts.PackageStatusExpired}},
|
||||
},
|
||||
}
|
||||
validateTask(task)
|
||||
if task.Details[0].Reason != "ICCID不存在或不属于当前租户" {
|
||||
t.Fatalf("first failure was lost: %q", task.Details[0].Reason)
|
||||
}
|
||||
if task.Details[1].Reason != atomicNotExecutedReason || allocationDetailStatus(task.Details[1]) != consts.BatchDetailStatusNotExecuted {
|
||||
t.Fatalf("validated card was not blocked: %+v", task.Details[1])
|
||||
}
|
||||
for _, reason := range []string{"卡已停机,不能划拨", "套餐已过期,不能划拨"} {
|
||||
if !strings.Contains(task.Details[2].Reason, reason) {
|
||||
t.Fatalf("later card was not fully validated: %q", task.Details[2].Reason)
|
||||
}
|
||||
}
|
||||
if successes, failures := normalizeAtomicParentResult(task); successes != 0 || failures != 2 {
|
||||
t.Fatalf("got successes=%d failures=%d, want 0 and 2", successes, failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDetailAllowsCurrentHolderReallocation(t *testing.T) {
|
||||
task := &Task{
|
||||
Batch: Batch{
|
||||
CreatedDept: 100,
|
||||
},
|
||||
TargetsByID: map[int64]*Target{
|
||||
200: {DeptID: 200, ParentID: 100},
|
||||
},
|
||||
}
|
||||
detail := &Detail{
|
||||
ICCID: "8986000000000000000",
|
||||
TargetDeptID: 200,
|
||||
Card: &Card{
|
||||
ICCID: "8986000000000000000",
|
||||
DeptID: 100,
|
||||
AllocationBatchNo: "ALC202608050001",
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
},
|
||||
}
|
||||
|
||||
validateDetail(task, detail)
|
||||
if detail.Reason != "" {
|
||||
t.Fatalf("expected current holder reallocation to pass, got %q", detail.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDetailRejectsAncestorReallocationAfterCardMoved(t *testing.T) {
|
||||
task := &Task{
|
||||
Batch: Batch{
|
||||
CreatedDept: 100,
|
||||
},
|
||||
TargetsByID: map[int64]*Target{
|
||||
300: {DeptID: 300, ParentID: 100},
|
||||
},
|
||||
}
|
||||
detail := &Detail{
|
||||
ICCID: "8986000000000000000",
|
||||
TargetDeptID: 300,
|
||||
Card: &Card{
|
||||
ICCID: "8986000000000000000",
|
||||
DeptID: 200,
|
||||
AllocationBatchNo: "ALC202608050001",
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
},
|
||||
}
|
||||
|
||||
validateDetail(task, detail)
|
||||
if detail.Reason == "" {
|
||||
t.Fatal("expected ancestor reallocation to be rejected after card moved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTaskMarksWholeBatchFailed(t *testing.T) {
|
||||
task := &Task{
|
||||
Details: []*Detail{
|
||||
{ICCID: "8986000000000000001"},
|
||||
{ICCID: "8986000000000000002", Reason: "目标客户不存在"},
|
||||
},
|
||||
}
|
||||
|
||||
validateTask(task)
|
||||
|
||||
for _, detail := range task.Details {
|
||||
if detail.Success() {
|
||||
t.Fatalf("detail %s remained successful after atomic failure", detail.ICCID)
|
||||
}
|
||||
}
|
||||
if task.FailureReason == "" {
|
||||
t.Fatal("expected batch failure reason")
|
||||
}
|
||||
status, result := batchResult(countResult(task))
|
||||
if status != consts.BatchAsyncStatusFailed || result != consts.BatchRunResultFailed {
|
||||
t.Fatalf("batchResult() = %d, %d; want failed, failed", status, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDetailRejectsChangedCardICCID(t *testing.T) {
|
||||
task := &Task{Batch: Batch{CreatedDept: 100}, TargetsByID: map[int64]*Target{200: {DeptID: 200, ParentID: 100}}}
|
||||
detail := &Detail{ICCID: "8986000000000000001", TargetDeptID: 200, Card: &Card{ICCID: "8986000000000000002", DeptID: 100, PackageStatus: consts.PackageStatusEffective}}
|
||||
|
||||
validateDetail(task, detail)
|
||||
if detail.Reason == "" {
|
||||
t.Fatal("expected changed card ICCID to be rejected")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
type Batch struct {
|
||||
ID int64
|
||||
BatchNo string
|
||||
BatchType int
|
||||
ParentBatchNo string
|
||||
TenantID string
|
||||
TargetDeptID int64
|
||||
CreatedDept int64
|
||||
CreatedBy int64
|
||||
StartTime *gtime.Time
|
||||
BatchInfo *gjson.Json
|
||||
}
|
||||
|
||||
type Card struct {
|
||||
ID int64
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
DeptID int64
|
||||
AllocationBatchNo string
|
||||
PackageStatus int
|
||||
BossCardStatus int
|
||||
FlowPkgEndAt *gtime.Time
|
||||
}
|
||||
|
||||
type Target struct {
|
||||
DeptID int64
|
||||
ParentID int64
|
||||
DeptKey string
|
||||
DeptName string
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
OriginalSnapshot *gjson.Json
|
||||
ID int64
|
||||
BatchNo string
|
||||
RowNo int
|
||||
DetailStatus int
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
TargetDeptID int64
|
||||
TargetDeptKey string
|
||||
TargetDeptName string
|
||||
Remark string
|
||||
Card *Card
|
||||
Target *Target
|
||||
Reason string
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Batch Batch
|
||||
Details []*Detail
|
||||
TargetsByID map[int64]*Target
|
||||
TargetsByKey map[string]*Target
|
||||
TargetsByName map[string][]*Target
|
||||
FailureReason string
|
||||
}
|
||||
|
||||
func (d *Detail) Success() bool {
|
||||
return d != nil && strings.TrimSpace(d.Reason) == ""
|
||||
}
|
||||
|
||||
func (d *Detail) DisplayCode() string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
if d.Card != nil && strings.TrimSpace(d.Card.ICCID) != "" {
|
||||
return strings.TrimSpace(d.Card.ICCID)
|
||||
}
|
||||
return strings.TrimSpace(d.ICCID)
|
||||
}
|
||||
|
||||
func (d *Detail) Snapshot(batch Batch) *gjson.Json {
|
||||
if d == nil {
|
||||
return gjson.New(nil)
|
||||
}
|
||||
data := map[string]any{}
|
||||
if d.OriginalSnapshot != nil {
|
||||
for k, v := range d.OriginalSnapshot.Map() {
|
||||
data[k] = v
|
||||
}
|
||||
}
|
||||
sourceDeptID := batch.CreatedDept
|
||||
if d.OriginalSnapshot != nil && d.OriginalSnapshot.Get("sourceDeptId").Int64() > 0 {
|
||||
sourceDeptID = d.OriginalSnapshot.Get("sourceDeptId").Int64()
|
||||
}
|
||||
current := map[string]any{
|
||||
"rowNo": d.RowNo,
|
||||
"ICCID": strings.TrimSpace(d.ICCID),
|
||||
"MSISDN": strings.TrimSpace(d.MSISDN),
|
||||
"IMSI": strings.TrimSpace(d.IMSI),
|
||||
"sourceDeptId": sourceDeptID,
|
||||
"targetDeptId": d.TargetDeptID,
|
||||
"targetDeptKey": strings.TrimSpace(d.TargetDeptKey),
|
||||
"targetDeptName": strings.TrimSpace(d.TargetDeptName),
|
||||
"remark": strings.TrimSpace(d.Remark),
|
||||
"errorReason": strings.TrimSpace(d.Reason),
|
||||
}
|
||||
for k, v := range current {
|
||||
data[k] = v
|
||||
}
|
||||
if d.Card != nil {
|
||||
data["cardId"] = d.Card.ID
|
||||
data["cardIccid"] = d.Card.ICCID
|
||||
data["cardMsisdn"] = d.Card.MSISDN
|
||||
data["cardImsi"] = d.Card.IMSI
|
||||
data["cardDeptIdBefore"] = d.Card.DeptID
|
||||
data["allocationBatchNoBefore"] = strings.TrimSpace(d.Card.AllocationBatchNo)
|
||||
data["packageStatus"] = d.Card.PackageStatus
|
||||
data["bossCardStatus"] = d.Card.BossCardStatus
|
||||
if d.Card.FlowPkgEndAt != nil {
|
||||
data["flowPkgEndAt"] = d.Card.FlowPkgEndAt.String()
|
||||
}
|
||||
}
|
||||
if d.Target != nil {
|
||||
data["targetDeptId"] = d.Target.DeptID
|
||||
if strings.TrimSpace(d.TargetDeptKey) == "" {
|
||||
data["targetDeptKey"] = d.Target.DeptKey
|
||||
}
|
||||
if strings.TrimSpace(d.TargetDeptName) == "" {
|
||||
data["targetDeptName"] = d.Target.DeptName
|
||||
}
|
||||
}
|
||||
return gjson.New(data)
|
||||
}
|
||||
|
||||
func (t *Task) ResultRows() []batchresult.Row {
|
||||
rows := make([]batchresult.Row, 0, len(t.Details))
|
||||
for _, detail := range t.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
targetName := strings.TrimSpace(detail.TargetDeptName)
|
||||
if targetName == "" && detail.Target != nil && strings.TrimSpace(detail.Target.DeptName) != "" {
|
||||
targetName = strings.TrimSpace(detail.Target.DeptName)
|
||||
}
|
||||
rows = append(rows, batchresult.Row{
|
||||
RowNo: detail.RowNo,
|
||||
ObjectCode: detail.DisplayCode(),
|
||||
ICCID: strings.TrimSpace(detail.ICCID),
|
||||
MSISDN: strings.TrimSpace(detail.MSISDN),
|
||||
IMSI: strings.TrimSpace(detail.IMSI),
|
||||
TargetDeptName: targetName,
|
||||
Operation: "卡划拨",
|
||||
Success: detail.Success(),
|
||||
Status: allocationResultStatus(detail),
|
||||
Reason: strings.TrimSpace(detail.Reason),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func allocationResultStatus(detail *Detail) string {
|
||||
if allocationDetailStatus(detail) == consts.BatchDetailStatusNotExecuted {
|
||||
return "未执行"
|
||||
}
|
||||
if detail.Success() {
|
||||
return "成功"
|
||||
}
|
||||
return "失败"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnitSnapshotSurvivesRenameAndWriteback(t *testing.T) {
|
||||
detail := &Detail{OriginalSnapshot: gjson.New(map[string]any{"sourceDeptName": "划拨旧名称", "sourceDeptKey": "source", "auditMarker": "keep"}), TargetDeptID: 2, TargetDeptName: "接收旧名称", TargetDeptKey: "target", Target: &Target{DeptID: 2, DeptName: "接收新名称", DeptKey: "new"}}
|
||||
info := detail.Snapshot(Batch{CreatedDept: 1})
|
||||
if info.Get("sourceDeptName").String() != "划拨旧名称" || info.Get("targetDeptName").String() != "接收旧名称" || info.Get("targetDeptKey").String() != "target" || info.Get("auditMarker").String() != "keep" {
|
||||
t.Fatal(info)
|
||||
}
|
||||
task := &Task{Details: []*Detail{detail}}
|
||||
if task.ResultRows()[0].TargetDeptName != "接收旧名称" {
|
||||
t.Fatal("结果文件覆盖历史名称")
|
||||
}
|
||||
parent := mergeAllocateParentInfo(gjson.New(map[string]any{"sourceDeptName": "划拨旧名称", "targetDeptName": "接收旧名称"}), nil)
|
||||
if parent.Get("sourceDeptName").String() != "划拨旧名称" {
|
||||
t.Fatal(parent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitTargetUsesStableIDAfterRename(t *testing.T) {
|
||||
target := &Target{DeptID: 2, DeptName: "接收单位新名称", DeptKey: "new-key"}
|
||||
detail := &Detail{OriginalSnapshot: gjson.New(map[string]any{"sourceDeptId": 9}), TargetDeptID: 2, TargetDeptName: "接收单位旧名称"}
|
||||
task := &Task{TargetsByID: map[int64]*Target{2: target}}
|
||||
if resolveTarget(task, detail) != target {
|
||||
t.Fatal("单位改名后未优先使用稳定 ID")
|
||||
}
|
||||
if detail.Snapshot(Batch{CreatedDept: 1}).Get("sourceDeptId").Int64() != 9 {
|
||||
t.Fatal("执行写回覆盖原划拨单位 ID")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package balance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/smartwalle/alipay/v3"
|
||||
)
|
||||
|
||||
type alipayConfig struct {
|
||||
AppID string `yaml:"appId"`
|
||||
PrivateKey string `yaml:"privateKey"`
|
||||
AlipayPublicKey string `yaml:"alipayPublicKey"`
|
||||
IsProduction bool `yaml:"isProduction"`
|
||||
}
|
||||
|
||||
type alipayTradeQueryer struct {
|
||||
once sync.Once
|
||||
client *alipay.Client
|
||||
clientErr error
|
||||
}
|
||||
|
||||
func newAlipayTradeQueryer() *alipayTradeQueryer {
|
||||
return &alipayTradeQueryer{}
|
||||
}
|
||||
|
||||
func (q *alipayTradeQueryer) Query(ctx context.Context, outTradeNo string) (*TradeQueryResult, error) {
|
||||
client, err := q.getClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsp, err := q.tradeQueryWithRetry(ctx, client, alipay.TradeQuery{OutTradeNo: outTradeNo})
|
||||
if err != nil {
|
||||
if isAlipayTradeNotFound(err.Error()) {
|
||||
return &TradeQueryResult{OutTradeNo: outTradeNo, TradeStatus: string(alipay.TradeStatusWaitBuyerPay)}, nil
|
||||
}
|
||||
return nil, gerror.Wrap(err, "query Alipay trade failed")
|
||||
}
|
||||
if rsp == nil {
|
||||
return nil, gerror.New("Alipay trade query returned no result")
|
||||
}
|
||||
if rsp.IsFailure() {
|
||||
if isAlipayTradeNotFound(string(rsp.Code), rsp.SubCode, rsp.SubMsg) {
|
||||
return &TradeQueryResult{OutTradeNo: outTradeNo, TradeStatus: string(alipay.TradeStatusWaitBuyerPay)}, nil
|
||||
}
|
||||
return nil, gerror.Newf("Alipay trade query failed: code=%s, subCode=%s, subMsg=%s", rsp.Code, rsp.SubCode, rsp.SubMsg)
|
||||
}
|
||||
return &TradeQueryResult{
|
||||
OutTradeNo: rsp.OutTradeNo,
|
||||
TradeNo: rsp.TradeNo,
|
||||
TradeStatus: string(rsp.TradeStatus),
|
||||
TotalAmount: rsp.TotalAmount,
|
||||
BuyerLogonID: rsp.BuyerLogonId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *alipayTradeQueryer) getClient(ctx context.Context) (*alipay.Client, error) {
|
||||
q.once.Do(func() {
|
||||
cfg := alipayConfig{}
|
||||
if err := g.Cfg().MustGet(ctx, "alipay").Scan(&cfg); err != nil {
|
||||
q.clientErr = gerror.Wrap(err, "read Alipay config failed")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(cfg.AppID) == "" || strings.TrimSpace(cfg.PrivateKey) == "" || strings.TrimSpace(cfg.AlipayPublicKey) == "" {
|
||||
q.clientErr = gerror.New("Alipay credentials are not configured")
|
||||
return
|
||||
}
|
||||
q.client, q.clientErr = alipay.New(cfg.AppID, cfg.PrivateKey, cfg.IsProduction, alipay.WithHTTPClient(&http.Client{Timeout: 10 * time.Second}))
|
||||
if q.clientErr != nil {
|
||||
q.clientErr = gerror.Wrap(q.clientErr, "initialize Alipay client failed")
|
||||
return
|
||||
}
|
||||
if err := q.client.LoadAliPayPublicKey(cfg.AlipayPublicKey); err != nil {
|
||||
q.clientErr = gerror.Wrap(err, "load Alipay public key failed")
|
||||
}
|
||||
})
|
||||
if q.clientErr != nil {
|
||||
return nil, q.clientErr
|
||||
}
|
||||
return q.client, nil
|
||||
}
|
||||
|
||||
func (q *alipayTradeQueryer) tradeQueryWithRetry(ctx context.Context, client *alipay.Client, query alipay.TradeQuery) (*alipay.TradeQueryRsp, error) {
|
||||
var err error
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
rsp, queryErr := client.TradeQuery(ctx, query)
|
||||
if queryErr == nil {
|
||||
return rsp, nil
|
||||
}
|
||||
err = queryErr
|
||||
if !isRetryableAlipayError(err) || attempt == 3 {
|
||||
return nil, err
|
||||
}
|
||||
if err = sleepWithContext(ctx, time.Duration(attempt)*300*time.Millisecond); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func isRetryableAlipayError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
for _, marker := range []string{"invalid character '<'", "unexpected end of json", "bad response", "eof", "timeout", "connection reset", "connection refused", "tls handshake", "temporary", "too many requests", "429", "500", "502", "503", "504"} {
|
||||
if strings.Contains(msg, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isAlipayTradeNotFound(parts ...string) bool {
|
||||
msg := strings.ToLower(strings.Join(parts, " "))
|
||||
return strings.Contains(msg, "acq.trade_not_exist") || strings.Contains(msg, "trade_not_exist") || strings.Contains(msg, "transaction not exist") || strings.Contains(msg, "交易不存在") || strings.Contains(msg, "订单不存在")
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package balance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
const (
|
||||
rechargeStatusPending = iota
|
||||
rechargeStatusPaid
|
||||
rechargeStatusExpired
|
||||
|
||||
rechargeChannelAlipay = 1
|
||||
)
|
||||
|
||||
type TradeQueryResult struct {
|
||||
OutTradeNo string
|
||||
TradeNo string
|
||||
TradeStatus string
|
||||
TotalAmount string
|
||||
BuyerLogonID string
|
||||
}
|
||||
|
||||
type TradeQueryer interface {
|
||||
Query(context.Context, string) (*TradeQueryResult, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
queryer TradeQueryer
|
||||
}
|
||||
|
||||
func New() *Service {
|
||||
return NewWithQuery(newAlipayTradeQueryer())
|
||||
}
|
||||
|
||||
func NewWithQuery(queryer TradeQueryer) *Service {
|
||||
return &Service{queryer: queryer}
|
||||
}
|
||||
|
||||
func (s *Service) CheckRechargeTimeout(ctx context.Context, outTradeNo string) error {
|
||||
outTradeNo = strings.TrimSpace(outTradeNo)
|
||||
if outTradeNo == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
detail := rechargeDetail{}
|
||||
if err := g.DB().Model("t_balance_detail").Ctx(ctx).
|
||||
Where("order_sn", outTradeNo).
|
||||
Where("transaction_channel", rechargeChannelAlipay).
|
||||
Scan(&detail); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return gerror.Wrap(err, "query recharge detail failed")
|
||||
}
|
||||
if detail.ID == 0 || detail.TransactionStatus != rechargeStatusPending {
|
||||
return nil
|
||||
}
|
||||
if s.queryer == nil {
|
||||
return gerror.New("alipay trade queryer is not configured")
|
||||
}
|
||||
|
||||
result, err := s.queryer.Query(ctx, outTradeNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result != nil && (result.TradeStatus == "TRADE_SUCCESS" || result.TradeStatus == "TRADE_FINISHED") {
|
||||
return s.creditRecharge(ctx, detail, result)
|
||||
}
|
||||
|
||||
_, err = g.DB().Model("t_balance_detail").Ctx(ctx).
|
||||
Where("id", detail.ID).
|
||||
Where("transaction_status", rechargeStatusPending).
|
||||
Data(g.Map{
|
||||
"transaction_status": rechargeStatusExpired,
|
||||
"remark": "Alipay recharge timed out without payment",
|
||||
"updated_at": gtime.Now(),
|
||||
}).
|
||||
Update()
|
||||
return gerror.Wrap(err, "expire recharge detail failed")
|
||||
}
|
||||
|
||||
func (s *Service) creditRecharge(ctx context.Context, initial rechargeDetail, result *TradeQueryResult) error {
|
||||
amount := gconv.Float64(strings.TrimSpace(result.TotalAmount))
|
||||
if amount <= 0 {
|
||||
return gerror.New("paid recharge has invalid amount")
|
||||
}
|
||||
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
detail := rechargeDetail{}
|
||||
detailModel := lockForUpdate(tx.Ctx(ctx).Model("t_balance_detail"))
|
||||
if err := detailModel.
|
||||
Where("id", initial.ID).
|
||||
Scan(&detail); err != nil {
|
||||
return gerror.Wrap(err, "lock recharge detail failed")
|
||||
}
|
||||
if detail.ID == 0 || detail.TransactionStatus == rechargeStatusPaid {
|
||||
return nil
|
||||
}
|
||||
if detail.TransactionStatus != rechargeStatusPending {
|
||||
return gerror.New("recharge order is closed")
|
||||
}
|
||||
if math.Abs(detail.Amount-amount) > 0.001 {
|
||||
return gerror.New("paid amount does not match recharge detail")
|
||||
}
|
||||
|
||||
account := agentBalance{}
|
||||
accountModel := lockForUpdate(tx.Ctx(ctx).Model("t_agent_balance"))
|
||||
if err := accountModel.
|
||||
Where("dept_id", detail.DeptID).
|
||||
Where("tenant_id", detail.TenantID).
|
||||
Scan(&account); err != nil {
|
||||
return gerror.Wrap(err, "lock agent balance failed")
|
||||
}
|
||||
now := gtime.Now()
|
||||
if account.ID == 0 {
|
||||
id, err := tx.Ctx(ctx).Model("t_agent_balance").Data(g.Map{
|
||||
"dept_id": detail.DeptID,
|
||||
"balance": 0,
|
||||
"withdraw_amount": 0,
|
||||
"withdraw_amount_status": 1,
|
||||
"invoice_amount": 0,
|
||||
"can_invoice_amount": 0,
|
||||
"credit_limit": 0,
|
||||
"used_credit": 0,
|
||||
"pending_repayment": 0,
|
||||
"status": 0,
|
||||
"tenant_id": detail.TenantID,
|
||||
"created_dept": detail.CreatedDept,
|
||||
"created_by": detail.CreatedBy,
|
||||
"created_at": now,
|
||||
}).InsertAndGetId()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "initialize agent balance failed")
|
||||
}
|
||||
account = agentBalance{ID: id}
|
||||
}
|
||||
|
||||
before := account.Balance
|
||||
after := before + detail.Amount
|
||||
if _, err := tx.Ctx(ctx).Model("t_agent_balance").Where("id", account.ID).Data(g.Map{
|
||||
"balance": after,
|
||||
"updated_by": detail.CreatedBy,
|
||||
"updated_at": now,
|
||||
}).Update(); err != nil {
|
||||
return gerror.Wrap(err, "update agent balance failed")
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model("t_balance_detail").Where("id", detail.ID).Data(g.Map{
|
||||
"transaction_status": rechargeStatusPaid,
|
||||
"transaction_time": now,
|
||||
"before_balance": before,
|
||||
"balance_after": after,
|
||||
"channel_serial_no": result.TradeNo,
|
||||
"remark": fmt.Sprintf("Alipay recharge, buyer: %s", result.BuyerLogonID),
|
||||
"updated_by": detail.CreatedBy,
|
||||
"updated_at": now,
|
||||
}).Update()
|
||||
return gerror.Wrap(err, "mark recharge paid failed")
|
||||
})
|
||||
}
|
||||
|
||||
type rechargeDetail struct {
|
||||
ID int64 `orm:"id"`
|
||||
DeptID int64 `orm:"dept_id"`
|
||||
TransactionStatus int `orm:"transaction_status"`
|
||||
Amount float64 `orm:"amount"`
|
||||
TenantID string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
}
|
||||
|
||||
type agentBalance struct {
|
||||
ID int64 `orm:"id"`
|
||||
Balance float64 `orm:"balance"`
|
||||
}
|
||||
|
||||
func lockForUpdate(model *gdb.Model) *gdb.Model {
|
||||
if g.DB().GetConfig().Type == "sqlite" {
|
||||
return model
|
||||
}
|
||||
return model.LockUpdate()
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package balance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type tradeQueryerStub struct {
|
||||
result *TradeQueryResult
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *tradeQueryerStub) Query(_ context.Context, _ string) (*TradeQueryResult, error) {
|
||||
s.calls++
|
||||
return s.result, s.err
|
||||
}
|
||||
|
||||
func TestCheckRechargeTimeout(t *testing.T) {
|
||||
g.Log().SetWriter(io.Discard)
|
||||
ctx := context.Background()
|
||||
if err := gdb.SetConfig(gdb.Config{
|
||||
"default": gdb.ConfigGroup{{Type: "sqlite", Name: filepath.Join(t.TempDir(), "balance.db")}},
|
||||
}); err != nil {
|
||||
t.Fatalf("configure test database: %v", err)
|
||||
}
|
||||
defer g.DB().Close(ctx)
|
||||
createBalanceTables(t, ctx)
|
||||
|
||||
t.Run("paid recharge is credited exactly once", func(t *testing.T) {
|
||||
insertRechargeDetail(t, ctx, "BAL-PAID", 10)
|
||||
insertAgentBalance(t, ctx, 11, "tenant-1", 5)
|
||||
queryer := &tradeQueryerStub{result: &TradeQueryResult{TradeStatus: "TRADE_SUCCESS", TotalAmount: "10.00", TradeNo: "ALI-1"}}
|
||||
service := NewWithQuery(queryer)
|
||||
|
||||
if err := service.CheckRechargeTimeout(ctx, "BAL-PAID"); err != nil {
|
||||
t.Fatalf("first check failed: %v", err)
|
||||
}
|
||||
if status := rechargeStatus(t, ctx, "BAL-PAID"); status != rechargeStatusPaid {
|
||||
t.Fatalf("expected paid status, got %d", status)
|
||||
}
|
||||
if balance := agentBalanceValue(t, ctx, 11, "tenant-1"); balance != 15 {
|
||||
t.Fatalf("expected balance 15, got %v", balance)
|
||||
}
|
||||
if err := service.CheckRechargeTimeout(ctx, "BAL-PAID"); err != nil {
|
||||
t.Fatalf("duplicate check failed: %v", err)
|
||||
}
|
||||
if queryer.calls != 1 {
|
||||
t.Fatalf("expected one Alipay query, got %d", queryer.calls)
|
||||
}
|
||||
if balance := agentBalanceValue(t, ctx, 11, "tenant-1"); balance != 15 {
|
||||
t.Fatalf("duplicate message changed balance to %v", balance)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unpaid recharge is expired", func(t *testing.T) {
|
||||
insertRechargeDetail(t, ctx, "BAL-EXPIRED", 10)
|
||||
service := NewWithQuery(&tradeQueryerStub{result: &TradeQueryResult{TradeStatus: "WAIT_BUYER_PAY"}})
|
||||
if err := service.CheckRechargeTimeout(ctx, "BAL-EXPIRED"); err != nil {
|
||||
t.Fatalf("timeout check failed: %v", err)
|
||||
}
|
||||
if status := rechargeStatus(t, ctx, "BAL-EXPIRED"); status != rechargeStatusExpired {
|
||||
t.Fatalf("expected expired status, got %d", status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing recharge is ignored", func(t *testing.T) {
|
||||
queryer := &tradeQueryerStub{err: errors.New("must not be called")}
|
||||
if err := NewWithQuery(queryer).CheckRechargeTimeout(ctx, "BAL-MISSING"); err != nil {
|
||||
t.Fatalf("missing recharge returned error: %v", err)
|
||||
}
|
||||
if queryer.calls != 0 {
|
||||
t.Fatalf("missing recharge queried Alipay %d times", queryer.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("amount mismatch returns an error without crediting", func(t *testing.T) {
|
||||
insertRechargeDetail(t, ctx, "BAL-MISMATCH", 10)
|
||||
insertAgentBalance(t, ctx, 11, "tenant-1", 15)
|
||||
service := NewWithQuery(&tradeQueryerStub{result: &TradeQueryResult{TradeStatus: "TRADE_SUCCESS", TotalAmount: "9.00"}})
|
||||
if err := service.CheckRechargeTimeout(ctx, "BAL-MISMATCH"); err == nil {
|
||||
t.Fatal("expected amount mismatch error")
|
||||
}
|
||||
if status := rechargeStatus(t, ctx, "BAL-MISMATCH"); status != rechargeStatusPending {
|
||||
t.Fatalf("mismatch changed status to %d", status)
|
||||
}
|
||||
if balance := agentBalanceValue(t, ctx, 11, "tenant-1"); balance != 15 {
|
||||
t.Fatalf("mismatch changed balance to %v", balance)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Alipay failure is returned for delayed retry", func(t *testing.T) {
|
||||
insertRechargeDetail(t, ctx, "BAL-RETRY", 10)
|
||||
service := NewWithQuery(&tradeQueryerStub{err: errors.New("Alipay unavailable")})
|
||||
if err := service.CheckRechargeTimeout(ctx, "BAL-RETRY"); err == nil {
|
||||
t.Fatal("expected query error")
|
||||
}
|
||||
if status := rechargeStatus(t, ctx, "BAL-RETRY"); status != rechargeStatusPending {
|
||||
t.Fatalf("retryable failure changed status to %d", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func createBalanceTables(t *testing.T, ctx context.Context) {
|
||||
t.Helper()
|
||||
statements := []string{
|
||||
`CREATE TABLE t_balance_detail (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dept_id INTEGER NOT NULL,
|
||||
transaction_status INTEGER NOT NULL,
|
||||
transaction_channel INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
order_sn TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
created_dept INTEGER NOT NULL,
|
||||
created_by INTEGER NOT NULL,
|
||||
transaction_time DATETIME,
|
||||
before_balance REAL,
|
||||
balance_after REAL,
|
||||
channel_serial_no TEXT,
|
||||
remark TEXT,
|
||||
updated_by INTEGER,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE t_agent_balance (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dept_id INTEGER NOT NULL,
|
||||
balance REAL NOT NULL,
|
||||
withdraw_amount REAL,
|
||||
withdraw_amount_status INTEGER,
|
||||
invoice_amount REAL,
|
||||
can_invoice_amount REAL,
|
||||
credit_limit REAL,
|
||||
used_credit REAL,
|
||||
pending_repayment REAL,
|
||||
status INTEGER,
|
||||
tenant_id TEXT NOT NULL,
|
||||
created_dept INTEGER,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME,
|
||||
updated_by INTEGER,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func insertRechargeDetail(t *testing.T, ctx context.Context, orderSN string, amount float64) {
|
||||
t.Helper()
|
||||
if _, err := g.DB().Model("t_balance_detail").Ctx(ctx).Data(g.Map{
|
||||
"dept_id": 11,
|
||||
"transaction_status": rechargeStatusPending,
|
||||
"transaction_channel": rechargeChannelAlipay,
|
||||
"amount": amount,
|
||||
"order_sn": orderSN,
|
||||
"tenant_id": "tenant-1",
|
||||
"created_dept": 11,
|
||||
"created_by": 7,
|
||||
}).Insert(); err != nil {
|
||||
t.Fatalf("insert recharge detail: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertAgentBalance(t *testing.T, ctx context.Context, deptID int64, tenantID string, balance float64) {
|
||||
t.Helper()
|
||||
if _, err := g.DB().Model("t_agent_balance").Ctx(ctx).Data(g.Map{"dept_id": deptID, "tenant_id": tenantID, "balance": balance}).Insert(); err != nil {
|
||||
t.Fatalf("insert agent balance: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func rechargeStatus(t *testing.T, ctx context.Context, orderSN string) int {
|
||||
t.Helper()
|
||||
value, err := g.DB().Model("t_balance_detail").Ctx(ctx).Where("order_sn", orderSN).Value("transaction_status")
|
||||
if err != nil {
|
||||
t.Fatalf("get recharge status: %v", err)
|
||||
}
|
||||
return value.Int()
|
||||
}
|
||||
|
||||
func agentBalanceValue(t *testing.T, ctx context.Context, deptID int64, tenantID string) float64 {
|
||||
t.Helper()
|
||||
value, err := g.DB().Model("t_agent_balance").Ctx(ctx).Where("dept_id", deptID).Where("tenant_id", tenantID).Value("balance")
|
||||
if err != nil {
|
||||
t.Fatalf("get agent balance: %v", err)
|
||||
}
|
||||
return value.Float64()
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
package batchresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
const configOssURLPathKey = "sys.oss.urlPath"
|
||||
|
||||
type Row struct {
|
||||
RowNo int
|
||||
ObjectCode string
|
||||
TargetCode string
|
||||
PackageCode string
|
||||
EffectiveMode string
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
TargetDeptName string
|
||||
StatusBefore int
|
||||
TargetStatus int
|
||||
Operation string
|
||||
ApnName string
|
||||
DetailStatus int
|
||||
Success bool
|
||||
Status string
|
||||
Reason string
|
||||
UpstreamCode int
|
||||
OrderNum string
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
TenantID string
|
||||
Rows []Row
|
||||
}
|
||||
|
||||
type Artifact struct {
|
||||
FileName string
|
||||
OriginalName string
|
||||
FileSuffix string
|
||||
Path string
|
||||
LocalPath string
|
||||
URL string
|
||||
Size int64
|
||||
MD5 string
|
||||
}
|
||||
|
||||
type Generator interface {
|
||||
Create(ctx context.Context, request Request) (Artifact, error)
|
||||
}
|
||||
|
||||
type generator struct{}
|
||||
|
||||
func New() Generator {
|
||||
return &generator{}
|
||||
}
|
||||
|
||||
func (gtr *generator) Create(ctx context.Context, request Request) (Artifact, error) {
|
||||
if request.BatchNo == "" || filepath.Base(request.BatchNo) != request.BatchNo {
|
||||
return Artifact{}, gerror.New("invalid batch number")
|
||||
}
|
||||
uploadPath := g.Cfg().MustGet(ctx, "system.upload.local.path").String()
|
||||
if uploadPath == "" {
|
||||
return Artifact{}, gerror.New("system.upload.local.path is empty")
|
||||
}
|
||||
tenantID := request.TenantID
|
||||
if tenantID == "" {
|
||||
tenantID = "default"
|
||||
}
|
||||
resultDir, err := resultDir(request.BatchType)
|
||||
if err != nil {
|
||||
return Artifact{}, err
|
||||
}
|
||||
objectDir := filepath.Join(tenantID, "user", "file", time.Now().Format("20060102"), resultDir)
|
||||
physicalUploadPath := resultPhysicalUploadRoot(uploadPath)
|
||||
directory := filepath.Join(physicalUploadPath, objectDir)
|
||||
if err = os.MkdirAll(directory, 0o755); err != nil {
|
||||
return Artifact{}, gerror.Wrap(err, "create batch result directory")
|
||||
}
|
||||
originalName := request.BatchNo + "-result.xlsx"
|
||||
var nonce [16]byte
|
||||
if _, err = rand.Read(nonce[:]); err != nil {
|
||||
return Artifact{}, gerror.Wrap(err, "generate batch result file name")
|
||||
}
|
||||
fileName := request.BatchNo + "-result-" + hex.EncodeToString(nonce[:]) + ".xlsx"
|
||||
filePath := filepath.Join(directory, fileName)
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o666)
|
||||
if err != nil {
|
||||
return Artifact{}, gerror.Wrap(err, "reserve batch result file")
|
||||
}
|
||||
if err = file.Close(); err != nil {
|
||||
_ = os.Remove(filePath)
|
||||
return Artifact{}, gerror.Wrap(err, "close reserved result file")
|
||||
}
|
||||
logicalPath := filepath.Join(uploadPath, objectDir, fileName)
|
||||
if err = writeWorkbook(filePath, request); err != nil {
|
||||
_ = os.Remove(filePath)
|
||||
return Artifact{}, err
|
||||
}
|
||||
artifact, err := inspectArtifact(ctx, filePath, logicalPath, fileName, originalName, tenantID)
|
||||
if err != nil {
|
||||
_ = os.Remove(filePath)
|
||||
return Artifact{}, err
|
||||
}
|
||||
return artifact, nil
|
||||
}
|
||||
|
||||
func resultPhysicalUploadRoot(uploadPath string) string {
|
||||
uploadPath = strings.TrimSpace(uploadPath)
|
||||
for _, envName := range []string{"IOTLCM_WORKER_UPLOAD_ROOT", "IOTLCM_UPLOAD_ROOT"} {
|
||||
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for _, candidate := range uploadRootEquivalentCandidates(uploadPath) {
|
||||
if samePath(candidate, uploadPath) {
|
||||
continue
|
||||
}
|
||||
for _, resolved := range resolvedRootCandidates(candidate) {
|
||||
if info, err := os.Stat(resolved); err == nil && info.IsDir() {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
return uploadPath
|
||||
}
|
||||
|
||||
func uploadRootEquivalentCandidates(root string) []string {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return nil
|
||||
}
|
||||
candidates := []string{root}
|
||||
normalized := filepath.ToSlash(filepath.Clean(root))
|
||||
if normalized == "resource/upload" || strings.HasSuffix(normalized, "/resource/upload") {
|
||||
prefix := strings.TrimSuffix(normalized, "resource/upload")
|
||||
candidates = appendUniqueString(candidates, filepath.FromSlash(prefix+"upload"))
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func resolvedRootCandidates(root string) []string {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return nil
|
||||
}
|
||||
if filepath.IsAbs(root) {
|
||||
return []string{root}
|
||||
}
|
||||
candidates := []string{root}
|
||||
for _, base := range localBaseDirs() {
|
||||
candidates = appendUniqueString(candidates, filepath.Join(base, root))
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func localBaseDirs() []string {
|
||||
var dirs []string
|
||||
if wd, err := os.Getwd(); err == nil && strings.TrimSpace(wd) != "" {
|
||||
dirs = appendUniqueString(dirs, wd)
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil && strings.TrimSpace(exe) != "" {
|
||||
dirs = appendUniqueString(dirs, filepath.Dir(exe))
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
func samePath(a, b string) bool {
|
||||
return filepath.Clean(strings.TrimSpace(a)) == filepath.Clean(strings.TrimSpace(b))
|
||||
}
|
||||
|
||||
func appendUniqueString(values []string, value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return values
|
||||
}
|
||||
for _, existing := range values {
|
||||
if samePath(existing, value) {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
|
||||
func resultDir(batchType int) (string, error) {
|
||||
if batchType == 11 {
|
||||
return filepath.Join("sim-card-add-package", "result"), nil
|
||||
}
|
||||
if batchType == 12 {
|
||||
return filepath.Join("sim-pool-add-package", "result"), nil
|
||||
}
|
||||
switch batchType {
|
||||
case 1:
|
||||
return filepath.Join("sim-card-inbound", "result"), nil
|
||||
case 3:
|
||||
return filepath.Join("sim-card-allocate", "result"), nil
|
||||
case 4:
|
||||
return filepath.Join("sim-card-renew-change-prepare", "result"), nil
|
||||
case 101:
|
||||
return filepath.Join("sim-card-renew", "result"), nil
|
||||
case 102:
|
||||
return filepath.Join("sim-card-change", "result"), nil
|
||||
case 103:
|
||||
return filepath.Join("sim-card-start", "result"), nil
|
||||
case 104:
|
||||
return filepath.Join("sim-card-stop", "result"), nil
|
||||
case 105:
|
||||
return filepath.Join("sim-card-restart", "result"), nil
|
||||
case 106:
|
||||
return filepath.Join("sim-card-cancel", "result"), nil
|
||||
case 107:
|
||||
return filepath.Join("sim-card-data-cut", "result"), nil
|
||||
case 108:
|
||||
return filepath.Join("sim-card-unsub", "result"), nil
|
||||
case 109:
|
||||
return filepath.Join("sim-card-data-start", "result"), nil
|
||||
case 110:
|
||||
return filepath.Join("sim-card-data-stop", "result"), nil
|
||||
case 40:
|
||||
return filepath.Join("sim-warning-config", "result"), nil
|
||||
case 41:
|
||||
return filepath.Join("sim-card-traffic-cutoff-config", "result"), nil
|
||||
default:
|
||||
return "", gerror.Newf("unsupported batch type %d", batchType)
|
||||
}
|
||||
}
|
||||
|
||||
func writeWorkbook(path string, request Request) error {
|
||||
file := excelize.NewFile()
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
const resultSheet = "处理结果"
|
||||
defaultSheet := file.GetSheetName(0)
|
||||
if err := file.SetSheetName(defaultSheet, resultSheet); err != nil {
|
||||
return gerror.Wrap(err, "name result sheet")
|
||||
}
|
||||
if request.BatchType == 1 {
|
||||
return writeInboundWorkbook(file, resultSheet, path, request)
|
||||
}
|
||||
if request.BatchType == 3 {
|
||||
return writeAllocateWorkbook(file, resultSheet, path, request)
|
||||
}
|
||||
if request.BatchType == 11 || request.BatchType == 12 {
|
||||
return writeAddPackageWorkbook(file, resultSheet, path, request)
|
||||
}
|
||||
if request.BatchType == 106 {
|
||||
return writeCancelWorkbook(file, resultSheet, path, request)
|
||||
}
|
||||
if request.BatchType == 40 || request.BatchType == 41 {
|
||||
return writeWarningConfigWorkbook(file, resultSheet, path, request)
|
||||
}
|
||||
if isDataServiceBatch(request.BatchType) {
|
||||
return writeDataServiceWorkbook(file, resultSheet, path, request)
|
||||
}
|
||||
return writeLifecycleWorkbook(file, resultSheet, path, request)
|
||||
}
|
||||
|
||||
func writeAllocateWorkbook(file *excelize.File, sheet, path string, request Request) error {
|
||||
headers := []interface{}{"行号", "ICCID", "MSISDN", "IMSI", "目标客户", "执行结果", "失败原因"}
|
||||
if err := file.SetSheetRow(sheet, "A1", &headers); err != nil {
|
||||
return gerror.Wrap(err, "write allocate result headers")
|
||||
}
|
||||
for index, row := range request.Rows {
|
||||
status := "失败"
|
||||
if row.Success {
|
||||
status = "成功"
|
||||
}
|
||||
if row.Status != "" {
|
||||
status = row.Status
|
||||
}
|
||||
values := []interface{}{row.RowNo, row.ICCID, row.MSISDN, row.IMSI, row.TargetDeptName, status, row.Reason}
|
||||
cell, err := excelize.CoordinatesToCellName(1, index+2)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "locate allocate result row")
|
||||
}
|
||||
if err = file.SetSheetRow(sheet, cell, &values); err != nil {
|
||||
return gerror.Wrap(err, "write allocate result row")
|
||||
}
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "A", "A", 10); err != nil {
|
||||
return gerror.Wrap(err, "format allocate result workbook")
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "B", "E", 24); err != nil {
|
||||
return gerror.Wrap(err, "format allocate result workbook")
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "F", "G", 24); err != nil {
|
||||
return gerror.Wrap(err, "format allocate result workbook")
|
||||
}
|
||||
if err := file.SaveAs(path); err != nil {
|
||||
return gerror.Wrap(err, "save allocate result workbook")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeWarningConfigWorkbook(file *excelize.File, sheet, path string, request Request) error {
|
||||
headers := []interface{}{"行号", "对象编码", "执行结果", "失败原因"}
|
||||
if err := file.SetSheetRow(sheet, "A1", &headers); err != nil {
|
||||
return gerror.Wrap(err, "write warning config result headers")
|
||||
}
|
||||
for index, row := range request.Rows {
|
||||
status := "失败"
|
||||
if row.Success {
|
||||
status = "成功"
|
||||
}
|
||||
objectCode := row.ObjectCode
|
||||
if objectCode == "" {
|
||||
objectCode = row.ICCID
|
||||
}
|
||||
values := []interface{}{row.RowNo, objectCode, status, row.Reason}
|
||||
cell, err := excelize.CoordinatesToCellName(1, index+2)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "locate warning config result row")
|
||||
}
|
||||
if err = file.SetSheetRow(sheet, cell, &values); err != nil {
|
||||
return gerror.Wrap(err, "write warning config result row")
|
||||
}
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "A", "A", 10); err != nil {
|
||||
return gerror.Wrap(err, "format warning config result workbook")
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "B", "B", 28); err != nil {
|
||||
return gerror.Wrap(err, "format warning config result workbook")
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "C", "D", 24); err != nil {
|
||||
return gerror.Wrap(err, "format warning config result workbook")
|
||||
}
|
||||
if err := file.SaveAs(path); err != nil {
|
||||
return gerror.Wrap(err, "save warning config result workbook")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAddPackageWorkbook(file *excelize.File, sheet, path string, request Request) error {
|
||||
headers := []interface{}{"行号", "目标编码", "加油包编码", "生效方式", "执行结果", "失败原因", "订单号"}
|
||||
if err := file.SetSheetRow(sheet, "A1", &headers); err != nil {
|
||||
return gerror.Wrap(err, "write add package result headers")
|
||||
}
|
||||
for index, row := range request.Rows {
|
||||
status := "失败"
|
||||
if row.Success {
|
||||
status = "成功"
|
||||
}
|
||||
targetCode := row.ObjectCode
|
||||
if targetCode == "" {
|
||||
targetCode = row.TargetCode
|
||||
}
|
||||
values := []interface{}{row.RowNo, targetCode, row.PackageCode, addPackageEffectiveModeText(row.EffectiveMode), status, row.Reason, row.OrderNum}
|
||||
cell, err := excelize.CoordinatesToCellName(1, index+2)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "locate add package result row")
|
||||
}
|
||||
if err = file.SetSheetRow(sheet, cell, &values); err != nil {
|
||||
return gerror.Wrap(err, "write add package result row")
|
||||
}
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "A", "A", 10); err != nil {
|
||||
return gerror.Wrap(err, "format add package result workbook")
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "B", "D", 24); err != nil {
|
||||
return gerror.Wrap(err, "format add package result workbook")
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "E", "G", 22); err != nil {
|
||||
return gerror.Wrap(err, "format add package result workbook")
|
||||
}
|
||||
if err := file.SaveAs(path); err != nil {
|
||||
return gerror.Wrap(err, "save add package result workbook")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addPackageEffectiveModeText(mode string) string {
|
||||
switch strings.TrimSpace(mode) {
|
||||
case "", "current_month", "immediate", "this_month", "本月生效", "本月", "当月":
|
||||
return "本月生效"
|
||||
case "next_month", "nextMonth", "次月生效", "次月", "下月", "下月生效":
|
||||
return "次月生效"
|
||||
default:
|
||||
return strings.TrimSpace(mode)
|
||||
}
|
||||
}
|
||||
|
||||
func isDataServiceBatch(batchType int) bool {
|
||||
return batchType == 107 || batchType == 109 || batchType == 110
|
||||
}
|
||||
|
||||
func writeInboundWorkbook(file *excelize.File, sheet, path string, request Request) error {
|
||||
headers := []interface{}{"行号", "ICCID", "执行结果", "失败原因"}
|
||||
if err := file.SetSheetRow(sheet, "A1", &headers); err != nil {
|
||||
return gerror.Wrap(err, "write inbound result headers")
|
||||
}
|
||||
for index, row := range request.Rows {
|
||||
status := "失败"
|
||||
if row.Success {
|
||||
status = "成功"
|
||||
}
|
||||
if row.Status != "" {
|
||||
status = row.Status
|
||||
}
|
||||
values := []interface{}{row.RowNo, row.ICCID, status, row.Reason}
|
||||
cell, err := excelize.CoordinatesToCellName(1, index+2)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "locate inbound result row")
|
||||
}
|
||||
if err = file.SetSheetRow(sheet, cell, &values); err != nil {
|
||||
return gerror.Wrap(err, "write inbound result row")
|
||||
}
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "A", "A", 10); err != nil {
|
||||
return gerror.Wrap(err, "format inbound result workbook")
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "B", "B", 24); err != nil {
|
||||
return gerror.Wrap(err, "format inbound result workbook")
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "C", "D", 24); err != nil {
|
||||
return gerror.Wrap(err, "format inbound result workbook")
|
||||
}
|
||||
if err := file.SaveAs(path); err != nil {
|
||||
return gerror.Wrap(err, "save inbound result workbook")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeLifecycleWorkbook(file *excelize.File, sheet, path string, request Request) error {
|
||||
headers := []interface{}{"行号", "ICCID", "MSISDN", "IMSI", "执行结果", "失败原因", "上游状态码", "上游订单号"}
|
||||
if err := file.SetSheetRow(sheet, "A1", &headers); err != nil {
|
||||
return gerror.Wrap(err, "write lifecycle result headers")
|
||||
}
|
||||
for index, row := range request.Rows {
|
||||
status := "失败"
|
||||
if row.Success {
|
||||
status = "成功"
|
||||
}
|
||||
values := []interface{}{row.RowNo, row.ICCID, row.MSISDN, row.IMSI, status, row.Reason, row.UpstreamCode, row.OrderNum}
|
||||
cell, err := excelize.CoordinatesToCellName(1, index+2)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "locate lifecycle result row")
|
||||
}
|
||||
if err = file.SetSheetRow(sheet, cell, &values); err != nil {
|
||||
return gerror.Wrap(err, "write lifecycle result row")
|
||||
}
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "A", "A", 10); err != nil {
|
||||
return gerror.Wrap(err, "format lifecycle result workbook")
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "B", "D", 24); err != nil {
|
||||
return gerror.Wrap(err, "format lifecycle result workbook")
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "E", "H", 20); err != nil {
|
||||
return gerror.Wrap(err, "format lifecycle result workbook")
|
||||
}
|
||||
if err := file.SaveAs(path); err != nil {
|
||||
return gerror.Wrap(err, "save lifecycle result workbook")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeCancelWorkbook(file *excelize.File, sheet, path string, request Request) error {
|
||||
headers := []interface{}{"行号", "ICCID", "MSISDN", "IMSI", "原卡状态", "目标卡状态", "执行结果", "失败原因"}
|
||||
if err := file.SetSheetRow(sheet, "A1", &headers); err != nil {
|
||||
return gerror.Wrap(err, "write cancel result headers")
|
||||
}
|
||||
for index, row := range request.Rows {
|
||||
status := "失败"
|
||||
if row.Success {
|
||||
status = "成功"
|
||||
}
|
||||
values := []interface{}{row.RowNo, row.ICCID, row.MSISDN, row.IMSI, row.StatusBefore, row.TargetStatus, status, row.Reason}
|
||||
cell, err := excelize.CoordinatesToCellName(1, index+2)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "locate cancel result row")
|
||||
}
|
||||
if err = file.SetSheetRow(sheet, cell, &values); err != nil {
|
||||
return gerror.Wrap(err, "write cancel result row")
|
||||
}
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "A", "A", 10); err != nil {
|
||||
return gerror.Wrap(err, "format cancel result workbook")
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "B", "D", 24); err != nil {
|
||||
return gerror.Wrap(err, "format cancel result workbook")
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "E", "H", 20); err != nil {
|
||||
return gerror.Wrap(err, "format cancel result workbook")
|
||||
}
|
||||
if err := file.SaveAs(path); err != nil {
|
||||
return gerror.Wrap(err, "save cancel result workbook")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeDataServiceWorkbook(file *excelize.File, sheet, path string, request Request) error {
|
||||
headers := []interface{}{"行号", "ICCID", "MSISDN", "IMSI", "操作", "APN", "执行结果", "失败/待核查原因", "上游状态码", "上游订单号"}
|
||||
if err := file.SetSheetRow(sheet, "A1", &headers); err != nil {
|
||||
return gerror.Wrap(err, "write data service result headers")
|
||||
}
|
||||
for index, row := range request.Rows {
|
||||
status := "失败"
|
||||
if row.DetailStatus == consts.BatchDetailStatusUnknown {
|
||||
status = "待核查"
|
||||
} else if row.Success {
|
||||
status = "成功"
|
||||
}
|
||||
values := []interface{}{row.RowNo, row.ICCID, row.MSISDN, row.IMSI, row.Operation, row.ApnName, status, row.Reason, row.UpstreamCode, row.OrderNum}
|
||||
cell, err := excelize.CoordinatesToCellName(1, index+2)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "locate data service result row")
|
||||
}
|
||||
if err = file.SetSheetRow(sheet, cell, &values); err != nil {
|
||||
return gerror.Wrap(err, "write data service result row")
|
||||
}
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "A", "A", 10); err != nil {
|
||||
return gerror.Wrap(err, "format data service result workbook")
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "B", "D", 24); err != nil {
|
||||
return gerror.Wrap(err, "format data service result workbook")
|
||||
}
|
||||
if err := file.SetColWidth(sheet, "E", "J", 20); err != nil {
|
||||
return gerror.Wrap(err, "format data service result workbook")
|
||||
}
|
||||
if err := file.SaveAs(path); err != nil {
|
||||
return gerror.Wrap(err, "save data service result workbook")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inspectArtifact(ctx context.Context, localPath, logicalPath, fileName, originalName, tenantID string) (Artifact, error) {
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return Artifact{}, gerror.Wrap(err, "open result file")
|
||||
}
|
||||
defer file.Close()
|
||||
hash := md5.New()
|
||||
size, err := io.Copy(hash, file)
|
||||
if err != nil {
|
||||
return Artifact{}, gerror.Wrap(err, "hash result file")
|
||||
}
|
||||
return Artifact{
|
||||
FileName: fileName,
|
||||
OriginalName: originalName,
|
||||
FileSuffix: "xlsx",
|
||||
Path: logicalPath,
|
||||
LocalPath: localPath,
|
||||
URL: localUploadURL(ctx, logicalPath, tenantID),
|
||||
Size: size,
|
||||
MD5: hex.EncodeToString(hash.Sum(nil)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func RemoveLocalFile(artifact Artifact) {
|
||||
path := strings.TrimSpace(artifact.LocalPath)
|
||||
if path == "" {
|
||||
path = strings.TrimSpace(artifact.Path)
|
||||
}
|
||||
if path != "" {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
func localUploadURL(ctx context.Context, filePath string, tenantID string) string {
|
||||
filePath = strings.TrimSpace(filepath.ToSlash(filepath.Clean(filePath)))
|
||||
if filePath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if configValue := loadUploadURLPathConfig(ctx, tenantID); configValue != "" {
|
||||
if strings.Contains(configValue, "|") {
|
||||
split := strings.SplitN(configValue, "|", 2)
|
||||
from := strings.TrimSpace(filepath.ToSlash(split[0]))
|
||||
to := strings.TrimSpace(filepath.ToSlash(split[1]))
|
||||
if suffix, ok := localUploadMappedSuffix(filePath, from); ok {
|
||||
return joinUploadURL(to, suffix)
|
||||
}
|
||||
} else {
|
||||
if suffix := localUploadRelativePath(filePath); suffix != "" {
|
||||
return joinUploadURL(configValue, suffix)
|
||||
}
|
||||
return joinUploadURL(configValue, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
if suffix := localUploadRelativePath(filePath); suffix != "" {
|
||||
return joinUploadURL("/upload", suffix)
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
func loadUploadURLPathConfig(ctx context.Context, tenantID string) (configValue string) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
configValue = ""
|
||||
}
|
||||
}()
|
||||
for _, tenant := range []string{strings.TrimSpace(tenantID), "000000"} {
|
||||
if tenant == "" {
|
||||
continue
|
||||
}
|
||||
var row struct {
|
||||
ConfigValue string `orm:"config_value"`
|
||||
}
|
||||
err := g.DB().Model("sys_config").Safe().Ctx(ctx).
|
||||
Fields("config_value").
|
||||
Where("tenant_id", tenant).
|
||||
Where("config_key", configOssURLPathKey).
|
||||
Limit(1).
|
||||
Scan(&row)
|
||||
if err == nil && strings.TrimSpace(row.ConfigValue) != "" {
|
||||
return strings.TrimSpace(row.ConfigValue)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func localUploadMappedSuffix(filePath string, from string) (string, bool) {
|
||||
from = strings.Trim(strings.TrimSpace(filepath.ToSlash(from)), "/")
|
||||
if from == "" {
|
||||
return "", false
|
||||
}
|
||||
normalized := strings.TrimSpace(filepath.ToSlash(filePath))
|
||||
marker := "/" + from + "/"
|
||||
if idx := strings.Index(normalized, marker); idx >= 0 {
|
||||
return strings.TrimPrefix(normalized[idx+len(marker):], "/"), true
|
||||
}
|
||||
prefix := from + "/"
|
||||
if strings.HasPrefix(normalized, prefix) {
|
||||
return strings.TrimPrefix(normalized[len(prefix):], "/"), true
|
||||
}
|
||||
if strings.Trim(normalized, "/") == from {
|
||||
return "", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func localUploadRelativePath(filePath string) string {
|
||||
normalized := filepath.ToSlash(filepath.Clean(strings.TrimSpace(filePath)))
|
||||
for _, marker := range []string{
|
||||
"resource/upload/",
|
||||
"/resource/upload/",
|
||||
"/app/resource/upload/",
|
||||
"/app/upload/",
|
||||
"upload/",
|
||||
"/upload/",
|
||||
} {
|
||||
if idx := strings.Index(normalized, marker); idx >= 0 {
|
||||
return strings.TrimPrefix(normalized[idx+len(marker):], "/")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func joinUploadURL(base string, suffix string) string {
|
||||
base = strings.TrimRight(strings.TrimSpace(filepath.ToSlash(base)), "/")
|
||||
suffix = strings.TrimLeft(strings.TrimSpace(filepath.ToSlash(suffix)), "/")
|
||||
if base == "" {
|
||||
base = "/upload"
|
||||
}
|
||||
if suffix == "" {
|
||||
return base
|
||||
}
|
||||
return base + "/" + suffix
|
||||
}
|
||||
|
||||
func TaskName(batchType int) string {
|
||||
if batchType == 11 {
|
||||
return "单卡加油包结果"
|
||||
}
|
||||
if batchType == 12 {
|
||||
return "流量池加油包结果"
|
||||
}
|
||||
switch batchType {
|
||||
case 1:
|
||||
return "SIM卡批量入库结果"
|
||||
case 3:
|
||||
return "卡划拨结果"
|
||||
case 4:
|
||||
return "卡续费/变更预处理结果"
|
||||
case 101:
|
||||
return "卡续费结果"
|
||||
case 102:
|
||||
return "卡变更套餐结果"
|
||||
case 103:
|
||||
return "SIM卡批量复机结果"
|
||||
case 104:
|
||||
return "SIM卡批量停机结果"
|
||||
case 105:
|
||||
return "SIM卡批量重启结果"
|
||||
case 106:
|
||||
return "卡销户结果"
|
||||
case 108:
|
||||
return "SIM卡批量退订结果"
|
||||
case 107:
|
||||
return "SIM卡达量断网结果"
|
||||
case 109:
|
||||
return "卡开数据结果"
|
||||
case 110:
|
||||
return "卡停数据结果"
|
||||
case 40:
|
||||
return "SIM卡预警配置结果"
|
||||
case 41:
|
||||
return "达量断网配置结果"
|
||||
default:
|
||||
return fmt.Sprintf("SIM卡批量任务%d结果", batchType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package batchresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestWriteDataServiceWorkbookPreservesReviewStatusAndReason(t *testing.T) {
|
||||
for _, batchType := range []int{consts.BatchTypeDataCut, consts.BatchTypeDataStart, consts.BatchTypeDataStop} {
|
||||
t.Run(TaskName(batchType), func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "result.xlsx")
|
||||
rows := []Row{
|
||||
{RowNo: 1, ICCID: "test-success", DetailStatus: consts.BatchDetailStatusSuccess, Success: true},
|
||||
{RowNo: 2, ICCID: "test-failed", DetailStatus: consts.BatchDetailStatusFailed, Reason: "上游明确拒绝"},
|
||||
{RowNo: 3, ICCID: "test-unknown", DetailStatus: consts.BatchDetailStatusUnknown, Reason: "查询超时,尚未确认上游执行结果", UpstreamCode: 202, OrderNum: "test-job-id"},
|
||||
{RowNo: 4, ICCID: "test-review-priority", DetailStatus: consts.BatchDetailStatusUnknown, Success: true, Reason: "明细仍待核查"},
|
||||
{RowNo: 5, ICCID: "test-legacy-success", Success: true},
|
||||
{RowNo: 6, ICCID: "test-legacy-failed", Reason: "未受理"},
|
||||
}
|
||||
if err := writeWorkbook(path, Request{BatchType: batchType, Rows: rows}); err != nil {
|
||||
t.Fatalf("写入数据服务结果失败:%v", err)
|
||||
}
|
||||
file, err := excelize.OpenFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("打开数据服务结果失败:%v", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
for cell, want := range map[string]string{
|
||||
"G1": "执行结果", "H1": "失败/待核查原因",
|
||||
"G2": "成功", "G3": "失败", "H3": "上游明确拒绝",
|
||||
"G4": "待核查", "H4": rows[2].Reason, "I4": "202", "J4": "test-job-id",
|
||||
"G5": "待核查", "H5": rows[3].Reason,
|
||||
"G6": "成功", "G7": "失败", "H7": "未受理",
|
||||
} {
|
||||
got, err := file.GetCellValue("处理结果", cell)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("单元格 %s = %q,预期 %q,错误:%v", cell, got, want, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSameBatchUsesIndependentResultFiles(t *testing.T) {
|
||||
// Keep the generated workbook path below Excel's 207-character limit on Windows.
|
||||
root, rootErr := os.MkdirTemp("", "batch-result-")
|
||||
if rootErr != nil {
|
||||
t.Fatal(rootErr)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
t.Errorf("remove test result directory: %v", err)
|
||||
}
|
||||
})
|
||||
t.Setenv("IOTLCM_WORKER_UPLOAD_ROOT", root)
|
||||
oldAdapter := g.Cfg().GetAdapter()
|
||||
adapter, err := gcfg.NewAdapterContent(`
|
||||
system:
|
||||
upload:
|
||||
local:
|
||||
path: "resource/upload"
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(oldAdapter) })
|
||||
request := Request{BatchNo: "DATA-REVIEW", BatchType: consts.BatchTypeDataStart, TenantID: "tenant", Rows: []Row{{RowNo: 1, ICCID: "first-card", DetailStatus: consts.BatchDetailStatusUnknown, Reason: "待核查"}}}
|
||||
requests := []Request{request, request}
|
||||
requests[1].Rows = []Row{{RowNo: 1, ICCID: "second-card", DetailStatus: consts.BatchDetailStatusUnknown, Reason: "待核查"}}
|
||||
artifacts := make([]Artifact, 2)
|
||||
errors := make([]error, 2)
|
||||
var workers sync.WaitGroup
|
||||
for index := range requests {
|
||||
workers.Add(1)
|
||||
go func(index int) {
|
||||
defer workers.Done()
|
||||
artifacts[index], errors[index] = New().Create(context.Background(), requests[index])
|
||||
}(index)
|
||||
}
|
||||
workers.Wait()
|
||||
for _, err := range errors {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
first, second := artifacts[0], artifacts[1]
|
||||
defer RemoveLocalFile(first)
|
||||
defer RemoveLocalFile(second)
|
||||
if first.Path == second.Path || first.LocalPath == second.LocalPath || first.FileName == second.FileName {
|
||||
t.Fatalf("同批结果物理文件冲突: first=%+v second=%+v", first, second)
|
||||
}
|
||||
if first.OriginalName != "DATA-REVIEW-result.xlsx" || second.OriginalName != first.OriginalName {
|
||||
t.Fatalf("用户下载原始文件名被更改: %q / %q", first.OriginalName, second.OriginalName)
|
||||
}
|
||||
RemoveLocalFile(first)
|
||||
if _, err := os.Stat(first.LocalPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("首次生成文件未删除: %v", err)
|
||||
}
|
||||
file, err := excelize.OpenFile(second.LocalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("删除第一份后第二份不可读: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
got, err := file.GetCellValue("处理结果", "B2")
|
||||
if err != nil || got != "second-card" {
|
||||
t.Fatalf("第二份结果内容损坏: B2=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePreservesWorkbookAndPermissionsForAllBatchTypes(t *testing.T) {
|
||||
root, err := os.MkdirTemp("", "batchresult-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
||||
t.Setenv("IOTLCM_WORKER_UPLOAD_ROOT", root)
|
||||
oldAdapter := g.Cfg().GetAdapter()
|
||||
adapter, err := gcfg.NewAdapterContent(`
|
||||
system:
|
||||
upload:
|
||||
local:
|
||||
path: "resource/upload"
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(oldAdapter) })
|
||||
for _, batchType := range []int{1, 3, 4, 11, 12, 40, 41, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110} {
|
||||
t.Run(strconv.Itoa(batchType), func(t *testing.T) {
|
||||
request := Request{BatchNo: "COMPAT-" + strconv.Itoa(batchType), BatchType: batchType, TenantID: "tenant", Rows: []Row{
|
||||
{RowNo: 1, ICCID: "test-success", ObjectCode: "test-success", Success: true},
|
||||
{RowNo: 2, ICCID: "test-failed", ObjectCode: "test-failed", Reason: "原有失败原因"},
|
||||
}}
|
||||
baseline := filepath.Join(t.TempDir(), "baseline.xlsx")
|
||||
if err := writeWorkbook(baseline, request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artifact, err := New().Create(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer RemoveLocalFile(artifact)
|
||||
if artifact.OriginalName != request.BatchNo+"-result.xlsx" || filepath.Ext(artifact.LocalPath) != ".xlsx" {
|
||||
t.Fatalf("结果文件名或后缀发生变化: %+v", artifact)
|
||||
}
|
||||
baselineInfo, err := os.Stat(baseline)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actualInfo, err := os.Stat(artifact.LocalPath)
|
||||
if err != nil || actualInfo.Mode().Perm() != baselineInfo.Mode().Perm() {
|
||||
t.Fatalf("结果文件权限未保留原SaveAs行为: %v", err)
|
||||
}
|
||||
before, err := excelize.OpenFile(baseline)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer before.Close()
|
||||
after, err := excelize.OpenFile(artifact.LocalPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer after.Close()
|
||||
want, err := before.GetRows("处理结果")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := after.GetRows("处理结果")
|
||||
if err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("随机物理名改变了批次%d工作簿内容: err=%v", batchType, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWritesToMountedUploadAndKeepsLogicalPath(t *testing.T) {
|
||||
wd := t.TempDir()
|
||||
oldWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("get working directory: %v", err)
|
||||
}
|
||||
if err = os.Chdir(wd); err != nil {
|
||||
t.Fatalf("change working directory: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Chdir(oldWd)
|
||||
}()
|
||||
|
||||
if err = os.MkdirAll(filepath.Join(wd, "upload"), 0o755); err != nil {
|
||||
t.Fatalf("create mounted upload root: %v", err)
|
||||
}
|
||||
oldAdapter := g.Cfg().GetAdapter()
|
||||
adapter, err := gcfg.NewAdapterContent(`
|
||||
system:
|
||||
upload:
|
||||
local:
|
||||
path: "resource/upload"
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create config adapter: %v", err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
defer g.Cfg().SetAdapter(oldAdapter)
|
||||
|
||||
artifact, err := New().Create(context.Background(), Request{
|
||||
BatchNo: "INB202608140001001",
|
||||
BatchType: 1,
|
||||
TenantID: "000000",
|
||||
Rows: []Row{{RowNo: 1, ICCID: "89860401786527567308", Success: true}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
defer RemoveLocalFile(artifact)
|
||||
|
||||
logicalPath := filepath.ToSlash(artifact.Path)
|
||||
if !strings.HasPrefix(logicalPath, "resource/upload/000000/user/file/") {
|
||||
t.Fatalf("logical path = %q, want resource/upload path", artifact.Path)
|
||||
}
|
||||
localPath := filepath.ToSlash(artifact.LocalPath)
|
||||
if !strings.HasPrefix(localPath, "upload/000000/user/file/") {
|
||||
t.Fatalf("local path = %q, want mounted upload path", artifact.LocalPath)
|
||||
}
|
||||
if _, err = os.Stat(artifact.LocalPath); err != nil {
|
||||
t.Fatalf("result file was not written at local path %q: %v", artifact.LocalPath, err)
|
||||
}
|
||||
if _, err = os.Stat(artifact.Path); !os.IsNotExist(err) {
|
||||
t.Fatalf("result file should not be written to logical-only path %q, stat error=%v", artifact.Path, err)
|
||||
}
|
||||
if !strings.HasPrefix(artifact.URL, "/upload/000000/user/file/") {
|
||||
t.Fatalf("url = %q, want /upload URL", artifact.URL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package cancel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/packagecustomer"
|
||||
)
|
||||
|
||||
const (
|
||||
cascadeFailureReason = "同一批次存在无效销户明细,该行已跳过"
|
||||
validationSkipReason = "批次校验未通过,已跳过取消操作"
|
||||
emptyICCIDReason = "ICCID不能为空"
|
||||
duplicateICCIDReason = "ICCID在批次内重复"
|
||||
)
|
||||
|
||||
type executor struct {
|
||||
repository Repository
|
||||
}
|
||||
|
||||
func NewExecutor(repository Repository) Executor {
|
||||
return &executor{repository: repository}
|
||||
}
|
||||
|
||||
func (e *executor) Execute(ctx context.Context, batchNo string) error {
|
||||
task, err := e.repository.Load(ctx, strings.TrimSpace(batchNo))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load cancel batch failed")
|
||||
}
|
||||
if !validateTask(task) {
|
||||
if err = e.repository.SaveValidationFailure(ctx, task); err != nil {
|
||||
return gerror.Wrap(err, "save cancel validation failure failed")
|
||||
}
|
||||
e.saveResultFile(ctx, task)
|
||||
return nil
|
||||
}
|
||||
if err = e.repository.CommitBusiness(ctx, task); err != nil {
|
||||
return gerror.Wrap(err, "commit cancel business failed")
|
||||
}
|
||||
packagecustomer.PublishSyncForDeptsSafe(ctx, task.Batch.TenantID, task.Batch.CreatedBy, cancelPackageSyncDeptIDs(task), "cancel", task.Batch.BatchNo)
|
||||
e.saveResultFile(ctx, task)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cancelPackageSyncDeptIDs(task *Task) []int64 {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
deptIds := []int64{task.Batch.DeptID, task.Batch.CreatedDept}
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil || strings.TrimSpace(detail.Reason) != "" {
|
||||
continue
|
||||
}
|
||||
if detail.CardDeptID > 0 {
|
||||
deptIds = append(deptIds, detail.CardDeptID)
|
||||
}
|
||||
}
|
||||
return deptIds
|
||||
}
|
||||
|
||||
func (e *executor) saveResultFile(ctx context.Context, task *Task) {
|
||||
request := batchresult.Request{
|
||||
BatchNo: task.Batch.BatchNo,
|
||||
BatchType: task.Batch.BatchType,
|
||||
TenantID: task.Batch.TenantID,
|
||||
Rows: task.ResultRows(),
|
||||
}
|
||||
if err := e.repository.SaveResultFile(ctx, task, request); err != nil {
|
||||
g.Log().Errorf(ctx, "save cancel result file status failed, batchNo:%s, err:%+v", task.Batch.BatchNo, err)
|
||||
}
|
||||
}
|
||||
|
||||
func validateTask(task *Task) bool {
|
||||
if task == nil || len(task.Details) == 0 {
|
||||
if task != nil {
|
||||
task.FailureReason = "销户批次无有效明细"
|
||||
}
|
||||
return false
|
||||
}
|
||||
hasFailure := false
|
||||
iccids := make(map[string]int, len(task.Details))
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
hasFailure = true
|
||||
continue
|
||||
}
|
||||
if detail.Reason != "" {
|
||||
hasFailure = true
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(detail.ICCID)
|
||||
if iccid == "" {
|
||||
detail.Reason = emptyICCIDReason
|
||||
hasFailure = true
|
||||
continue
|
||||
}
|
||||
iccids[iccid]++
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil || detail.Reason != "" {
|
||||
continue
|
||||
}
|
||||
if iccids[strings.TrimSpace(detail.ICCID)] > 1 {
|
||||
detail.Reason = duplicateICCIDReason
|
||||
hasFailure = true
|
||||
}
|
||||
}
|
||||
if !hasFailure {
|
||||
return true
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && detail.Reason == "" {
|
||||
detail.Reason = cascadeFailureReason
|
||||
}
|
||||
}
|
||||
task.FailureReason = validationSkipReason
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package cancel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
type Batch struct {
|
||||
ID int64
|
||||
BatchNo string
|
||||
BatchType int
|
||||
TenantID string
|
||||
DeptID int64
|
||||
CreatedBy int64
|
||||
CreatedDept int64
|
||||
StartTime *gtime.Time
|
||||
BatchInfo *gjson.Json
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
ID int64
|
||||
RowNo int
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
Reason string
|
||||
|
||||
Authorized bool
|
||||
InputMSISDN string
|
||||
InputIMSI string
|
||||
|
||||
StatusBefore int
|
||||
TargetStatus int
|
||||
CancelTime string
|
||||
|
||||
SubscriptionNo string
|
||||
OrderCode string
|
||||
PackageCode string
|
||||
PackageType int
|
||||
CardDeptID int64
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Batch Batch
|
||||
Details []*Detail
|
||||
FailureReason string
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
Load(ctx context.Context, batchNo string) (*Task, error)
|
||||
SaveValidationFailure(ctx context.Context, task *Task) error
|
||||
CommitBusiness(ctx context.Context, task *Task) error
|
||||
SaveResultFile(ctx context.Context, task *Task, request batchresult.Request) error
|
||||
}
|
||||
|
||||
type Executor interface {
|
||||
Execute(ctx context.Context, batchNo string) error
|
||||
}
|
||||
|
||||
func (d *Detail) Snapshot() *gjson.Json {
|
||||
if d == nil {
|
||||
return gjson.New(nil)
|
||||
}
|
||||
return gjson.New(map[string]any{
|
||||
"rowNo": d.RowNo,
|
||||
"ICCID": d.ICCID,
|
||||
"MSISDN": d.MSISDN,
|
||||
"IMSI": d.IMSI,
|
||||
"inputMsisdn": d.InputMSISDN,
|
||||
"inputImsi": d.InputIMSI,
|
||||
"authorized": d.Authorized,
|
||||
"statusBefore": d.StatusBefore,
|
||||
"targetStatus": d.TargetStatus,
|
||||
"cancelTime": d.CancelTime,
|
||||
"subscriptionNo": d.SubscriptionNo,
|
||||
"orderCode": d.OrderCode,
|
||||
"packageCode": d.PackageCode,
|
||||
"packageType": d.PackageType,
|
||||
"cardDeptId": d.CardDeptID,
|
||||
"errorReason": d.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
func (t *Task) ResultRows() []batchresult.Row {
|
||||
rows := make([]batchresult.Row, 0, len(t.Details))
|
||||
for _, detail := range t.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, batchresult.Row{
|
||||
RowNo: detail.RowNo,
|
||||
ICCID: detail.ICCID,
|
||||
MSISDN: detail.MSISDN,
|
||||
IMSI: detail.IMSI,
|
||||
StatusBefore: detail.StatusBefore,
|
||||
TargetStatus: detail.TargetStatus,
|
||||
Success: detail.Reason == "",
|
||||
Reason: detail.Reason,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Package dailyusage implements the zset.Handler for previous-day usage.
|
||||
//
|
||||
// It queries one card per task through the upstream gateway (the same path the
|
||||
// status sync uses), validates the returned period and identity, and writes the
|
||||
// usage to the TDengine daily table.
|
||||
//
|
||||
// The failure classifier is intentionally separate from the monthly-usage one
|
||||
// and deliberately conservative: only errors that are known to be transient are
|
||||
// retried. An unknown carrier code is parked as a terminal business failure
|
||||
// instead of being retried, because a retry storm against a rate-limited carrier
|
||||
// is worse than a card that needs manual review (see the 2026-09-16 disk-full
|
||||
// incident). Like the monthly classifier, it never inspects the error text.
|
||||
package dailyusage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
zsetconsumer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consumer/zset"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
)
|
||||
|
||||
const (
|
||||
// errCodeTelecomChannelLimited is the carrier error code that means the
|
||||
// whole channel is throttled or disabled.
|
||||
errCodeTelecomChannelLimited = "S00004" // chanGatewayInternalError is the gateway code for transport, HTTP and
|
||||
// response-decoding failures. An empty errCode distinguishes those from a
|
||||
// carrier business result that happens to use the same number.
|
||||
chanGatewayInternalError = 9999
|
||||
// tokenExpiredCode is the carrier code for an expired upstream token. The
|
||||
// gateway refreshes the token once; a later round can then succeed.
|
||||
tokenExpiredCode = 12021
|
||||
|
||||
mobileBusyFirstCode = 13000
|
||||
mobileBusyLastCode = 13007
|
||||
mobileBusyClass = "mobile_busy"
|
||||
)
|
||||
|
||||
// mobileUsageTemplates identifies the China Mobile OneLink implementations.
|
||||
// Carrier identity is resolved from the channel template rather than from the
|
||||
// channel name or the error text, both of which are unstable.
|
||||
//
|
||||
// The set mirrors the OneLink registration table in the upstream proxy and is
|
||||
// kept in sync with the monthly-usage classifier
|
||||
// (service/monthlyusage/handler.go). Unknown templates stay "generic" and are
|
||||
// never classified as mobile.
|
||||
var mobileUsageTemplates = map[string]struct{}{
|
||||
"sim_yd_v5": {},
|
||||
"sim_yd_mock_v5": {},
|
||||
"sim_sdtywl_v1": {},
|
||||
"sim_onelink_tengyu_v1": {},
|
||||
"sim_onelink_xuzhou_mobile_v1": {},
|
||||
"sim_onelink_lelian_v1": {},
|
||||
"sim_onelink_qianxun_v1": {},
|
||||
"sim_onelink_henan_xinjing_v1": {},
|
||||
"sim_henan_xinjing_direct_v1": {},
|
||||
"sim_onelink_zibo_v1": {},
|
||||
"sim_tibiot_v1": {},
|
||||
"sim_qianxun_v3": {},
|
||||
"sim_yunda_v1": {},
|
||||
"sim_mingqian_v3": {},
|
||||
"sim_daihe_v1": {},
|
||||
"sim_onelink_daihe_v1": {},
|
||||
"sim_onelink_daliuliang_v1": {},
|
||||
"sim__daliuliang_v1": {},
|
||||
"sim_onelink_mobile_public_v1": {},
|
||||
"sim_onelink_kedang_v1": {},
|
||||
"sim_onelink_beixin_v1": {},
|
||||
"lanshang_8000": {},
|
||||
"lanshang_8003": {},
|
||||
}
|
||||
|
||||
// usageCarrierForTemplate resolves a channel template to its carrier.
|
||||
//
|
||||
// Telecom is detected through model.IsTelecomTemplate so every worker path
|
||||
// shares one definition (it covers both sim_dx_v1 and sim_dx_v3). Hard-coding
|
||||
// the v1 template here would silently classify a v3 channel as generic, which
|
||||
// in turn would drop its S00004 channel-blocking behaviour.
|
||||
func usageCarrierForTemplate(tempKey string) string {
|
||||
tempKey = strings.ToLower(strings.TrimSpace(tempKey))
|
||||
if model.IsTelecomTemplate(tempKey) {
|
||||
return "telecom"
|
||||
}
|
||||
if _, ok := mobileUsageTemplates[tempKey]; ok {
|
||||
return "mobile"
|
||||
}
|
||||
return "generic"
|
||||
}
|
||||
|
||||
// classifyDailyUsageFailure maps one upstream item to the queue semantics owned
|
||||
// by this service.
|
||||
//
|
||||
// message is accepted only so callers can log it; it never influences the
|
||||
// decision, because text matching was the root cause of the 2026-09-14 channel
|
||||
// retry loop. The default is a non-retryable business failure: a wrong retry
|
||||
// multiplies carrier throttling, while a missing retry costs one manual replay.
|
||||
func classifyDailyUsageFailure(tempKey string, code int, errCode string) (errorClass string, retryable, blockChannel bool) {
|
||||
carrier := usageCarrierForTemplate(tempKey)
|
||||
switch {
|
||||
case carrier == "telecom" && strings.EqualFold(strings.TrimSpace(errCode), errCodeTelecomChannelLimited):
|
||||
return "channel_limited", false, true
|
||||
case carrier == "mobile" && code >= mobileBusyFirstCode && code <= mobileBusyLastCode:
|
||||
return mobileBusyClass, true, false
|
||||
case code == tokenExpiredCode:
|
||||
return "token_expired", true, false
|
||||
case code == chanGatewayInternalError && strings.TrimSpace(errCode) == "":
|
||||
return "network", true, false
|
||||
default:
|
||||
return "business", false, false
|
||||
}
|
||||
}
|
||||
|
||||
// UsageClient is the upstream surface needed by the handler.
|
||||
type UsageClient interface {
|
||||
QuerySimDatausageDaily(context.Context, *model.SimDataUsageDailyInput) ([]model.SimBaseRes[model.SimDataUsageDailyOutputItem], error)
|
||||
}
|
||||
|
||||
// UsageRepository persists the daily usage rows.
|
||||
type UsageRepository interface {
|
||||
UpdateDaily(context.Context, []model.TrafficUsageDailyItem) error
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
client UsageClient
|
||||
repo UsageRepository
|
||||
}
|
||||
|
||||
func NewHandler(client UsageClient, repo UsageRepository) (*Handler, error) {
|
||||
if client == nil || repo == nil {
|
||||
return nil, fmt.Errorf("daily usage handler dependencies are incomplete")
|
||||
}
|
||||
return &Handler{client: client, repo: repo}, nil
|
||||
}
|
||||
|
||||
// Query performs one upstream daily-usage request for a single card.
|
||||
//
|
||||
// task.QueryMonth carries the run's period identifier: for a daily run that is
|
||||
// yyyyMMdd, and it is sent upstream verbatim as queryDate.
|
||||
func (h *Handler) Query(ctx context.Context, task queue.Task) zsetconsumer.Result {
|
||||
result := zsetconsumer.Result{Task: task}
|
||||
queryDate := strings.TrimSpace(task.QueryMonth)
|
||||
if queryDate == "" {
|
||||
result.ErrorClass = "protocol"
|
||||
result.Message = "daily usage task is missing its query date"
|
||||
return result
|
||||
}
|
||||
output, err := h.client.QuerySimDatausageDaily(ctx, &model.SimDataUsageDailyInput{
|
||||
SimKeys: []*model.SimKey{{
|
||||
ChanKey: task.ChanKey,
|
||||
ICCID: task.ICCID,
|
||||
IMSI: task.IMSI,
|
||||
MSISDN: task.MSISDN,
|
||||
}},
|
||||
ReqData: model.SimDataUsageDailyInputItem{QueryDate: queryDate},
|
||||
})
|
||||
if err != nil {
|
||||
result.ErrorClass = "network"
|
||||
result.Retryable = true
|
||||
result.Message = err.Error()
|
||||
return result
|
||||
}
|
||||
if len(output) == 0 || output[0].SimKey == nil {
|
||||
result.ErrorClass = "protocol"
|
||||
result.Retryable = true
|
||||
result.Message = "chan returned empty daily usage result"
|
||||
return result
|
||||
}
|
||||
item := output[0]
|
||||
if task.ICCID != "" && item.SimKey.ICCID != task.ICCID {
|
||||
result.ErrorClass = "protocol"
|
||||
result.Retryable = true
|
||||
result.Message = "chan returned mismatched card identifier"
|
||||
return result
|
||||
}
|
||||
result.Code = item.Code
|
||||
result.Message = item.Message
|
||||
result.ErrCode = item.ErrCode
|
||||
if item.Code != 0 {
|
||||
result.ErrorClass, result.Retryable, result.BlockChannel = classifyDailyUsageFailure(task.TempKey, item.Code, item.ErrCode)
|
||||
return result
|
||||
}
|
||||
// A successful code with a different (or missing) period would write the
|
||||
// wrong day, so it is treated as a protocol failure rather than silently
|
||||
// accepted. This mirrors the legacy admin reconcile requirement.
|
||||
if period, ok := normalizeDailyQueryDate(item.ResData.QueryDate); !ok || period != queryDate {
|
||||
result.ErrorClass = "protocol"
|
||||
result.Retryable = true
|
||||
result.Message = "chan returned mismatched daily usage period"
|
||||
return result
|
||||
}
|
||||
result.Success = true
|
||||
result.UsageMB = item.ResData.DataAmount
|
||||
return result
|
||||
}
|
||||
|
||||
// Apply writes every successful result to the TDengine daily table.
|
||||
//
|
||||
// The timestamp is derived from the run period rather than from the response, so
|
||||
// every card in a run lands on the same 23:59:59.000 row that the dashboard and
|
||||
// the diagnosis page read.
|
||||
func (h *Handler) Apply(ctx context.Context, results []zsetconsumer.Result) error {
|
||||
items := make([]model.TrafficUsageDailyItem, 0, len(results))
|
||||
for _, result := range results {
|
||||
if !result.Success {
|
||||
continue
|
||||
}
|
||||
date, ok := parseDailyQueryDate(strings.TrimSpace(result.Task.QueryMonth))
|
||||
if !ok {
|
||||
return fmt.Errorf("daily usage task %s has an invalid period %q", result.Task.TaskID, result.Task.QueryMonth)
|
||||
}
|
||||
items = append(items, model.TrafficUsageDailyItem{
|
||||
TrafficUsageBase: model.TrafficUsageBase{
|
||||
Iccid: result.Task.ICCID,
|
||||
UsageMb: result.UsageMB,
|
||||
PackageCode: result.Task.PackageCode,
|
||||
},
|
||||
Date: date,
|
||||
})
|
||||
}
|
||||
return h.repo.UpdateDaily(ctx, items)
|
||||
}
|
||||
|
||||
// ShouldRetryNextRound is the daily-usage retry policy. It is passed to the
|
||||
// run-aware ZSet consumer only; it keeps the classifier's verdict authoritative.
|
||||
func ShouldRetryNextRound(result zsetconsumer.Result) bool {
|
||||
return !result.Success && result.Retryable
|
||||
}
|
||||
|
||||
var dailyUsageLocation = time.FixedZone("UTC+8", 8*60*60)
|
||||
|
||||
// normalizeDailyQueryDate canonicalizes an upstream period to yyyyMMdd and
|
||||
// reports whether the value was interpretable at all.
|
||||
func normalizeDailyQueryDate(value string) (string, bool) {
|
||||
for _, layout := range []string{"20060102", "2006-01-02"} {
|
||||
parsed, err := time.ParseInLocation(layout, strings.TrimSpace(value), dailyUsageLocation)
|
||||
if err == nil {
|
||||
return parsed.Format("20060102"), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseDailyQueryDate(value string) (time.Time, bool) {
|
||||
parsed, err := time.ParseInLocation("20060102", value, dailyUsageLocation)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package dailyusage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
zsetconsumer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consumer/zset"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
)
|
||||
|
||||
type fakeClient struct {
|
||||
output []model.SimBaseRes[model.SimDataUsageDailyOutputItem]
|
||||
err error
|
||||
// gotQueryDate records the period the handler sent upstream so a test can
|
||||
// prove the task period is forwarded verbatim.
|
||||
gotQueryDate string
|
||||
}
|
||||
|
||||
func (c *fakeClient) QuerySimDatausageDaily(_ context.Context, input *model.SimDataUsageDailyInput) ([]model.SimBaseRes[model.SimDataUsageDailyOutputItem], error) {
|
||||
c.gotQueryDate = input.ReqData.QueryDate
|
||||
return c.output, c.err
|
||||
}
|
||||
|
||||
type fakeRepo struct {
|
||||
items []model.TrafficUsageDailyItem
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpdateDaily(_ context.Context, items []model.TrafficUsageDailyItem) error {
|
||||
r.items = append(r.items, items...)
|
||||
return r.err
|
||||
}
|
||||
|
||||
func dailyTask() queue.Task {
|
||||
return queue.Task{
|
||||
TaskID: "run:op:42",
|
||||
RunID: "run",
|
||||
Operation: model.OperationPreviousDayUsageFullSync,
|
||||
QueryMonth: "20261009",
|
||||
ChanKey: "217",
|
||||
TempKey: "sim_yd_v5",
|
||||
CardID: 42,
|
||||
ICCID: "89860042",
|
||||
PackageCode: "PKG-A",
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryForwardsTaskPeriodUpstream(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ICCID: "89860042"},
|
||||
ResData: model.SimDataUsageDailyOutputItem{
|
||||
QueryDate: "20261009",
|
||||
DataAmount: 12.5,
|
||||
},
|
||||
}}}
|
||||
handler, err := NewHandler(client, &fakeRepo{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := handler.Query(context.Background(), dailyTask())
|
||||
if !result.Success {
|
||||
t.Fatalf("expected success, got class=%s message=%s", result.ErrorClass, result.Message)
|
||||
}
|
||||
if client.gotQueryDate != "20261009" {
|
||||
t.Fatalf("upstream queryDate = %q, want the task period 20261009", client.gotQueryDate)
|
||||
}
|
||||
if result.UsageMB != 12.5 {
|
||||
t.Fatalf("usage = %v, want 12.5", result.UsageMB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAcceptsEquivalentPeriodFormats(t *testing.T) {
|
||||
for _, period := range []string{"20261009", "2026-10-09"} {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ICCID: "89860042"},
|
||||
ResData: model.SimDataUsageDailyOutputItem{QueryDate: period, DataAmount: 1},
|
||||
}}}
|
||||
handler, _ := NewHandler(client, &fakeRepo{})
|
||||
if result := handler.Query(context.Background(), dailyTask()); !result.Success {
|
||||
t.Fatalf("period %q was rejected: class=%s message=%s", period, result.ErrorClass, result.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRejectsMismatchedPeriod(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ICCID: "89860042"},
|
||||
ResData: model.SimDataUsageDailyOutputItem{QueryDate: "20261008", DataAmount: 1},
|
||||
}}}
|
||||
handler, _ := NewHandler(client, &fakeRepo{})
|
||||
result := handler.Query(context.Background(), dailyTask())
|
||||
if result.Success {
|
||||
t.Fatal("a mismatched period must not be accepted")
|
||||
}
|
||||
if result.ErrorClass != "protocol" || !result.Retryable {
|
||||
t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRejectsMissingPeriodEcho(t *testing.T) {
|
||||
// A channel that omits QueryDate from a successful response must fail too:
|
||||
// the task period would be unverifiable, so writing the row risks wrong-day
|
||||
// data. This mirrors the admin reconcile requirement.
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ICCID: "89860042"},
|
||||
ResData: model.SimDataUsageDailyOutputItem{DataAmount: 1},
|
||||
}}}
|
||||
handler, _ := NewHandler(client, &fakeRepo{})
|
||||
result := handler.Query(context.Background(), dailyTask())
|
||||
if result.Success {
|
||||
t.Fatal("a missing period echo must not be accepted")
|
||||
}
|
||||
if result.ErrorClass != "protocol" || !result.Retryable {
|
||||
t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRejectsMismatchedICCID(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ICCID: "89869999"},
|
||||
ResData: model.SimDataUsageDailyOutputItem{QueryDate: "20261009"},
|
||||
}}}
|
||||
handler, _ := NewHandler(client, &fakeRepo{})
|
||||
result := handler.Query(context.Background(), dailyTask())
|
||||
if result.Success || result.ErrorClass != "protocol" || !result.Retryable {
|
||||
t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRejectsEmptyResult(t *testing.T) {
|
||||
handler, _ := NewHandler(&fakeClient{}, &fakeRepo{})
|
||||
result := handler.Query(context.Background(), dailyTask())
|
||||
if result.Success || result.ErrorClass != "protocol" || !result.Retryable {
|
||||
t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTreatsTransportErrorAsRetryableNetwork(t *testing.T) {
|
||||
handler, _ := NewHandler(&fakeClient{err: errors.New("connection refused")}, &fakeRepo{})
|
||||
result := handler.Query(context.Background(), dailyTask())
|
||||
if result.ErrorClass != "network" || !result.Retryable {
|
||||
t.Fatalf("class=%s retryable=%v, want network/retryable", result.ErrorClass, result.Retryable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRejectsTaskWithoutPeriod(t *testing.T) {
|
||||
handler, _ := NewHandler(&fakeClient{}, &fakeRepo{})
|
||||
task := dailyTask()
|
||||
task.QueryMonth = ""
|
||||
result := handler.Query(context.Background(), task)
|
||||
if result.Success || result.Retryable {
|
||||
t.Fatalf("a task without a period must fail without retrying: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWritesPackageCodeAndEndOfDayTimestamp(t *testing.T) {
|
||||
repo := &fakeRepo{}
|
||||
handler, _ := NewHandler(&fakeClient{}, repo)
|
||||
err := handler.Apply(context.Background(), []zsetconsumer.Result{
|
||||
{Task: dailyTask(), Success: true, UsageMB: 7.25},
|
||||
{Task: dailyTask(), Success: false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.items) != 1 {
|
||||
t.Fatalf("wrote %d items, want only the successful one", len(repo.items))
|
||||
}
|
||||
item := repo.items[0]
|
||||
if item.Iccid != "89860042" || item.UsageMb != 7.25 || item.PackageCode != "PKG-A" {
|
||||
t.Fatalf("item = %+v, want iccid/usage/packageCode carried from the task", item)
|
||||
}
|
||||
// The handler supplies the day; repository.snapshotDay turns it into the
|
||||
// 23:59:59.000 row (covered by repository_test.go). Assert the day here so a
|
||||
// period mix-up cannot slip through.
|
||||
if got := item.Date.Format("2006-01-02"); got != "2026-10-09" {
|
||||
t.Fatalf("date = %s, want the run period 2026-10-09", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRejectsInvalidPeriod(t *testing.T) {
|
||||
handler, _ := NewHandler(&fakeClient{}, &fakeRepo{})
|
||||
task := dailyTask()
|
||||
task.QueryMonth = "2026-10-09"
|
||||
if err := handler.Apply(context.Background(), []zsetconsumer.Result{{Task: task, Success: true}}); err == nil {
|
||||
t.Fatal("expected an unparseable period to fail the write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDailyUsageFailure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tempKey string
|
||||
code int
|
||||
errCode string
|
||||
wantClass string
|
||||
wantRetry bool
|
||||
wantBlocked bool
|
||||
}{
|
||||
{name: "telecom channel limited", tempKey: "sim_dx_v1", code: 500, errCode: "S00004", wantClass: "channel_limited", wantBlocked: true},
|
||||
{name: "telecom channel limited lowercase", tempKey: "sim_dx_v1", code: 500, errCode: "s00004", wantClass: "channel_limited", wantBlocked: true},
|
||||
// sim_dx_v3 is a telecom template too. It is resolved through
|
||||
// model.IsTelecomTemplate so a v3 channel keeps S00004 blocking instead
|
||||
// of falling through to generic and retrying every card to exhaustion.
|
||||
{name: "telecom v3 channel limited", tempKey: "sim_dx_v3", code: 500, errCode: "S00004", wantClass: "channel_limited", wantBlocked: true},
|
||||
{name: "telecom v3 template is not treated as mobile", tempKey: "sim_dx_v3", code: 13000, wantClass: "business"},
|
||||
{name: "mobile busy first code", tempKey: "sim_yd_v5", code: 13000, wantClass: "mobile_busy", wantRetry: true},
|
||||
{name: "mobile busy last code", tempKey: "sim_yd_v5", code: 13007, wantClass: "mobile_busy", wantRetry: true},
|
||||
{name: "mobile one past the busy range", tempKey: "sim_yd_v5", code: 13008, wantClass: "business"},
|
||||
{name: "mobile one below the busy range", tempKey: "sim_yd_v5", code: 12999, wantClass: "business"},
|
||||
{name: "busy code on a non-mobile template", tempKey: "sim_qianxun_v3_bogus", code: 13000, wantClass: "business"},
|
||||
{name: "token expired", tempKey: "sim_yd_v5", code: 12021, wantClass: "token_expired", wantRetry: true},
|
||||
{name: "gateway internal without errCode", tempKey: "sim_yd_v5", code: 9999, errCode: "", wantClass: "network", wantRetry: true},
|
||||
{name: "gateway code with a carrier errCode stays business", tempKey: "sim_yd_v5", code: 9999, errCode: "E42", wantClass: "business"},
|
||||
{name: "telecom S00004 without the telecom template is not channel limited", tempKey: "sim_yd_v5", code: 500, errCode: "S00004", wantClass: "business"},
|
||||
{name: "unknown code is not retried", tempKey: "sim_yd_v5", code: 4242, wantClass: "business"},
|
||||
{name: "unknown code on an unknown template is not retried", tempKey: "some_new_channel_v9", code: 13000, wantClass: "business"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
class, retry, blocked := classifyDailyUsageFailure(test.tempKey, test.code, test.errCode)
|
||||
if class != test.wantClass || retry != test.wantRetry || blocked != test.wantBlocked {
|
||||
t.Fatalf("classify(%q, %d, %q) = (%s, %v, %v), want (%s, %v, %v)",
|
||||
test.tempKey, test.code, test.errCode, class, retry, blocked, test.wantClass, test.wantRetry, test.wantBlocked)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyIgnoresMessageText pins the 2026-09-14 discipline: classification
|
||||
// must never depend on carrier text, because text matching caused the channel
|
||||
// retry loop that the rule was introduced to prevent.
|
||||
func TestClassifyIgnoresMessageText(t *testing.T) {
|
||||
// A "busy"-looking message on an unknown code must stay non-retryable.
|
||||
if _, retry, _ := classifyDailyUsageFailure("sim_yd_v5", 4242, ""); retry {
|
||||
t.Fatal("an unknown code must not become retryable because its message looked transient")
|
||||
}
|
||||
// And a busy code must stay retryable regardless of what the text says.
|
||||
if _, retry, _ := classifyDailyUsageFailure("sim_yd_v5", 13000, ""); !retry {
|
||||
t.Fatal("a mobile busy code must stay retryable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRetryNextRoundFollowsClassifier(t *testing.T) {
|
||||
if ShouldRetryNextRound(zsetconsumer.Result{Success: true}) {
|
||||
t.Fatal("a successful result must not be retried")
|
||||
}
|
||||
if !ShouldRetryNextRound(zsetconsumer.Result{Retryable: true}) {
|
||||
t.Fatal("a retryable failure must be deferred to the next round")
|
||||
}
|
||||
if ShouldRetryNextRound(zsetconsumer.Result{Retryable: false}) {
|
||||
t.Fatal("a terminal failure must not be retried")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageCarrierForTemplateMatchesSharedDefinition(t *testing.T) {
|
||||
// The daily classifier must agree with model.IsTelecomTemplate for every
|
||||
// telecom template the rest of the worker recognises. A drift here means a
|
||||
// channel silently loses its S00004 blocking behaviour.
|
||||
for _, tempKey := range []string{"sim_dx_v1", "sim_dx_v3", "SIM_DX_V1", " sim_dx_v3 "} {
|
||||
if got := usageCarrierForTemplate(tempKey); got != "telecom" {
|
||||
t.Errorf("usageCarrierForTemplate(%q) = %q, want telecom", tempKey, got)
|
||||
}
|
||||
}
|
||||
for _, tempKey := range []string{"sim_yd_v5", "sim_onelink_daihe_v1"} {
|
||||
if got := usageCarrierForTemplate(tempKey); got != "mobile" {
|
||||
t.Errorf("usageCarrierForTemplate(%q) = %q, want mobile", tempKey, got)
|
||||
}
|
||||
}
|
||||
for _, tempKey := range []string{"", "some_new_channel_v9", "sim_dx_v2"} {
|
||||
if got := usageCarrierForTemplate(tempKey); got != "generic" {
|
||||
t.Errorf("usageCarrierForTemplate(%q) = %q, want generic", tempKey, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBlockChannelPropagatesForTelecomS00004(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{{
|
||||
Code: 500,
|
||||
ErrCode: "S00004",
|
||||
Message: "channel disabled",
|
||||
SimKey: &model.SimKey{ICCID: "89860042"},
|
||||
}}}
|
||||
handler, _ := NewHandler(client, &fakeRepo{})
|
||||
task := dailyTask()
|
||||
task.TempKey = "sim_dx_v1"
|
||||
result := handler.Query(context.Background(), task)
|
||||
if result.ErrorClass != "channel_limited" || !result.BlockChannel || result.Retryable {
|
||||
t.Fatalf("telecom S00004 must block the channel without retrying: %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
// 首查 +3min,此后每 3min 重试 3 次(+3/+6/+9/+12min)。
|
||||
// 2026-10-15 潍坊、濮阳实测:10/30/100 卡共12笔首次可见终态约 65~225s;
|
||||
// 15s 采样只能给出完成区间,不能据此承诺所有高峰时段的完成上限。
|
||||
// 偶发超长任务不增加自动重试;超窗记 review,保留受理号和原因供手动查询。
|
||||
// 未确认不得记办理失败,也不得重新提交办理请求。
|
||||
const (
|
||||
subBatchLease = 5 * time.Minute
|
||||
firstResultDelay = 3 * time.Minute
|
||||
furtherResultDelay = 3 * time.Minute
|
||||
maxResultQueries = 4
|
||||
)
|
||||
|
||||
func newSubBatch(task *Task, details []*Detail) SubBatch {
|
||||
keys := make([]*model.SimKey, 0, len(details))
|
||||
ids := make([]int64, 0, len(details))
|
||||
for _, detail := range details {
|
||||
ids = append(ids, detail.ID)
|
||||
keys = append(keys, &model.SimKey{ChanKey: detail.ChanKey, ICCID: detail.ICCID, MSISDN: detail.MSISDN, IMSI: detail.IMSI})
|
||||
}
|
||||
raw, _ := json.Marshal(struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
IDs []int64
|
||||
}{task.Batch.BatchNo, task.Batch.BatchType, ids})
|
||||
digest := sha256.Sum256(raw)
|
||||
id := hex.EncodeToString(digest[:])
|
||||
input := model.SimCardApnChangeInput{SimKeys: keys, ReqData: model.SimCardApnChangeInputItem{OperType: task.Operation.ApnOperType, ApnName: task.ApnName, OperationID: id, SubmitOnly: true}}
|
||||
return SubBatch{BatchNo: task.Batch.BatchNo, BatchType: task.Batch.BatchType, ChanKey: details[0].ChanKey, OperationID: id, DetailIDs: ids, Request: input, Status: "prepared"}
|
||||
}
|
||||
|
||||
func splitAsyncDetails(details []*Detail) [][]*Detail {
|
||||
withMSISDN := make([]*Detail, 0, len(details))
|
||||
batches := make([][]*Detail, 0)
|
||||
for _, detail := range details {
|
||||
if strings.TrimSpace(detail.MSISDN) == "" {
|
||||
batches = append(batches, []*Detail{detail})
|
||||
} else {
|
||||
withMSISDN = append(withMSISDN, detail)
|
||||
}
|
||||
}
|
||||
for _, chunk := range splitDetails(withMSISDN, consts.BatchSubBatchSize) {
|
||||
if len(chunk) < 10 {
|
||||
for _, detail := range chunk {
|
||||
batches = append(batches, []*Detail{detail})
|
||||
}
|
||||
} else {
|
||||
batches = append(batches, chunk)
|
||||
}
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
// prepareAndSubmit persists every intent before a single write leaves the process.
|
||||
func (e *executor) prepareAndSubmit(ctx context.Context, task *Task, repo SubBatchRepository, upstream AsyncUpstream) error {
|
||||
groups := make(map[string][]*Detail)
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && detail.Reason == "" && (detail.DetailStatus == 0 || detail.DetailStatus == consts.BatchDetailStatusRunning) {
|
||||
groups[detail.ChanKey] = append(groups[detail.ChanKey], detail)
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(groups))
|
||||
for key := range groups {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
for _, slice := range splitAsyncDetails(groups[key]) {
|
||||
sub := newSubBatch(task, slice)
|
||||
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
|
||||
return fmt.Errorf("保存APN提交意图失败(未提交运营商): %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processSubBatch only submits while prepared. Any interrupted submission is recovered by operationId, never repeated.
|
||||
func (e *executor) processSubBatch(ctx context.Context, repo SubBatchRepository, upstream AsyncUpstream, operationID string) error {
|
||||
channel, err := repo.SubBatchChannel(ctx, operationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, _ := e.channelSlots.LoadOrStore(channel, make(chan struct{}, consts.BatchSubBatchConcurrency))
|
||||
slots := value.(chan struct{})
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
defer func() { <-slots }()
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
token := uuid.NewString()
|
||||
callCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
|
||||
defer cancel()
|
||||
if checker, ok := upstream.(interface {
|
||||
CheckSimApnAsyncCapability(context.Context, *model.SimCardApnChangeInput) error
|
||||
}); ok {
|
||||
snapshot, err := repo.GetSubBatch(callCtx, operationID)
|
||||
if err != nil || snapshot == nil {
|
||||
return err
|
||||
}
|
||||
if snapshot.Status == "done" || snapshot.Status == "review" {
|
||||
return nil
|
||||
}
|
||||
if snapshot.Status == "prepared" {
|
||||
if err := checker.CheckSimApnAsyncCapability(callCtx, &snapshot.Request); err != nil {
|
||||
persistCtx, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer stop()
|
||||
if delayErr := repo.DelaySubBatchPrecheck(persistCtx, *snapshot, time.Now().Add(30*time.Second), err.Error()); delayErr != nil {
|
||||
return fmt.Errorf("保存APN预检调度退避失败: %w", delayErr)
|
||||
}
|
||||
return fmt.Errorf("APN提交预检未就绪,未领取提交意图: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
sub, err := repo.ClaimSubBatch(ctx, operationID, token, time.Now())
|
||||
if err != nil || sub == nil {
|
||||
return err
|
||||
}
|
||||
if sub.Status == "prepared" {
|
||||
response, submitErr := upstream.SubmitSimApnFunction(callCtx, &sub.Request)
|
||||
if submitErr != nil {
|
||||
if isAPNRequestNotSent(submitErr) {
|
||||
return releaseUnsent(ctx, repo, *sub, submitErr)
|
||||
}
|
||||
return e.rememberUncertain(ctx, repo, *sub, "提交结果未确认(网络超时或连接中断): "+submitErr.Error())
|
||||
}
|
||||
return e.handleResponse(ctx, repo, *sub, response, false)
|
||||
}
|
||||
if sub.Status == "submitting" || sub.Status == "uncertain" || (sub.Status == "accepted" && sub.JobID == "") {
|
||||
sub.Request.ReqData.RecoverOnly = true
|
||||
sub.Request.ReqData.QueryJobID = ""
|
||||
response, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request)
|
||||
if queryErr != nil {
|
||||
if isAPNRequestNotSent(queryErr) {
|
||||
return releaseUnsent(ctx, repo, *sub, queryErr)
|
||||
}
|
||||
return e.rememberUncertain(ctx, repo, *sub, "受理映射恢复失败(不重发): "+queryErr.Error())
|
||||
}
|
||||
return e.handleResponse(ctx, repo, *sub, response, false)
|
||||
}
|
||||
sub.Request.ReqData.QueryJobID = sub.JobID
|
||||
sub.Request.ReqData.RecoverOnly = false
|
||||
response, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request)
|
||||
if queryErr != nil {
|
||||
if isAPNRequestNotSent(queryErr) {
|
||||
return releaseUnsent(ctx, repo, *sub, queryErr)
|
||||
}
|
||||
return e.queryUnavailable(ctx, repo, *sub, "查询请求超时或不可用: "+queryErr.Error())
|
||||
}
|
||||
return e.handleResponse(ctx, repo, *sub, response, true)
|
||||
}
|
||||
|
||||
// QueryReview仅执行一次只读补查,不重新开启自动轮询或提交办理。
|
||||
func (e *executor) QueryReview(ctx context.Context, operationID string) error {
|
||||
operationID = strings.TrimSpace(operationID)
|
||||
if operationID == "" {
|
||||
return fmt.Errorf("operationId不能为空")
|
||||
}
|
||||
repo, ok := e.repository.(SubBatchRepository)
|
||||
if !ok {
|
||||
return fmt.Errorf("未配置APN持久化仓储")
|
||||
}
|
||||
reviewRepo, ok := e.repository.(ReviewRepository)
|
||||
if !ok {
|
||||
return fmt.Errorf("未配置APN待核查查询仓储")
|
||||
}
|
||||
upstream, ok := e.upstream.(AsyncUpstream)
|
||||
if !ok {
|
||||
return fmt.Errorf("未配置APN只读补查客户端")
|
||||
}
|
||||
sub, err := reviewRepo.ClaimReviewSubBatch(ctx, operationID, uuid.NewString(), time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sub == nil {
|
||||
snapshot, err := repo.GetSubBatch(ctx, operationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if snapshot != nil && snapshot.Status == "done" {
|
||||
return repo.FinalizeReadyBatch(ctx, snapshot.BatchNo)
|
||||
}
|
||||
return fmt.Errorf("子批不处于可查询的待核查状态,或已有查询/汇总正在运行")
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
|
||||
defer cancel()
|
||||
sub.Request.ReqData.SubmitOnly = false
|
||||
sub.Request.ReqData.QueryJobID = sub.JobID
|
||||
sub.Request.ReqData.RecoverOnly = sub.JobID == ""
|
||||
items, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request)
|
||||
if queryErr != nil {
|
||||
if err := e.deferResult(ctx, repo, *sub, "review", "手动补查未确认:"+queryErr.Error()); err != nil {
|
||||
return err
|
||||
}
|
||||
return queryErr
|
||||
}
|
||||
return e.handleResponse(ctx, repo, *sub, items, true)
|
||||
}
|
||||
|
||||
// QueryReviewBatch 是"手动同步上游结果"入口:只对父批下已冻结的待核查子批
|
||||
// 做一次只读补查,然后尝试汇总父批。
|
||||
// 不重发办理请求、不清零自动补查计数、不重新开启轮询调度。
|
||||
func (e *executor) QueryReviewBatch(ctx context.Context, batchNo string) (int, error) {
|
||||
batchNo = strings.TrimSpace(batchNo)
|
||||
if batchNo == "" {
|
||||
return 0, fmt.Errorf("批次编号不能为空")
|
||||
}
|
||||
repo, ok := e.repository.(ReviewBatchRepository)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("未配置APN待核查批量同步仓储")
|
||||
}
|
||||
if _, ok := e.upstream.(AsyncUpstream); !ok {
|
||||
return 0, fmt.Errorf("未配置APN只读补查客户端")
|
||||
}
|
||||
ids, err := repo.ListReviewSubBatches(ctx, batchNo)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
queried := 0
|
||||
var firstErr error
|
||||
for _, id := range ids {
|
||||
if err := e.QueryReview(ctx, id); err != nil {
|
||||
g.Log().Warningf(ctx, "手动同步待核查子批未完成 batchNo=%s operationId=%s error=%v", batchNo, id, err)
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("operationId=%s: %w", id, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
queried++
|
||||
}
|
||||
// 已确认终态的子批会在这里收敛父批;仍为 review 的子批继续留在待核查。
|
||||
if err := repo.FinalizeReadyBatch(ctx, batchNo); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
g.Log().Infof(ctx, "手动同步APN上游结果 batchNo=%s 待核查子批=%d 本次已同步=%d", batchNo, len(ids), queried)
|
||||
if firstErr != nil {
|
||||
return queried, firstErr
|
||||
}
|
||||
return queried, nil
|
||||
}
|
||||
|
||||
func isAPNRequestNotSent(err error) bool {
|
||||
var notSent interface{ APNRequestNotSent() bool }
|
||||
return errors.As(err, ¬Sent) && notSent.APNRequestNotSent()
|
||||
}
|
||||
func releaseUnsent(ctx context.Context, repo SubBatchRepository, sub SubBatch, cause error) error {
|
||||
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
return repo.ReleaseUnsentSubBatch(persistCtx, sub, cause.Error())
|
||||
}
|
||||
|
||||
func nextResultCheck(sub SubBatch) time.Time {
|
||||
base := sub.SubmittedAt
|
||||
if base.IsZero() {
|
||||
base = time.Now()
|
||||
}
|
||||
return base.Add(firstResultDelay + time.Duration(sub.PollCount)*furtherResultDelay)
|
||||
}
|
||||
|
||||
func (e *executor) rememberUncertain(ctx context.Context, repo SubBatchRepository, sub SubBatch, reason string) error {
|
||||
return e.deferResult(ctx, repo, sub, "uncertain", reason)
|
||||
}
|
||||
|
||||
func (e *executor) queryUnavailable(ctx context.Context, repo SubBatchRepository, sub SubBatch, reason string) error {
|
||||
return e.deferResult(ctx, repo, sub, "accepted", reason)
|
||||
}
|
||||
|
||||
func (e *executor) deferResult(ctx context.Context, repo SubBatchRepository, sub SubBatch, state, reason string) error {
|
||||
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
g.Log().Warningf(persistCtx, "APN补查 operationId=%s jobId=%s pollCount=%d reason=%s", sub.OperationID, sub.JobID, sub.PollCount, reason)
|
||||
if sub.Status == "review_query" || sub.PollCount >= maxResultQueries {
|
||||
if err := repo.UpdateSubBatch(persistCtx, sub, "review", sub.JobID, "补查窗口结束,办理结果未知:"+reason, time.Time{}, sub.PollCount); err != nil {
|
||||
return err
|
||||
}
|
||||
return repo.FinalizeReadyBatch(persistCtx, sub.BatchNo)
|
||||
}
|
||||
return repo.UpdateSubBatch(persistCtx, sub, state, sub.JobID, reason, nextResultCheck(sub), sub.PollCount)
|
||||
}
|
||||
|
||||
func (e *executor) handleResponse(ctx context.Context, repo SubBatchRepository, sub SubBatch, items []model.SimBaseRes[model.SimCardApnChangeOutputItem], queried bool) error {
|
||||
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
|
||||
defer cancel()
|
||||
outcomes := make([]Outcome, len(sub.Request.SimKeys))
|
||||
used := make([]bool, len(items))
|
||||
pending := false
|
||||
reason := "未返回该卡结果,办理未确认"
|
||||
for i, key := range sub.Request.SimKeys {
|
||||
matched := -1
|
||||
for j, item := range items {
|
||||
if !used[j] && sameSim(&Detail{ICCID: key.ICCID, MSISDN: key.MSISDN, IMSI: key.IMSI}, item.SimKey) {
|
||||
matched = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched < 0 {
|
||||
outcomes[i] = Outcome{Pending: true, Message: reason}
|
||||
pending = true
|
||||
continue
|
||||
}
|
||||
used[matched] = true
|
||||
item := items[matched]
|
||||
if item.ResData.Pending || item.ResData.Uncertain {
|
||||
pending = true
|
||||
outcomes[i] = Outcome{Pending: true, Code: item.Code, Message: item.Message}
|
||||
if strings.TrimSpace(item.Message) != "" {
|
||||
reason = item.Message
|
||||
}
|
||||
// Only Pending identifies a batch job: a synchronous per-card order is never queried as a job.
|
||||
if item.ResData.Pending && strings.TrimSpace(item.ResData.OrderNum) != "" {
|
||||
sub.JobID = strings.TrimSpace(item.ResData.OrderNum)
|
||||
}
|
||||
continue
|
||||
}
|
||||
outcomes[i] = Outcome{Success: item.Code == 0, Code: item.Code, Message: item.Message, OrderNum: item.ResData.OrderNum}
|
||||
}
|
||||
if pending {
|
||||
// Confirmed cards are committed now. Later missing/pending responses cannot overwrite their terminal rows.
|
||||
if err := repo.SaveSubBatchProgress(persistCtx, sub, outcomes); err != nil {
|
||||
return err
|
||||
}
|
||||
state := "accepted"
|
||||
if sub.JobID == "" {
|
||||
state = "uncertain"
|
||||
reason += "(缺少jobId,仅按operationId恢复,不重发)"
|
||||
}
|
||||
return e.deferResult(persistCtx, repo, sub, state, reason)
|
||||
}
|
||||
if err := repo.CompleteSubBatch(persistCtx, sub, outcomes); err != nil {
|
||||
return err
|
||||
}
|
||||
return repo.FinalizeReadyBatch(persistCtx, sub.BatchNo)
|
||||
}
|
||||
|
||||
func mapOutcomesFromKeys(keys []*model.SimKey, response []model.SimBaseRes[model.SimCardApnChangeOutputItem]) []Outcome {
|
||||
details := make([]*Detail, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
details = append(details, &Detail{ICCID: key.ICCID, MSISDN: key.MSISDN, IMSI: key.IMSI})
|
||||
}
|
||||
return mapOutcomes(details, response)
|
||||
}
|
||||
|
||||
// RunReconciler uses a fixed global worker bound; waiting for a job never retains a worker slot.
|
||||
func (e *executor) RunReconciler(ctx context.Context) error {
|
||||
repo, ok := e.repository.(SubBatchRepository)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
upstream, ok := e.upstream.(AsyncUpstream)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
jobs := make(chan string, 64)
|
||||
var inFlight sync.Map
|
||||
var workers sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case id := <-jobs:
|
||||
if err := e.processSubBatch(ctx, repo, upstream, id); err != nil {
|
||||
g.Log().Errorf(ctx, "APN恢复失败 operationId=%s error=%v", id, err)
|
||||
}
|
||||
inFlight.Delete(id)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
defer workers.Wait()
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
batches, err := repo.ListRecoverableBatches(ctx, 32)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "读取APN恢复父批失败: %v", err)
|
||||
} else {
|
||||
for _, batchNo := range batches {
|
||||
if err := e.Execute(ctx, batchNo); err != nil {
|
||||
g.Log().Errorf(ctx, "恢复APN父批失败 batchNo=%s error=%v", batchNo, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
ids, err := repo.ListDueSubBatches(ctx, time.Now(), 128)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "读取APN补查子批失败: %v", err)
|
||||
} else {
|
||||
for _, id := range ids {
|
||||
if _, loaded := inFlight.LoadOrStore(id, true); loaded {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case jobs <- id:
|
||||
case <-ctx.Done():
|
||||
inFlight.Delete(id)
|
||||
return nil
|
||||
default:
|
||||
inFlight.Delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
type fairnessRepository struct{ *durableFake }
|
||||
|
||||
func (f *fairnessRepository) Load(context.Context, string) (*Task, error) {
|
||||
return nil, errors.New("公平性测试不应准备父批")
|
||||
}
|
||||
func (f *fairnessRepository) SaveValidationFailure(context.Context, *Task) error { return nil }
|
||||
func (f *fairnessRepository) SaveResult(context.Context, *Task, *Detail, Outcome) (Outcome, error) {
|
||||
return Outcome{}, nil
|
||||
}
|
||||
func (f *fairnessRepository) Finalize(context.Context, *Task) error { return nil }
|
||||
|
||||
type fairnessUpstream struct {
|
||||
asyncFake
|
||||
rejected atomic.Int32
|
||||
}
|
||||
|
||||
func (f *fairnessUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return nil, errors.New("调度不得走同步办理")
|
||||
}
|
||||
|
||||
func (f *fairnessUpstream) CheckSimApnAsyncCapability(_ context.Context, in *model.SimCardApnChangeInput) error {
|
||||
if in.SimKeys[0].ChanKey == "removed" {
|
||||
f.rejected.Add(1)
|
||||
return errors.New("通道已移除,无protocolVersion")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDurableAPNPrecheckBackoffAllowsHealthy129thTask(t *testing.T) {
|
||||
repo := newDurableFake()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
for i := 0; i < 129; i++ {
|
||||
sub := fixtureSub()
|
||||
sub.OperationID = fmt.Sprintf("fair-%03d", i)
|
||||
sub.Request.ReqData.OperationID = sub.OperationID
|
||||
sub.ChanKey = "removed"
|
||||
if i == 128 {
|
||||
sub.ChanKey = "healthy"
|
||||
}
|
||||
for _, key := range sub.Request.SimKeys {
|
||||
key.ChanKey = sub.ChanKey
|
||||
}
|
||||
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
completed := make(chan struct{}, 1)
|
||||
upstream := &fairnessUpstream{}
|
||||
upstream.submit = func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if in.SimKeys[0].ChanKey != "healthy" {
|
||||
t.Error("拒绝通道发出办理")
|
||||
}
|
||||
out := pendingResponse(in, 0)
|
||||
for i := range out {
|
||||
out[i].ResData.Pending = false
|
||||
}
|
||||
completed <- struct{}{}
|
||||
return out, nil
|
||||
}
|
||||
e := &executor{repository: &fairnessRepository{repo}, upstream: upstream}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- e.RunReconciler(ctx) }()
|
||||
select {
|
||||
case <-completed:
|
||||
case <-time.After(15 * time.Second):
|
||||
cancel()
|
||||
<-done
|
||||
t.Fatal("前128个预检拒绝使第129健康任务饥饿")
|
||||
}
|
||||
// 健康提交可能早于已入队的拒绝任务完成;等待退避持久化后再取消调度器。
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
timeout := time.NewTimer(15 * time.Second)
|
||||
defer timeout.Stop()
|
||||
for {
|
||||
repo.mu.Lock()
|
||||
delayed := 0
|
||||
for id, sub := range repo.batches {
|
||||
if sub.ChanKey == "removed" && !repo.next[id].IsZero() {
|
||||
delayed++
|
||||
}
|
||||
}
|
||||
repo.mu.Unlock()
|
||||
if delayed == 128 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-timeout.C:
|
||||
cancel()
|
||||
<-done
|
||||
t.Fatalf("拒绝任务退避未全部持久化: delayed=%d rejected=%d", delayed, upstream.rejected.Load())
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.submitCalls != 1 || upstream.rejected.Load() != 128 {
|
||||
t.Fatalf("submit=%d rejected=%d", upstream.submitCalls, upstream.rejected.Load())
|
||||
}
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
for id, sub := range repo.batches {
|
||||
if sub.ChanKey == "healthy" {
|
||||
continue
|
||||
}
|
||||
if sub.Status != "prepared" || sub.PollCount != 0 || !sub.SubmittedAt.IsZero() || sub.LeaseToken != "" || repo.next[id].IsZero() {
|
||||
t.Fatalf("只读退避改变办理意图: %+v", sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
// reviewBatchFake 复用 durableFake 的持久化语义,只补手动同步需要的仓储能力。
|
||||
type reviewBatchFake struct {
|
||||
*durableFake
|
||||
candidates []string
|
||||
finalized []string
|
||||
}
|
||||
|
||||
func newReviewBatchFake(candidates ...string) *reviewBatchFake {
|
||||
return &reviewBatchFake{durableFake: newDurableFake(), candidates: candidates}
|
||||
}
|
||||
|
||||
func (f *reviewBatchFake) ListReviewSubBatches(_ context.Context, _ string) ([]string, error) {
|
||||
return append([]string(nil), f.candidates...), nil
|
||||
}
|
||||
|
||||
// 手动同步不触碰校验/终态写入路径,这里只补齐 Repository 契约。
|
||||
func (f *reviewBatchFake) Load(context.Context, string) (*Task, error) { return &Task{}, nil }
|
||||
func (f *reviewBatchFake) SaveValidationFailure(context.Context, *Task) error { return nil }
|
||||
func (f *reviewBatchFake) SaveResult(context.Context, *Task, *Detail, Outcome) (Outcome, error) {
|
||||
return Outcome{}, nil
|
||||
}
|
||||
func (f *reviewBatchFake) Finalize(context.Context, *Task) error { return nil }
|
||||
|
||||
// manualSyncUpstream 补上同步办理入口,确保手动同步一旦误走同步路径立即暴露。
|
||||
type manualSyncUpstream struct {
|
||||
*asyncFake
|
||||
}
|
||||
|
||||
func (u *manualSyncUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return nil, errors.New("手动同步不得调用同步办理入口")
|
||||
}
|
||||
|
||||
func (f *reviewBatchFake) FinalizeReadyBatch(_ context.Context, batchNo string) error {
|
||||
f.finalized = append(f.finalized, batchNo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *reviewBatchFake) ClaimReviewSubBatch(_ context.Context, operationID, token string, now time.Time) (*SubBatch, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
sub, ok := f.batches[operationID]
|
||||
if !ok || (sub.Status != "review" && sub.Status != "review_query") {
|
||||
return nil, nil
|
||||
}
|
||||
sub.Status = "review_query"
|
||||
sub.LeaseToken = token
|
||||
sub.LeaseUntil = now.Add(subBatchLease)
|
||||
f.batches[operationID] = sub
|
||||
out := sub
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// 手动同步只用既有受理号只读补查:不重发办理、不清零自动补查计数。
|
||||
func TestManualSyncQueriesFrozenIntentsWithoutResubmitting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sub := fixtureSub()
|
||||
repo := newReviewBatchFake(sub.OperationID)
|
||||
sub.Status = "review"
|
||||
sub.JobID = "job-1"
|
||||
sub.PollCount = 4
|
||||
repo.batches[sub.OperationID] = sub
|
||||
|
||||
upstream := &manualSyncUpstream{asyncFake: &asyncFake{
|
||||
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
t.Fatal("手动同步不得重发办理")
|
||||
return nil, nil
|
||||
},
|
||||
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if in.ReqData.QueryJobID != "job-1" {
|
||||
t.Fatalf("手动同步必须按既有 jobId 查询: %+v", in.ReqData)
|
||||
}
|
||||
out := make([]model.SimBaseRes[model.SimCardApnChangeOutputItem], 0, len(in.SimKeys))
|
||||
for _, key := range in.SimKeys {
|
||||
out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: 0, ResData: model.SimCardApnChangeOutputItem{OrderNum: "job-1"}})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
}}
|
||||
executor := &executor{repository: repo, upstream: upstream}
|
||||
|
||||
queried, err := executor.QueryReviewBatch(ctx, " P-1 ")
|
||||
if err != nil {
|
||||
t.Fatalf("QueryReviewBatch() error = %v", err)
|
||||
}
|
||||
if queried != 1 {
|
||||
t.Fatalf("queried = %d, want 1", queried)
|
||||
}
|
||||
if upstream.submitCalls != 0 {
|
||||
t.Fatalf("submitCalls = %d, want 0", upstream.submitCalls)
|
||||
}
|
||||
got := repo.batches[sub.OperationID]
|
||||
if got.Status != "done" {
|
||||
t.Fatalf("状态 = %s, want done", got.Status)
|
||||
}
|
||||
if got.PollCount != 4 {
|
||||
t.Fatalf("pollCount = %d, want 4(手动同步不得清零自动补查计数)", got.PollCount)
|
||||
}
|
||||
// 子批收敛与批量入口都会尝试汇总父批,重复汇总是幂等的。
|
||||
if len(repo.finalized) == 0 || repo.finalized[0] != "P-1" {
|
||||
t.Fatalf("父批未汇总: %+v", repo.finalized)
|
||||
}
|
||||
if len(repo.outcomes[sub.OperationID]) != len(sub.DetailIDs) {
|
||||
t.Fatalf("逐卡结果未写回: %+v", repo.outcomes[sub.OperationID])
|
||||
}
|
||||
}
|
||||
|
||||
// 上游仍无结论时明细保持待核查,且可以再次手动同步。
|
||||
func TestManualSyncKeepsUnknownWhenUpstreamStillPending(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sub := fixtureSub()
|
||||
repo := newReviewBatchFake(sub.OperationID)
|
||||
sub.Status = "review"
|
||||
repo.batches[sub.OperationID] = sub
|
||||
|
||||
upstream := &manualSyncUpstream{asyncFake: &asyncFake{
|
||||
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
t.Fatal("手动同步不得重发办理")
|
||||
return nil, nil
|
||||
},
|
||||
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return pendingResponse(in, 14008), nil
|
||||
},
|
||||
}}
|
||||
executor := &executor{repository: repo, upstream: upstream}
|
||||
|
||||
if _, err := executor.QueryReviewBatch(ctx, "P-1"); err != nil {
|
||||
t.Fatalf("QueryReviewBatch() error = %v", err)
|
||||
}
|
||||
got := repo.batches[sub.OperationID]
|
||||
if got.Status != "review" {
|
||||
t.Fatalf("状态 = %s, want review(未确认不得记失败)", got.Status)
|
||||
}
|
||||
if got.JobID != "job-1" {
|
||||
t.Fatalf("jobId = %s, want job-1", got.JobID)
|
||||
}
|
||||
if got.PollCount != 0 {
|
||||
t.Fatalf("pollCount = %d, want 0(手动同步不得占用自动补查次数)", got.PollCount)
|
||||
}
|
||||
// 再次同步仍可用,说明没有被自动窗口锁死。
|
||||
if _, err := executor.QueryReviewBatch(ctx, "P-1"); err != nil {
|
||||
t.Fatalf("第二次 QueryReviewBatch() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 单个子批补查失败不影响其余子批,并把失败如实返回给调用方。
|
||||
func TestManualSyncReportsPerIntentFailureAndContinues(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
first := fixtureSub()
|
||||
first.Status = "review"
|
||||
first.JobID = "job-1"
|
||||
second := fixtureSub()
|
||||
second.OperationID = "intent-2"
|
||||
second.Status = "review"
|
||||
repo := newReviewBatchFake(first.OperationID, second.OperationID)
|
||||
repo.batches[first.OperationID] = first
|
||||
repo.batches[second.OperationID] = second
|
||||
|
||||
upstream := &manualSyncUpstream{asyncFake: &asyncFake{
|
||||
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
t.Fatal("手动同步不得重发办理")
|
||||
return nil, nil
|
||||
},
|
||||
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if in.ReqData.RecoverOnly {
|
||||
return nil, errors.New("上游查询不可用")
|
||||
}
|
||||
return pendingResponse(in, 0), nil
|
||||
},
|
||||
}}
|
||||
executor := &executor{repository: repo, upstream: upstream}
|
||||
|
||||
queried, err := executor.QueryReviewBatch(ctx, "P-1")
|
||||
if err == nil {
|
||||
t.Fatal("QueryReviewBatch() error = nil, want error")
|
||||
}
|
||||
if queried != 1 {
|
||||
t.Fatalf("queried = %d, want 1(成功一个,失败一个)", queried)
|
||||
}
|
||||
if upstream.submitCalls != 0 {
|
||||
t.Fatalf("submitCalls = %d, want 0", upstream.submitCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// 空批次号直接拒绝,避免误触发全量扫描。
|
||||
func TestManualSyncRejectsBlankBatchNo(t *testing.T) {
|
||||
executor := &executor{repository: newReviewBatchFake(), upstream: &manualSyncUpstream{asyncFake: &asyncFake{}}}
|
||||
if _, err := executor.QueryReviewBatch(context.Background(), " "); err == nil {
|
||||
t.Fatal("QueryReviewBatch() blank batchNo error = nil, want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
upstreamclient "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/client/upstream"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestDurableAPNResultScheduleIsThreeMinutesAndThreeRetries(t *testing.T) {
|
||||
if maxResultQueries != 4 {
|
||||
t.Fatalf("首次加三次重试应为四次查询: got=%d", maxResultQueries)
|
||||
}
|
||||
base := time.Date(2026, 10, 15, 8, 0, 0, 0, time.UTC)
|
||||
for i, minute := range []int{3, 6, 9, 12} {
|
||||
got := nextResultCheck(SubBatch{SubmittedAt: base, PollCount: i})
|
||||
want := base.Add(time.Duration(minute) * time.Minute)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("第%d次查询: got=%v want=%v", i+1, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type durableFake struct {
|
||||
mu sync.Mutex
|
||||
batches map[string]SubBatch
|
||||
next map[string]time.Time
|
||||
outcomes map[string][]Outcome
|
||||
preparedBeforeSubmit int
|
||||
}
|
||||
|
||||
func newDurableFake() *durableFake {
|
||||
return &durableFake{batches: map[string]SubBatch{}, next: map[string]time.Time{}, outcomes: map[string][]Outcome{}}
|
||||
}
|
||||
func (f *durableFake) HasDurableParent(context.Context, string) (bool, error) { return true, nil }
|
||||
func (f *durableFake) PrepareSubBatch(_ context.Context, b SubBatch) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.batches[b.OperationID]; !ok {
|
||||
b.ID = int64(len(f.batches) + 1)
|
||||
f.batches[b.OperationID] = b
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *durableFake) ResumeOrPrepare(ctx context.Context, _ string, p func(context.Context) error) error {
|
||||
return p(ctx)
|
||||
}
|
||||
func (f *durableFake) GetSubBatch(_ context.Context, id string) (*SubBatch, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
b := f.batches[id]
|
||||
b.NextCheckAt = f.next[id]
|
||||
return &b, nil
|
||||
}
|
||||
func (f *durableFake) DelaySubBatchPrecheck(_ context.Context, snapshot SubBatch, next time.Time, _ string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
b := f.batches[snapshot.OperationID]
|
||||
if b.Status == snapshot.Status && b.LeaseToken == snapshot.LeaseToken && f.next[snapshot.OperationID].Equal(snapshot.NextCheckAt) {
|
||||
f.next[snapshot.OperationID] = next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *durableFake) ReleaseUnsentSubBatch(_ context.Context, b SubBatch, _ string) error {
|
||||
old := f.batches[b.OperationID]
|
||||
old.Status = b.Status
|
||||
old.LeaseToken = ""
|
||||
if b.Status == "prepared" {
|
||||
old.SubmittedAt = time.Time{}
|
||||
} else {
|
||||
old.PollCount--
|
||||
}
|
||||
f.batches[b.OperationID] = old
|
||||
return nil
|
||||
}
|
||||
func (f *durableFake) SubBatchChannel(_ context.Context, id string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.batches[id].ChanKey, nil
|
||||
}
|
||||
func (f *durableFake) ProtectExecutionFailure(context.Context, string, string) error { return nil }
|
||||
func (f *durableFake) SaveSubBatchProgress(_ context.Context, b SubBatch, results []Outcome) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
old := f.outcomes[b.OperationID]
|
||||
if old == nil {
|
||||
old = make([]Outcome, len(results))
|
||||
for i := range old {
|
||||
old[i].Pending = true
|
||||
}
|
||||
}
|
||||
for i, result := range results {
|
||||
if old[i].Pending && !result.Pending {
|
||||
old[i] = result
|
||||
}
|
||||
}
|
||||
confirmed := false
|
||||
for _, result := range old {
|
||||
confirmed = confirmed || !result.Pending
|
||||
}
|
||||
if confirmed {
|
||||
f.outcomes[b.OperationID] = old
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *durableFake) ClaimSubBatch(_ context.Context, id, token string, now time.Time) (*SubBatch, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
b, ok := f.batches[id]
|
||||
if !ok || b.LeaseToken != "" || b.Status == "done" || b.Status == "review" || f.next[id].After(now) {
|
||||
return nil, nil
|
||||
}
|
||||
out := b
|
||||
b.LeaseToken = token
|
||||
out.LeaseToken = token
|
||||
if b.Status == "prepared" {
|
||||
b.Status = "submitting"
|
||||
b.SubmittedAt = now
|
||||
out.SubmittedAt = now
|
||||
} else {
|
||||
b.PollCount++
|
||||
out.PollCount = b.PollCount
|
||||
}
|
||||
f.batches[id] = b
|
||||
return &out, nil
|
||||
}
|
||||
func (f *durableFake) ListDueSubBatches(_ context.Context, now time.Time, limit int) ([]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var ids []string
|
||||
for id, b := range f.batches {
|
||||
if b.Status != "done" && b.Status != "review" && !f.next[id].After(now) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
a, b := f.next[ids[i]], f.next[ids[j]]
|
||||
if a.Equal(b) {
|
||||
return ids[i] < ids[j]
|
||||
}
|
||||
return a.Before(b)
|
||||
})
|
||||
if len(ids) > limit {
|
||||
ids = ids[:limit]
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
func (f *durableFake) UpdateSubBatch(_ context.Context, b SubBatch, status, job, reason string, next time.Time, count int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
old := f.batches[b.OperationID]
|
||||
if old.LeaseToken != b.LeaseToken {
|
||||
return errors.New("lease lost")
|
||||
}
|
||||
old.Status = status
|
||||
old.JobID = job
|
||||
old.PollCount = count
|
||||
old.LeaseToken = ""
|
||||
f.batches[b.OperationID] = old
|
||||
f.next[b.OperationID] = next
|
||||
return nil
|
||||
}
|
||||
func (f *durableFake) CompleteSubBatch(_ context.Context, b SubBatch, results []Outcome) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
old := f.batches[b.OperationID]
|
||||
if old.LeaseToken != b.LeaseToken {
|
||||
return errors.New("lease lost")
|
||||
}
|
||||
old.Status = "done"
|
||||
old.LeaseToken = ""
|
||||
f.batches[b.OperationID] = old
|
||||
f.outcomes[b.OperationID] = results
|
||||
return nil
|
||||
}
|
||||
func (f *durableFake) FinalizeReadyBatch(context.Context, string) error { return nil }
|
||||
func (f *durableFake) ListRecoverableBatches(context.Context, int) ([]string, error) { return nil, nil }
|
||||
|
||||
type asyncFake struct {
|
||||
submitCalls int
|
||||
queryCalls int
|
||||
submit func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
|
||||
query func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
|
||||
}
|
||||
|
||||
func (f *asyncFake) SubmitSimApnFunction(_ context.Context, in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
f.submitCalls++
|
||||
return f.submit(in)
|
||||
}
|
||||
func (f *asyncFake) QuerySimApnFunctionResult(_ context.Context, in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
f.queryCalls++
|
||||
return f.query(in)
|
||||
}
|
||||
func fixtureSub() SubBatch {
|
||||
op, _ := OperationByBatchType(consts.BatchTypeDataStop)
|
||||
return newSubBatch(&Task{Batch: Batch{BatchNo: "P-1", BatchType: consts.BatchTypeDataStop}, Operation: op, ApnName: "CMIOT"}, []*Detail{{ID: 1, ICCID: "iccid-1", MSISDN: "1001", ChanKey: "a"}, {ID: 2, ICCID: "iccid-2", MSISDN: "1002", ChanKey: "a"}})
|
||||
}
|
||||
func pendingResponse(in *model.SimCardApnChangeInput, code int) []model.SimBaseRes[model.SimCardApnChangeOutputItem] {
|
||||
var out []model.SimBaseRes[model.SimCardApnChangeOutputItem]
|
||||
for _, key := range in.SimKeys {
|
||||
out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: code, Message: "结果暂不可查", ResData: model.SimCardApnChangeOutputItem{OrderNum: "job-1", Pending: true}})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestDurableAPNRestartDoesNotSubmitAcceptedAgain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if repo.batches[sub.OperationID].Status != "submitting" {
|
||||
t.Fatal("提交前未保存意图")
|
||||
}
|
||||
return pendingResponse(in, 0), nil
|
||||
}, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if in.ReqData.QueryJobID != "job-1" {
|
||||
t.Fatalf("查询丢失jobId: %+v", in.ReqData)
|
||||
}
|
||||
out := pendingResponse(in, 0)
|
||||
out[0].ResData.Pending = false
|
||||
out[1].ResData.Pending = false
|
||||
out[1].Code = 12047
|
||||
out[1].Message = "明确失败"
|
||||
return out, nil
|
||||
}}
|
||||
e := &executor{}
|
||||
before := time.Now()
|
||||
if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.next[sub.OperationID].Before(before.Add(firstResultDelay)) || repo.next[sub.OperationID].After(time.Now().Add(firstResultDelay)) {
|
||||
t.Fatalf("首查未按firstResultDelay排程: %v", repo.next[sub.OperationID])
|
||||
}
|
||||
// A second process must respect next_check_at and never issue a second write.
|
||||
restarted := &executor{}
|
||||
if err := restarted.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.queryCalls != 0 {
|
||||
t.Fatal("过早查询")
|
||||
}
|
||||
repo.next[sub.OperationID] = time.Time{}
|
||||
if err := restarted.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.submitCalls != 1 || upstream.queryCalls != 1 {
|
||||
t.Fatalf("submit=%d query=%d", upstream.submitCalls, upstream.queryCalls)
|
||||
}
|
||||
outcomes := repo.outcomes[sub.OperationID]
|
||||
if len(outcomes) != 2 || !outcomes[0].Success || outcomes[1].Success {
|
||||
t.Fatalf("部分失败未逐卡保留: %+v", outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNTimeoutRecoversMappingWithoutResubmission(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
}, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if !in.ReqData.RecoverOnly || in.ReqData.QueryJobID != "" || in.ReqData.OperationID != sub.OperationID {
|
||||
t.Fatalf("不是按operationId恢复: %+v", in.ReqData)
|
||||
}
|
||||
return pendingResponse(in, 0), nil
|
||||
}}
|
||||
e := &executor{}
|
||||
if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.outcomes) != 0 {
|
||||
t.Fatal("超时不能标记办理失败")
|
||||
}
|
||||
repo.next[sub.OperationID] = time.Time{}
|
||||
if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.submitCalls != 1 || upstream.queryCalls != 1 || repo.batches[sub.OperationID].JobID != "job-1" {
|
||||
t.Fatalf("受理恢复错误: %+v", repo.batches[sub.OperationID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNPendingNonzeroStopsAfterQueryWindowWithoutFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
sub.Status = "accepted"
|
||||
sub.JobID = "job-1"
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return pendingResponse(in, 500), nil
|
||||
}}
|
||||
for i := 1; i <= maxResultQueries; i++ {
|
||||
repo.next[sub.OperationID] = time.Time{}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := repo.batches[sub.OperationID]
|
||||
if b.PollCount != i {
|
||||
t.Fatalf("poll count=%d want %d", b.PollCount, i)
|
||||
}
|
||||
if i < maxResultQueries && b.Status != "accepted" {
|
||||
t.Fatalf("pending误终态: %+v", b)
|
||||
}
|
||||
}
|
||||
if repo.batches[sub.OperationID].Status != "review" || len(repo.outcomes) != 0 {
|
||||
t.Fatal("补查窗口耗尽后未确认不得写办理失败")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNMixedResultsPersistAcrossMissingAndReview(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
out := pendingResponse(in, 0)
|
||||
out[0].ResData.Pending = false
|
||||
out[0].ResData.OrderNum = "single-order"
|
||||
return out, nil
|
||||
}, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return nil, nil
|
||||
}}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := repo.outcomes[sub.OperationID]; len(got) != 2 || !got[0].Success || !got[1].Pending {
|
||||
t.Fatalf("混合确认结果未立即保存: %+v", got)
|
||||
}
|
||||
base := repo.batches[sub.OperationID].SubmittedAt
|
||||
for i := 1; i <= maxResultQueries; i++ {
|
||||
repo.next[sub.OperationID] = time.Time{}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := repo.batches[sub.OperationID]
|
||||
if b.PollCount != i || !b.SubmittedAt.Equal(base) {
|
||||
t.Fatalf("重启丢失持久时点/次数: %+v", b)
|
||||
}
|
||||
if i < maxResultQueries && !repo.next[sub.OperationID].Equal(base.Add(firstResultDelay+time.Duration(i)*furtherResultDelay)) {
|
||||
t.Fatalf("排程未按submitted_at: %v", repo.next[sub.OperationID])
|
||||
}
|
||||
}
|
||||
if !repo.outcomes[sub.OperationID][0].Success || repo.batches[sub.OperationID].Status != "review" || upstream.submitCalls != 1 {
|
||||
t.Fatal("后续缺卡覆盖终态或重发")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNMappingRecoveryKeepsConsumedPollCount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
sub.Status = "uncertain"
|
||||
sub.PollCount = 2
|
||||
sub.SubmittedAt = time.Now().Add(-8 * time.Minute)
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if !in.ReqData.RecoverOnly {
|
||||
t.Fatal("恢复应为只读")
|
||||
}
|
||||
return pendingResponse(in, 0), nil
|
||||
}}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := repo.batches[sub.OperationID]
|
||||
if b.JobID != "job-1" || b.PollCount != 3 || !repo.next[sub.OperationID].Equal(sub.SubmittedAt.Add(firstResultDelay+time.Duration(b.PollCount)*furtherResultDelay)) {
|
||||
t.Fatalf("恢复jobId清零或重排: %+v next=%v", b, repo.next[sub.OperationID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNExplicitUncertainAndMissingNeverFailCard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return []model.SimBaseRes[model.SimCardApnChangeOutputItem]{{SimKey: in.SimKeys[0], Code: 9999, Message: "代理未确认", ResData: model.SimCardApnChangeOutputItem{Uncertain: true}}}, nil
|
||||
}}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.outcomes) != 0 || repo.batches[sub.OperationID].Status != "uncertain" {
|
||||
t.Fatal("显式未知或缺卡不能转明确失败")
|
||||
}
|
||||
}
|
||||
|
||||
type capabilityFake struct {
|
||||
*asyncFake
|
||||
checkErr error
|
||||
}
|
||||
|
||||
func (f *capabilityFake) CheckSimApnAsyncCapability(context.Context, *model.SimCardApnChangeInput) error {
|
||||
return f.checkErr
|
||||
}
|
||||
|
||||
type requestNotSent struct{}
|
||||
|
||||
func (requestNotSent) Error() string { return "能力预检明确拒绝" }
|
||||
func (requestNotSent) APNRequestNotSent() bool { return true }
|
||||
|
||||
func TestDurableAPNCapabilityRefusalDoesNotClaimOrSpendQueries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &capabilityFake{asyncFake: &asyncFake{}, checkErr: errors.New("门禁关闭")}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err == nil {
|
||||
t.Fatal("应拒绝预检")
|
||||
}
|
||||
b := repo.batches[sub.OperationID]
|
||||
if b.Status != "prepared" || b.LeaseToken != "" || !b.SubmittedAt.IsZero() || b.PollCount != 0 || upstream.submitCalls != 0 {
|
||||
t.Fatalf("预检拒绝却已领取或提交: %+v", b)
|
||||
}
|
||||
upstream.checkErr = nil
|
||||
upstream.submit = func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return pendingResponse(in, 0), nil
|
||||
}
|
||||
if upstream.submitCalls != 0 || !repo.next[sub.OperationID].After(time.Now()) {
|
||||
t.Fatal("只读预检拒绝未持久退避")
|
||||
}
|
||||
repo.next[sub.OperationID] = time.Time{}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.submitCalls != 1 || repo.batches[sub.OperationID].Status != "accepted" {
|
||||
t.Fatal("门禁打开且退避到期后不能恢复首发")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNCapabilityRefusalDoesNotBlockReadOnlyQueries(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
status string
|
||||
jobID string
|
||||
recoverOnly bool
|
||||
}{
|
||||
{name: "accepted_with_job_id", status: "accepted", jobID: "job-1"},
|
||||
{name: "submitting", status: "submitting", recoverOnly: true},
|
||||
{name: "uncertain", status: "uncertain", recoverOnly: true},
|
||||
{name: "accepted_without_job_id", status: "accepted", recoverOnly: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
sub.Status = tt.status
|
||||
sub.JobID = tt.jobID
|
||||
sub.SubmittedAt = time.Now().Add(-firstResultDelay)
|
||||
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
upstream := &capabilityFake{
|
||||
checkErr: errors.New("门禁关闭"),
|
||||
asyncFake: &asyncFake{
|
||||
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
t.Fatal("只读补查不得重新提交")
|
||||
return nil, nil
|
||||
},
|
||||
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if in.ReqData.OperationID != sub.OperationID || in.ReqData.QueryJobID != tt.jobID || in.ReqData.RecoverOnly != tt.recoverOnly {
|
||||
t.Fatalf("只读查询参数错误: %+v", in.ReqData)
|
||||
}
|
||||
return pendingResponse(in, 0), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatalf("只读查询被提交门禁阻断: %v", err)
|
||||
}
|
||||
if upstream.queryCalls != 1 || upstream.submitCalls != 0 {
|
||||
t.Fatalf("应仅查询一次: query=%d submit=%d", upstream.queryCalls, upstream.submitCalls)
|
||||
}
|
||||
b := repo.batches[sub.OperationID]
|
||||
if b.Status != "accepted" || b.JobID != "job-1" || b.PollCount != 1 || b.LeaseToken != "" {
|
||||
t.Fatalf("只读查询结果未持久化: %+v", b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNOnlyTypedUnsentCanReturnPrepared(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return nil, fmt.Errorf("预检竞态: %w", requestNotSent{})
|
||||
}}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := repo.batches[sub.OperationID]
|
||||
if b.Status != "prepared" || !b.SubmittedAt.IsZero() || b.PollCount != 0 {
|
||||
t.Fatalf("确定未发送应安全回退: %+v", b)
|
||||
}
|
||||
upstream.submit = func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.batches[sub.OperationID].Status != "uncertain" {
|
||||
t.Fatal("普通网络错误被误作确定未发送")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNFinishedFailure9999IsTerminal(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
sub.Status = "accepted"
|
||||
sub.JobID = "failed-job"
|
||||
sub.SubmittedAt = time.Now().Add(-time.Minute)
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
var out []model.SimBaseRes[model.SimCardApnChangeOutputItem]
|
||||
for _, key := range in.SimKeys {
|
||||
out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: 9999, Message: "任务已完成且明确失败"})
|
||||
}
|
||||
return out, nil
|
||||
}}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.batches[sub.OperationID].Status != "done" || len(repo.outcomes[sub.OperationID]) != 2 {
|
||||
t.Fatal("明确9999被误判待查")
|
||||
}
|
||||
for _, out := range repo.outcomes[sub.OperationID] {
|
||||
if out.Pending || out.Success || out.Code != 9999 {
|
||||
t.Fatalf("明确失败终态错误: %+v", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNRealClientSecondProbeRejectsThenSubmitsOnce(t *testing.T) {
|
||||
oldAdapter := g.Cfg().GetAdapter()
|
||||
defer g.Cfg().SetAdapter(oldAdapter)
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
probes, submits := 0, 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var in model.SimCardApnChangeInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
t.Error(err)
|
||||
http.Error(w, "bad", 400)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/sim/query/sim-apn-capability" {
|
||||
probes++
|
||||
if probes == 2 {
|
||||
http.Error(w, "第二次探测拒绝", 404)
|
||||
return
|
||||
}
|
||||
type capability struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
}
|
||||
out := make([]model.SimBaseRes[capability], 0, len(in.SimKeys))
|
||||
for _, key := range in.SimKeys {
|
||||
out = append(out, model.SimBaseRes[capability]{SimKey: key, ResData: capability{ProtocolVersion: 1}})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": out})
|
||||
return
|
||||
}
|
||||
submits++
|
||||
out := pendingResponse(&in, 0)
|
||||
for i := range out {
|
||||
out[i].ResData.Pending = false
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": out})
|
||||
}))
|
||||
defer server.Close()
|
||||
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":1}}}`, strings.TrimPrefix(server.URL, "http://")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
client := upstreamclient.New()
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := repo.batches[sub.OperationID]
|
||||
if probes != 2 || submits != 0 || b.Status != "prepared" || !b.SubmittedAt.IsZero() || b.LeaseToken != "" {
|
||||
t.Fatalf("双probe竞态烧掉意图: probes=%d submits=%d sub=%+v", probes, submits, b)
|
||||
}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if submits != 1 || repo.batches[sub.OperationID].Status != "done" {
|
||||
t.Fatalf("门禁恢复未唯一首发: submits=%d sub=%+v", submits, repo.batches[sub.OperationID])
|
||||
}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if submits != 1 {
|
||||
t.Fatal("完成后重复首发")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncSubBatchStableAndChannelIsolated(t *testing.T) {
|
||||
op, _ := OperationByBatchType(consts.BatchTypeDataStart)
|
||||
task := &Task{Batch: Batch{BatchNo: "P-2", BatchType: consts.BatchTypeDataStart}, Operation: op}
|
||||
groups := map[string][]*Detail{"a": {}, "b": {}}
|
||||
for i := 0; i < 203; i++ {
|
||||
key := "a"
|
||||
if i >= 201 {
|
||||
key = "b"
|
||||
}
|
||||
groups[key] = append(groups[key], &Detail{ID: int64(i + 1), ICCID: fmt.Sprint(i), MSISDN: fmt.Sprint(i), ChanKey: key})
|
||||
}
|
||||
total := 0
|
||||
for key, details := range groups {
|
||||
for _, chunk := range splitAsyncDetails(details) {
|
||||
if len(chunk) > 100 {
|
||||
t.Fatal("超过100卡")
|
||||
}
|
||||
first := newSubBatch(task, chunk)
|
||||
second := newSubBatch(task, chunk)
|
||||
if first.OperationID != second.OperationID {
|
||||
t.Fatal("operationId不稳定")
|
||||
}
|
||||
for _, sim := range first.Request.SimKeys {
|
||||
if sim.ChanKey != key {
|
||||
t.Fatal("跨通道子批")
|
||||
}
|
||||
}
|
||||
total++
|
||||
}
|
||||
}
|
||||
if total != 5 {
|
||||
t.Fatalf("sub batches=%d want5", total)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
"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/model/entity"
|
||||
)
|
||||
|
||||
type executor struct {
|
||||
repository Repository
|
||||
upstream Upstream
|
||||
channelSlots sync.Map
|
||||
}
|
||||
|
||||
func (e *executor) ClaimsProcessingReentry() bool { return true }
|
||||
func (e *executor) CanExecuteProcessing(ctx context.Context, main *entity.BatchMain) (bool, error) {
|
||||
repo, durable := e.repository.(SubBatchRepository)
|
||||
_, async := e.upstream.(AsyncUpstream)
|
||||
if !durable || !async {
|
||||
return false, nil
|
||||
}
|
||||
return repo.HasDurableParent(ctx, strings.TrimSpace(main.BatchNo))
|
||||
}
|
||||
func (e *executor) ProtectExecutionFailure(ctx context.Context, batchNo string, batchType int, reason string) (bool, error) {
|
||||
repo, durable := e.repository.(SubBatchRepository)
|
||||
_, async := e.upstream.(AsyncUpstream)
|
||||
if !durable || !async {
|
||||
return false, nil
|
||||
}
|
||||
return true, repo.ProtectExecutionFailure(ctx, batchNo, reason)
|
||||
}
|
||||
|
||||
func NewExecutor(repository Repository, upstream Upstream) Executor {
|
||||
return &executor{repository: repository, upstream: upstream}
|
||||
}
|
||||
|
||||
func (e *executor) Execute(ctx context.Context, batchNo string) error {
|
||||
if durable, ok := e.repository.(SubBatchRepository); ok {
|
||||
if _, ok := e.upstream.(AsyncUpstream); ok {
|
||||
// Earlier child intents were durably frozen: a re-delivered parent cannot re-split running rows.
|
||||
return batch.Retryable(durable.ResumeOrPrepare(ctx, strings.TrimSpace(batchNo), func(prepareCtx context.Context) error {
|
||||
return e.executeFresh(prepareCtx, batchNo)
|
||||
}))
|
||||
}
|
||||
}
|
||||
return e.executeFresh(ctx, batchNo)
|
||||
}
|
||||
|
||||
func (e *executor) executeFresh(ctx context.Context, batchNo string) error {
|
||||
task, err := e.repository.Load(ctx, strings.TrimSpace(batchNo))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load data service batch")
|
||||
}
|
||||
if task.Operation.BatchType == consts.BatchTypeDataCut {
|
||||
return e.executeTrafficCutoff(ctx, task)
|
||||
}
|
||||
if !validateTask(task) {
|
||||
if err = e.repository.SaveValidationFailure(ctx, task); err != nil {
|
||||
return gerror.Wrap(err, "save data service validation failure")
|
||||
}
|
||||
if _, durable := e.repository.(SubBatchRepository); durable {
|
||||
if _, async := e.upstream.(AsyncUpstream); async {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return gerror.Wrap(e.repository.Finalize(ctx, task), "finalize data service validation failure")
|
||||
}
|
||||
|
||||
return e.executeValidated(ctx, task)
|
||||
}
|
||||
|
||||
func (e *executor) executeTrafficCutoff(ctx context.Context, task *Task) error {
|
||||
if !validateTrafficCutoffTask(task) {
|
||||
if err := e.repository.SaveValidationFailure(ctx, task); err != nil {
|
||||
return gerror.Wrap(err, "save traffic cutoff validation failure")
|
||||
}
|
||||
if _, durable := e.repository.(SubBatchRepository); durable {
|
||||
if _, async := e.upstream.(AsyncUpstream); async {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return gerror.Wrap(e.repository.Finalize(ctx, task), "finalize traffic cutoff validation failure")
|
||||
}
|
||||
if err := e.refreshDataServiceState(ctx, task); err != nil {
|
||||
return gerror.Wrap(err, "refresh data service state")
|
||||
}
|
||||
if err := e.saveInvalidTrafficCutoffRows(ctx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, durable := e.repository.(SubBatchRepository); durable {
|
||||
if _, async := e.upstream.(AsyncUpstream); async {
|
||||
// 本地disabled不能证明前序开通已终结;持久化路径统一经过卡级提交屏障。
|
||||
return e.executeValidated(ctx, task)
|
||||
}
|
||||
}
|
||||
if err := e.saveIdempotentCutoffSkips(ctx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.executeValidated(ctx, task)
|
||||
}
|
||||
|
||||
func (e *executor) executeValidated(ctx context.Context, task *Task) error {
|
||||
if durable, ok := e.repository.(SubBatchRepository); ok {
|
||||
if async, ok := e.upstream.(AsyncUpstream); ok {
|
||||
seen := map[string]bool{}
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil || detail.Reason != "" || (detail.DetailStatus != consts.BatchDetailStatusPending && detail.DetailStatus != consts.BatchDetailStatusRunning) {
|
||||
continue
|
||||
}
|
||||
if seen[detail.ICCID] {
|
||||
resolved, err := e.repository.SaveResult(ctx, task, detail, Outcome{Message: "重复ICCID,未提交上游"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
detail.ApplyOutcome(resolved)
|
||||
continue
|
||||
}
|
||||
seen[detail.ICCID] = true
|
||||
}
|
||||
return e.prepareAndSubmit(ctx, task, durable, async)
|
||||
}
|
||||
}
|
||||
groups := groupByChanKey(task.Operation, task.Details)
|
||||
keys := make([]string, 0, len(groups))
|
||||
for chanKey := range groups {
|
||||
keys = append(keys, chanKey)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, chanKey := range keys {
|
||||
if err := e.changeGroup(ctx, task, groups[chanKey]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return gerror.Wrap(e.repository.Finalize(ctx, task), "finalize data service batch")
|
||||
}
|
||||
|
||||
type dataServiceStateRefresher interface {
|
||||
RefreshDataServiceState(ctx context.Context, task *Task) error
|
||||
}
|
||||
|
||||
func (e *executor) refreshDataServiceState(ctx context.Context, task *Task) error {
|
||||
refresher, ok := e.repository.(dataServiceStateRefresher)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return refresher.RefreshDataServiceState(ctx, task)
|
||||
}
|
||||
|
||||
func (e *executor) saveIdempotentCutoffSkips(ctx context.Context, task *Task) error {
|
||||
if task == nil || task.Operation.BatchType != consts.BatchTypeDataCut {
|
||||
return nil
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if !skipUpstreamForIdempotentCutoff(task.Operation, detail) {
|
||||
continue
|
||||
}
|
||||
resolved, saveErr := e.repository.SaveResult(ctx, task, detail, Outcome{
|
||||
Success: true,
|
||||
Message: "数据服务已关闭,未发起上游请求",
|
||||
})
|
||||
if saveErr != nil {
|
||||
return gerror.Wrapf(saveErr, "save data service detail %d idempotent skip result", detail.ID)
|
||||
}
|
||||
detail.ApplyOutcome(resolved)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTask(task *Task) bool {
|
||||
if task == nil || len(task.Details) == 0 {
|
||||
if task != nil && task.FailureReason == "" {
|
||||
task.FailureReason = "数据开停批次没有有效明细"
|
||||
}
|
||||
return false
|
||||
}
|
||||
hasFailure := false
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
hasFailure = true
|
||||
continue
|
||||
}
|
||||
validateDetail(task.Operation, detail)
|
||||
if detail.Reason != "" {
|
||||
hasFailure = true
|
||||
}
|
||||
}
|
||||
if !hasFailure {
|
||||
task.ValidationOK = true
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil {
|
||||
detail.ValidationPassed = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && detail.Reason == "" {
|
||||
detail.Reason = "同批次存在校验失败卡,本卡未请求上游"
|
||||
}
|
||||
}
|
||||
task.FailureReason = "批次校验未通过,未发起上游请求"
|
||||
return false
|
||||
}
|
||||
|
||||
func validateTrafficCutoffTask(task *Task) bool {
|
||||
if task == nil || len(task.Details) == 0 {
|
||||
if task != nil {
|
||||
task.FailureReason = "流量断网批次无有效明细"
|
||||
}
|
||||
return false
|
||||
}
|
||||
hasSuccess := false
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
validateDetail(task.Operation, detail)
|
||||
detail.ValidationPassed = detail.Reason == ""
|
||||
if detail.ValidationPassed {
|
||||
hasSuccess = true
|
||||
}
|
||||
}
|
||||
if hasSuccess {
|
||||
task.ValidationOK = true
|
||||
if failed := countFailedDetails(task); failed > 0 {
|
||||
task.FailureReason = strconv.Itoa(failed) + " 张卡未通过校验"
|
||||
} else {
|
||||
task.FailureReason = ""
|
||||
}
|
||||
return true
|
||||
}
|
||||
task.FailureReason = "流量断网批次校验未通过,未发起上游请求"
|
||||
return false
|
||||
}
|
||||
|
||||
func countFailedDetails(task *Task) int {
|
||||
if task == nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil || detail.Reason != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func validateDetail(_ Operation, detail *Detail) {
|
||||
if detail.Reason != "" {
|
||||
return
|
||||
}
|
||||
if detail.Card == nil {
|
||||
detail.Reason = "ICCID不存在或不属于当前租户"
|
||||
return
|
||||
}
|
||||
detail.MSISDN = strings.TrimSpace(detail.Card.MSISDN)
|
||||
detail.IMSI = strings.TrimSpace(detail.Card.IMSI)
|
||||
detail.ChanKey = strings.TrimSpace(detail.Card.ChanKey)
|
||||
detail.StatusBefore = detail.Card.DataServiceStatus
|
||||
detail.StatusAfter = detail.Card.DataServiceStatus
|
||||
|
||||
if !detail.Authorized {
|
||||
detail.Reason = "ICCID不在当前操作员授权范围内"
|
||||
return
|
||||
}
|
||||
var reasons []string
|
||||
if detail.ChanKey == "" {
|
||||
reasons = append(reasons, "ICCID缺少通道标识")
|
||||
}
|
||||
if detail.InputMSISDN != "" && detail.InputMSISDN != detail.MSISDN {
|
||||
reasons = append(reasons, "MSISDN与卡档案不一致")
|
||||
}
|
||||
if detail.InputIMSI != "" && detail.InputIMSI != detail.IMSI {
|
||||
reasons = append(reasons, "IMSI与卡档案不一致")
|
||||
}
|
||||
detail.Reason = strings.Join(reasons, ";")
|
||||
}
|
||||
|
||||
func groupByChanKey(operation Operation, details []*Detail) map[string][]*Detail {
|
||||
groups := make(map[string][]*Detail)
|
||||
for _, detail := range details {
|
||||
if detail == nil || detail.Reason != "" {
|
||||
continue
|
||||
}
|
||||
if operation.BatchType == consts.BatchTypeDataCut && (detail == nil || !detail.ValidationPassed || detail.Reason != "") {
|
||||
continue
|
||||
}
|
||||
if skipUpstreamForIdempotentCutoff(operation, detail) {
|
||||
continue
|
||||
}
|
||||
groups[detail.ChanKey] = append(groups[detail.ChanKey], detail)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func (e *executor) saveInvalidTrafficCutoffRows(ctx context.Context, task *Task) error {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil || detail.ValidationPassed || detail.Reason == "" {
|
||||
continue
|
||||
}
|
||||
resolved, saveErr := e.repository.SaveResult(ctx, task, detail, Outcome{
|
||||
Message: detail.Reason,
|
||||
})
|
||||
if saveErr != nil {
|
||||
return gerror.Wrapf(saveErr, "save traffic cutoff detail %d validation result", detail.ID)
|
||||
}
|
||||
detail.ApplyOutcome(resolved)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skipUpstreamForIdempotentCutoff(operation Operation, detail *Detail) bool {
|
||||
return operation.BatchType == consts.BatchTypeDataCut &&
|
||||
detail != nil &&
|
||||
detail.ValidationPassed &&
|
||||
detail.StatusBefore == consts.SimDataServiceStatusDisabled &&
|
||||
detail.StatusAfter == consts.SimDataServiceStatusDisabled
|
||||
}
|
||||
|
||||
// changeGroup 把同一通道下的明细切成子批次并发调用上游,并在**每个子批次拿到结果后立即落库该子批的明细**。
|
||||
//
|
||||
// 不再等所有子批次跑完才统一写库:大批量(如 2 万张 = 200 个子批)执行时,执行日志可以按子批
|
||||
// 实时反映进度,而不是在整个批次结束前始终全部停留在"执行中"。子批之间相互隔离——某个子批
|
||||
// 落库失败只记录错误、不影响其他子批继续办理与落库。
|
||||
func (e *executor) changeGroup(ctx context.Context, task *Task, details []*Detail) error {
|
||||
subBatches := splitDetails(details, consts.BatchSubBatchSize)
|
||||
|
||||
sem := make(chan struct{}, consts.BatchSubBatchConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var firstErr error
|
||||
|
||||
for _, sub := range subBatches {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(batch []*Detail) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if err := e.changeAndSaveSubBatch(ctx, task, batch); err != nil {
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}(sub)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// changeAndSaveSubBatch 处理单个子批次:构建请求、调用上游,然后逐条落库本子批的明细。
|
||||
// 明细结果只在所属子批次落库,天然做到"查到一个子批就落库一个子批"。
|
||||
func (e *executor) changeAndSaveSubBatch(ctx context.Context, task *Task, details []*Detail) error {
|
||||
for index, outcome := range e.changeSubBatch(ctx, task, details) {
|
||||
detail := details[index]
|
||||
resolved, saveErr := e.repository.SaveResult(ctx, task, detail, outcome)
|
||||
if saveErr != nil {
|
||||
return gerror.Wrapf(saveErr, "save data service detail %d result", detail.ID)
|
||||
}
|
||||
detail.ApplyOutcome(resolved)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// changeSubBatch 处理单个子批次:构建请求并调用上游,返回与该子批次明细等长的 Outcome 切片。
|
||||
func (e *executor) changeSubBatch(ctx context.Context, task *Task, details []*Detail) []Outcome {
|
||||
input := &model.SimCardApnChangeInput{
|
||||
SimKeys: make([]*model.SimKey, 0, len(details)),
|
||||
ReqData: model.SimCardApnChangeInputItem{OperType: task.Operation.ApnOperType, ApnName: task.ApnName},
|
||||
}
|
||||
for _, detail := range details {
|
||||
input.SimKeys = append(input.SimKeys, &model.SimKey{ChanKey: detail.ChanKey, ICCID: detail.ICCID, MSISDN: detail.MSISDN, IMSI: detail.IMSI})
|
||||
}
|
||||
response, err := e.upstream.ChangeSimApnFunction(ctx, input)
|
||||
if err != nil {
|
||||
outcomes := make([]Outcome, len(details))
|
||||
for index := range outcomes {
|
||||
outcomes[index].Message = "请求上游失败:" + err.Error()
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
return mapOutcomes(details, response)
|
||||
}
|
||||
|
||||
// splitDetails 按固定大小把明细切成有序的子批次;空输入返回空切片。
|
||||
func splitDetails(details []*Detail, size int) [][]*Detail {
|
||||
if size <= 0 {
|
||||
size = 1
|
||||
}
|
||||
batches := make([][]*Detail, 0, (len(details)+size-1)/size)
|
||||
for i := 0; i < len(details); i += size {
|
||||
end := i + size
|
||||
if end > len(details) {
|
||||
end = len(details)
|
||||
}
|
||||
batches = append(batches, details[i:end])
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
func mapOutcomes(details []*Detail, response []model.SimBaseRes[model.SimCardApnChangeOutputItem]) []Outcome {
|
||||
outcomes := make([]Outcome, len(details))
|
||||
used := make([]bool, len(response))
|
||||
for detailIndex, detail := range details {
|
||||
matched := -1
|
||||
for responseIndex, item := range response {
|
||||
if !used[responseIndex] && sameSim(detail, item.SimKey) {
|
||||
matched = responseIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched < 0 && detailIndex < len(response) && !used[detailIndex] && response[detailIndex].SimKey == nil {
|
||||
matched = detailIndex
|
||||
}
|
||||
if matched < 0 {
|
||||
outcomes[detailIndex] = Outcome{Message: "上游未返回该卡处理结果"}
|
||||
continue
|
||||
}
|
||||
used[matched] = true
|
||||
item := response[matched]
|
||||
message := strings.TrimSpace(item.Message)
|
||||
if item.Code != 0 && message == "" {
|
||||
message = "上游处理失败"
|
||||
}
|
||||
outcomes[detailIndex] = Outcome{Success: item.Code == 0, Code: item.Code, Message: message, OrderNum: strings.TrimSpace(item.ResData.OrderNum)}
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
func sameSim(detail *Detail, key *model.SimKey) bool {
|
||||
if detail == nil || key == nil {
|
||||
return false
|
||||
}
|
||||
if value := strings.TrimSpace(key.ICCID); value != "" {
|
||||
return value == detail.ICCID
|
||||
}
|
||||
if value := strings.TrimSpace(key.MSISDN); value != "" {
|
||||
return value == detail.MSISDN
|
||||
}
|
||||
if value := strings.TrimSpace(key.IMSI); value != "" {
|
||||
return value == detail.IMSI
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func OperationByBatchType(batchType int) (Operation, error) {
|
||||
switch batchType {
|
||||
case consts.BatchTypeDataStart:
|
||||
return Operation{BatchType: batchType, Name: "dataStart", ApnOperType: consts.SimApnOperTypeOpen, TargetStatus: consts.SimDataServiceStatusEnabled}, nil
|
||||
case consts.BatchTypeDataCut:
|
||||
return Operation{BatchType: batchType, Name: "trafficCutoff", ApnOperType: consts.SimApnOperTypeStop, TargetStatus: consts.SimDataServiceStatusDisabled}, nil
|
||||
case consts.BatchTypeDataStop:
|
||||
return Operation{BatchType: batchType, Name: "dataStop", ApnOperType: consts.SimApnOperTypeStop, TargetStatus: consts.SimDataServiceStatusDisabled}, nil
|
||||
default:
|
||||
return Operation{}, gerror.Newf("unsupported data service batch type %d", batchType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"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/model/entity"
|
||||
)
|
||||
|
||||
func TestAccuracyInvalidDuplicateKeepsValidTrafficCutoffRow(t *testing.T) {
|
||||
for _, invalidFirst := range []bool{true, false} {
|
||||
for _, invalidInput := range []string{"MSISDN", "IMSI", "authorization", "existing"} {
|
||||
t.Run(fmt.Sprintf("%s/invalidFirst=%v", invalidInput, invalidFirst), func(t *testing.T) {
|
||||
operation, err := OperationByBatchType(107)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card := &Card{ID: 10, ICCID: "same-card", MSISDN: "1001", IMSI: "2001", ChanKey: "channel", DataServiceStatus: 1}
|
||||
invalid := &Detail{ID: 1, ICCID: card.ICCID, Authorized: true, Card: card}
|
||||
var wantReason string
|
||||
switch invalidInput {
|
||||
case "MSISDN":
|
||||
invalid.InputMSISDN = "wrong"
|
||||
wantReason = "MSISDN与卡档案不一致"
|
||||
case "IMSI":
|
||||
invalid.InputIMSI = "wrong"
|
||||
wantReason = "IMSI与卡档案不一致"
|
||||
case "authorization":
|
||||
invalid.Authorized = false
|
||||
wantReason = "ICCID不在当前操作员授权范围内"
|
||||
case "existing":
|
||||
wantReason = "导入行格式校验失败"
|
||||
invalid.Reason = wantReason
|
||||
}
|
||||
valid := &Detail{ID: 2, ICCID: card.ICCID, Authorized: true, Card: card}
|
||||
otherInvalid := &Detail{ID: 3, ICCID: card.ICCID, Reason: "该行独立校验失败"}
|
||||
duplicate := &Detail{ID: 4, ICCID: card.ICCID, Authorized: true, Card: card}
|
||||
details := []*Detail{invalid, valid, otherInvalid, duplicate}
|
||||
if !invalidFirst {
|
||||
details[0], details[1] = details[1], details[0]
|
||||
}
|
||||
task := &Task{Batch: Batch{BatchNo: "ACCURACY-CUT", BatchType: 107}, Operation: operation, ApnName: "CMIOT", Details: details}
|
||||
base := &accuracyRepository{task: task, saved: map[int64]Outcome{}}
|
||||
repo := &accuracyDurableRepository{accuracyRepository: base}
|
||||
if err := NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(context.Background(), task.Batch.BatchNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.prepared) != 1 || len(repo.prepared[0].DetailIDs) != 1 || repo.prepared[0].DetailIDs[0] != valid.ID {
|
||||
t.Fatalf("有效行未独立冻结: %+v", repo.prepared)
|
||||
}
|
||||
if valid.Reason != "" || valid.DetailStatus == consts.BatchDetailStatusFailed {
|
||||
t.Fatalf("有效行被无效重复行阻断: %+v", valid)
|
||||
}
|
||||
for _, check := range []struct {
|
||||
detail *Detail
|
||||
reason string
|
||||
}{{invalid, wantReason}, {otherInvalid, "该行独立校验失败"}, {duplicate, "重复ICCID,未提交上游"}} {
|
||||
outcome, ok := base.saved[check.detail.ID]
|
||||
if !ok || outcome.Success || outcome.Message != check.reason || check.detail.Reason != check.reason {
|
||||
t.Fatalf("逐行原因被改写: id=%d detail=%+v outcome=%+v", check.detail.ID, check.detail, outcome)
|
||||
}
|
||||
}
|
||||
if repo.prepared[0].Request.ReqData.OperType != "1" || operation.TargetStatus != 0 {
|
||||
t.Fatalf("107开停方向倒置: %+v", repo.prepared[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccuracyFrozenRequestsKeepAPNDirection(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
batchType int
|
||||
operType string
|
||||
target int
|
||||
}{{107, "1", 0}, {109, "0", 1}, {110, "1", 0}} {
|
||||
t.Run(fmt.Sprint(tc.batchType), func(t *testing.T) {
|
||||
operation, err := OperationByBatchType(tc.batchType)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card := &Card{ID: 10, ICCID: "card", MSISDN: "1001", ChanKey: "channel", DataServiceStatus: 1 - tc.target}
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "ACCURACY-DIRECTION", BatchType: tc.batchType}, Operation: operation, ApnName: "CMIOT",
|
||||
Details: []*Detail{{ID: 1, ICCID: card.ICCID, Authorized: true, Card: card}, {ID: 2, ICCID: card.ICCID, Authorized: true, Card: card}},
|
||||
}
|
||||
repo := &accuracyDurableRepository{accuracyRepository: &accuracyRepository{task: task, saved: map[int64]Outcome{}}}
|
||||
if err := NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(context.Background(), task.Batch.BatchNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.prepared) != 1 || len(repo.prepared[0].DetailIDs) != 1 || repo.prepared[0].DetailIDs[0] != 1 {
|
||||
t.Fatalf("同ICCID未只保留首个有效行: %+v", repo.prepared)
|
||||
}
|
||||
if operation.TargetStatus != tc.target || repo.prepared[0].Request.ReqData.OperType != tc.operType {
|
||||
t.Fatalf("批次%d开停方向错误: operation=%+v request=%+v", tc.batchType, operation, repo.prepared[0].Request)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccuracyEmptyBatchKeepsValidationReason(t *testing.T) {
|
||||
for _, batchType := range []int{107, 109, 110} {
|
||||
t.Run(fmt.Sprint(batchType), func(t *testing.T) {
|
||||
operation, err := OperationByBatchType(batchType)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{Batch: Batch{BatchNo: "ACCURACY-EMPTY", BatchType: batchType}, Operation: operation}
|
||||
repo := &accuracyRepository{task: task}
|
||||
if err := NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(context.Background(), task.Batch.BatchNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !repo.validationSaved || !repo.finalized || task.ValidationOK || task.FailureReason == "" {
|
||||
t.Fatalf("空明细校验失败原因缺失: task=%+v saved=%v finalized=%v", task, repo.validationSaved, repo.finalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type accuracyRepository struct {
|
||||
task *Task
|
||||
saved map[int64]Outcome
|
||||
validationSaved bool
|
||||
finalized bool
|
||||
}
|
||||
|
||||
func (r *accuracyRepository) Load(context.Context, string) (*Task, error) { return r.task, nil }
|
||||
func (r *accuracyRepository) SaveValidationFailure(context.Context, *Task) error {
|
||||
r.validationSaved = true
|
||||
return nil
|
||||
}
|
||||
func (r *accuracyRepository) SaveResult(_ context.Context, _ *Task, detail *Detail, outcome Outcome) (Outcome, error) {
|
||||
r.saved[detail.ID] = outcome
|
||||
return outcome, nil
|
||||
}
|
||||
func (r *accuracyRepository) Finalize(context.Context, *Task) error {
|
||||
r.finalized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type accuracyDurableRepository struct {
|
||||
*accuracyRepository
|
||||
SubBatchRepository
|
||||
prepared []SubBatch
|
||||
}
|
||||
|
||||
func (r *accuracyDurableRepository) ResumeOrPrepare(ctx context.Context, _ string, prepare func(context.Context) error) error {
|
||||
return prepare(ctx)
|
||||
}
|
||||
func (r *accuracyDurableRepository) PrepareSubBatch(_ context.Context, sub SubBatch) error {
|
||||
r.prepared = append(r.prepared, sub)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAccuracyCanExecuteProcessingRequiresExistingParent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
parent bool
|
||||
readErr error
|
||||
wantOK bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "旧Processing无凭据不可接管"},
|
||||
{name: "新批次ready0已持久化凭据", parent: true, wantOK: true},
|
||||
{name: "缺少迁移查询失败安全拒绝", readErr: errors.New("no such table: data_operation_parent"), wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
repo := &accuracyParentRepository{accuracyDurableRepository: &accuracyDurableRepository{accuracyRepository: &accuracyRepository{}}, present: tc.parent, readErr: tc.readErr}
|
||||
e := NewExecutor(repo, &accuracyNoUpstream{t: t}).(*executor)
|
||||
ok, err := e.CanExecuteProcessing(ctx, &entity.BatchMain{BatchNo: " APN-EXISTING "})
|
||||
if ok != tc.wantOK || (err != nil) != tc.wantErr || repo.lookups != 1 || repo.batchNo != "APN-EXISTING" {
|
||||
t.Fatalf("parent gate ok=%v err=%v lookups=%d batchNo=%q", ok, err, repo.lookups, repo.batchNo)
|
||||
}
|
||||
})
|
||||
}
|
||||
base := &accuracyRepository{}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
repo Repository
|
||||
upstream Upstream
|
||||
}{
|
||||
{name: "缺少持久仓储", repo: base, upstream: &accuracyNoUpstream{t: t}},
|
||||
{name: "仅同步上游", repo: &accuracyParentRepository{accuracyDurableRepository: &accuracyDurableRepository{accuracyRepository: base}}, upstream: &accuracySyncOnlyUpstream{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := NewExecutor(tc.repo, tc.upstream).(*executor)
|
||||
ok, err := e.CanExecuteProcessing(ctx, &entity.BatchMain{BatchNo: "APN-EXISTING"})
|
||||
if ok || err != nil {
|
||||
t.Fatalf("不支持持久异步不得接管Processing: ok=%v err=%v", ok, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccuracyDurableRepositoryWithSyncUpstreamFinalizesValidationFailure(t *testing.T) {
|
||||
for _, batchType := range []int{107, 109, 110} {
|
||||
t.Run(fmt.Sprint(batchType), func(t *testing.T) {
|
||||
operation, err := OperationByBatchType(batchType)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{Batch: Batch{BatchNo: "SYNC-FAILED", BatchType: batchType}, Operation: operation}
|
||||
base := &accuracyRepository{task: task}
|
||||
repo := &accuracyDurableRepository{accuracyRepository: base}
|
||||
if err := NewExecutor(repo, &accuracySyncOnlyUpstream{}).Execute(context.Background(), task.Batch.BatchNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !base.validationSaved || !base.finalized || task.FailureReason == "" {
|
||||
t.Fatalf("同步兜底遗漏校验失败父批: saved=%v finalized=%v task=%+v", base.validationSaved, base.finalized, task)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type accuracyParentRepository struct {
|
||||
*accuracyDurableRepository
|
||||
present bool
|
||||
readErr error
|
||||
lookups int
|
||||
batchNo string
|
||||
}
|
||||
|
||||
func (r *accuracyParentRepository) HasDurableParent(_ context.Context, batchNo string) (bool, error) {
|
||||
r.lookups++
|
||||
r.batchNo = batchNo
|
||||
return r.present, r.readErr
|
||||
}
|
||||
|
||||
type accuracySyncOnlyUpstream struct{}
|
||||
|
||||
func (*accuracySyncOnlyUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
panic("校验失败不得发运营商请求")
|
||||
}
|
||||
|
||||
type accuracyNoUpstream struct{ t *testing.T }
|
||||
|
||||
func (u *accuracyNoUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
u.t.Fatal("该回归不得调用同步上游")
|
||||
return nil, nil
|
||||
}
|
||||
func (u *accuracyNoUpstream) SubmitSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
u.t.Fatal("该回归只冻结意图,不得提交上游")
|
||||
return nil, nil
|
||||
}
|
||||
func (u *accuracyNoUpstream) QuerySimApnFunctionResult(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
u.t.Fatal("该回归不得查询上游")
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestExecuteDataStartSuccess(t *testing.T) {
|
||||
operation, err := OperationByBatchType(consts.BatchTypeDataStart)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "D-1"},
|
||||
Operation: operation,
|
||||
ApnName: consts.SimApnNameDefault,
|
||||
Details: []*Detail{
|
||||
{ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusDisabled}},
|
||||
},
|
||||
}
|
||||
repo := &fakeRepository{task: task}
|
||||
upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardApnChangeOutputItem]{
|
||||
{Code: 0, Message: "ok", SimKey: &model.SimKey{ICCID: "iccid-1"}, ResData: model.SimCardApnChangeOutputItem{OrderNum: "order-1"}},
|
||||
}}
|
||||
|
||||
if err = NewExecutor(repo, upstream).Execute(context.Background(), "D-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", upstream.calls)
|
||||
}
|
||||
if upstream.input.ReqData.OperType != consts.SimApnOperTypeOpen || upstream.input.ReqData.ApnName != consts.SimApnNameDefault {
|
||||
t.Fatalf("reqData = %+v", upstream.input.ReqData)
|
||||
}
|
||||
if len(repo.saved) != 1 || !repo.saved[0].Success || repo.saved[0].OrderNum != "order-1" {
|
||||
t.Fatalf("saved = %+v", repo.saved)
|
||||
}
|
||||
if task.Details[0].StatusBefore != consts.SimDataServiceStatusDisabled {
|
||||
t.Fatalf("StatusBefore = %d", task.Details[0].StatusBefore)
|
||||
}
|
||||
if !repo.finalized {
|
||||
t.Fatal("batch was not finalized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDataStopSuccess(t *testing.T) {
|
||||
operation, err := OperationByBatchType(consts.BatchTypeDataStop)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "D-2"},
|
||||
Operation: operation,
|
||||
ApnName: consts.SimApnNameDefault,
|
||||
Details: []*Detail{
|
||||
{ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}},
|
||||
},
|
||||
}
|
||||
repo := &fakeRepository{task: task}
|
||||
upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardApnChangeOutputItem]{
|
||||
{Code: 0, Message: "ok", SimKey: &model.SimKey{ICCID: "iccid-1"}},
|
||||
}}
|
||||
|
||||
if err = NewExecutor(repo, upstream).Execute(context.Background(), "D-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 1 || upstream.input.ReqData.OperType != consts.SimApnOperTypeStop {
|
||||
t.Fatalf("upstream calls = %d, operType = %s", upstream.calls, upstream.input.ReqData.OperType)
|
||||
}
|
||||
if len(repo.saved) != 1 || !repo.saved[0].Success {
|
||||
t.Fatalf("saved = %+v", repo.saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTrafficCutoffSuccess(t *testing.T) {
|
||||
operation, err := OperationByBatchType(consts.BatchTypeDataCut)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "DCT-1"},
|
||||
Operation: operation,
|
||||
ApnName: consts.SimApnNameDefault,
|
||||
Details: []*Detail{
|
||||
{ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}},
|
||||
},
|
||||
}
|
||||
repo := &fakeRepository{task: task}
|
||||
upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardApnChangeOutputItem]{
|
||||
{Code: 0, Message: "ok", SimKey: &model.SimKey{ICCID: "iccid-1"}},
|
||||
}}
|
||||
|
||||
if err = NewExecutor(repo, upstream).Execute(context.Background(), "DCT-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 1 || upstream.input.ReqData.OperType != consts.SimApnOperTypeStop {
|
||||
t.Fatalf("upstream calls = %d, operType = %s", upstream.calls, upstream.input.ReqData.OperType)
|
||||
}
|
||||
if len(repo.saved) != 1 || !repo.saved[0].Success {
|
||||
t.Fatalf("saved = %+v", repo.saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTrafficCutoffRefreshesCurrentStatusBeforeUpstream(t *testing.T) {
|
||||
operation, err := OperationByBatchType(consts.BatchTypeDataCut)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "DCT-3"},
|
||||
Operation: operation,
|
||||
ApnName: consts.SimApnNameDefault,
|
||||
Details: []*Detail{
|
||||
{ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}},
|
||||
},
|
||||
}
|
||||
repo := &fakeRepository{
|
||||
task: task,
|
||||
refresh: func(task *Task) {
|
||||
if len(task.Details) == 0 || task.Details[0] == nil || task.Details[0].Card == nil {
|
||||
return
|
||||
}
|
||||
task.Details[0].Card.DataServiceStatus = consts.SimDataServiceStatusDisabled
|
||||
task.Details[0].StatusBefore = consts.SimDataServiceStatusDisabled
|
||||
task.Details[0].StatusAfter = consts.SimDataServiceStatusDisabled
|
||||
},
|
||||
}
|
||||
upstream := &fakeUpstream{responseError: errors.New("must not be called")}
|
||||
|
||||
if err = NewExecutor(repo, upstream).Execute(context.Background(), "DCT-3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("upstream calls = %d, want 0", upstream.calls)
|
||||
}
|
||||
if len(repo.saved) != 1 || !repo.saved[0].Success {
|
||||
t.Fatalf("saved = %+v", repo.saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTrafficCutoffSkipsAlreadyDisabledCard(t *testing.T) {
|
||||
operation, err := OperationByBatchType(consts.BatchTypeDataCut)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "DCT-2"},
|
||||
Operation: operation,
|
||||
ApnName: consts.SimApnNameDefault,
|
||||
Details: []*Detail{
|
||||
{ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusDisabled}},
|
||||
},
|
||||
}
|
||||
repo := &fakeRepository{task: task}
|
||||
upstream := &fakeUpstream{responseError: errors.New("must not be called")}
|
||||
|
||||
if err = NewExecutor(repo, upstream).Execute(context.Background(), "DCT-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("upstream calls = %d, want 0", upstream.calls)
|
||||
}
|
||||
if len(repo.saved) != 1 || !repo.saved[0].Success {
|
||||
t.Fatalf("saved = %+v", repo.saved)
|
||||
}
|
||||
if task.Details[0].Reason != "" {
|
||||
t.Fatalf("already disabled cutoff should be settled as success, reason = %q", task.Details[0].Reason)
|
||||
}
|
||||
if !repo.finalized {
|
||||
t.Fatal("batch was not finalized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTrafficCutoffValidationFailureAllowsValidRows(t *testing.T) {
|
||||
operation, err := OperationByBatchType(consts.BatchTypeDataCut)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "DCT-4"},
|
||||
Operation: operation,
|
||||
ApnName: consts.SimApnNameDefault,
|
||||
Details: []*Detail{
|
||||
{ID: 1, ICCID: "iccid-1", Authorized: false, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}},
|
||||
{ID: 2, ICCID: "iccid-2", Authorized: true, Card: &Card{ID: 12, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}},
|
||||
},
|
||||
}
|
||||
repo := &fakeRepository{task: task}
|
||||
upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardApnChangeOutputItem]{
|
||||
{Code: 0, Message: "ok", SimKey: &model.SimKey{ICCID: "iccid-2"}},
|
||||
}}
|
||||
|
||||
if err = NewExecutor(repo, upstream).Execute(context.Background(), "DCT-4"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", upstream.calls)
|
||||
}
|
||||
if len(upstream.input.SimKeys) != 1 || upstream.input.SimKeys[0].ICCID != "iccid-2" {
|
||||
t.Fatalf("upstream input = %+v", upstream.input.SimKeys)
|
||||
}
|
||||
if len(repo.saved) != 2 {
|
||||
t.Fatalf("saved count = %d, want 2; saved = %+v", len(repo.saved), repo.saved)
|
||||
}
|
||||
if repo.saved[0].Success || repo.saved[0].Message != "ICCID不在当前操作员授权范围内" {
|
||||
t.Fatalf("invalid row result = %+v", repo.saved[0])
|
||||
}
|
||||
if !repo.saved[1].Success {
|
||||
t.Fatalf("valid row result = %+v", repo.saved[1])
|
||||
}
|
||||
if task.Details[0].ValidationPassed || !task.Details[1].ValidationPassed {
|
||||
t.Fatalf("validation flags = (%v, %v)", task.Details[0].ValidationPassed, task.Details[1].ValidationPassed)
|
||||
}
|
||||
if !repo.finalized {
|
||||
t.Fatal("batch was not finalized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSameLocalStatusStillCallsUpstream(t *testing.T) {
|
||||
operation, err := OperationByBatchType(consts.BatchTypeDataStart)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "D-3"},
|
||||
Operation: operation,
|
||||
ApnName: consts.SimApnNameDefault,
|
||||
Details: []*Detail{
|
||||
{ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}},
|
||||
},
|
||||
}
|
||||
repo := &fakeRepository{task: task}
|
||||
upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardApnChangeOutputItem]{
|
||||
{Code: 0, Message: "ok", SimKey: &model.SimKey{ICCID: "iccid-1"}},
|
||||
}}
|
||||
|
||||
if err = NewExecutor(repo, upstream).Execute(context.Background(), "D-3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1 even when local status already matches", upstream.calls)
|
||||
}
|
||||
if task.Details[0].Reason != "" {
|
||||
t.Fatalf("same local status must not be a validation blocker, reason = %q", task.Details[0].Reason)
|
||||
}
|
||||
if len(repo.saved) != 1 || !repo.saved[0].Success {
|
||||
t.Fatalf("saved = %+v", repo.saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidationFailureSkipsUpstream(t *testing.T) {
|
||||
operation, err := OperationByBatchType(consts.BatchTypeDataStop)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "D-4"},
|
||||
Operation: operation,
|
||||
ApnName: consts.SimApnNameDefault,
|
||||
Details: []*Detail{
|
||||
{ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}},
|
||||
{ID: 2, ICCID: "iccid-2", Authorized: false, Card: &Card{ID: 12, ChanKey: "channel", DataServiceStatus: consts.SimDataServiceStatusEnabled}},
|
||||
},
|
||||
}
|
||||
repo := &fakeRepository{task: task}
|
||||
upstream := &fakeUpstream{responseError: errors.New("must not be called")}
|
||||
|
||||
if err = NewExecutor(repo, upstream).Execute(context.Background(), "D-4"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("upstream calls = %d, want 0", upstream.calls)
|
||||
}
|
||||
if !repo.validationSaved || !repo.finalized {
|
||||
t.Fatal("validation failure was not persisted and finalized")
|
||||
}
|
||||
if task.Details[0].Reason == "" || task.Details[1].Reason == "" {
|
||||
t.Fatal("every detail must receive a validation failure reason")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteUpstreamPartialSuccess(t *testing.T) {
|
||||
operation, err := OperationByBatchType(consts.BatchTypeDataStart)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "D-5"},
|
||||
Operation: operation,
|
||||
ApnName: consts.SimApnNameDefault,
|
||||
Details: []*Detail{
|
||||
{ID: 1, ICCID: "iccid-1", MSISDN: "msisdn-1", IMSI: "imsi-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", MSISDN: "msisdn-1", IMSI: "imsi-1", DataServiceStatus: consts.SimDataServiceStatusDisabled}},
|
||||
{ID: 2, ICCID: "iccid-2", MSISDN: "msisdn-2", IMSI: "imsi-2", Authorized: true, Card: &Card{ID: 12, ChanKey: "channel", MSISDN: "msisdn-2", IMSI: "imsi-2", DataServiceStatus: consts.SimDataServiceStatusDisabled}},
|
||||
},
|
||||
}
|
||||
repo := &fakeRepository{task: task}
|
||||
upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardApnChangeOutputItem]{
|
||||
{Code: 0, Message: "ok", SimKey: nil, ResData: model.SimCardApnChangeOutputItem{OrderNum: "order-1"}},
|
||||
{Code: 9001, Message: "denied", SimKey: &model.SimKey{ICCID: "iccid-2"}, ResData: model.SimCardApnChangeOutputItem{OrderNum: "order-2"}},
|
||||
}}
|
||||
|
||||
if err = NewExecutor(repo, upstream).Execute(context.Background(), "D-5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.saved) != 2 || !repo.saved[0].Success || repo.saved[0].OrderNum != "order-1" {
|
||||
t.Fatalf("first result = %+v", repo.saved)
|
||||
}
|
||||
if repo.saved[1].Success || repo.saved[1].Code != 9001 || repo.saved[1].Message != "denied" || repo.saved[1].OrderNum != "order-2" {
|
||||
t.Fatalf("second result = %+v", repo.saved[1])
|
||||
}
|
||||
if !repo.finalized {
|
||||
t.Fatal("batch was not finalized")
|
||||
}
|
||||
}
|
||||
|
||||
// 大批量场景下,每个子批次一拿到结果就立即落库,不必等所有子批次跑完。
|
||||
func TestExecuteSavesEachSubBatchAsSoonAsItSettles(t *testing.T) {
|
||||
operation, err := OperationByBatchType(consts.BatchTypeDataStart)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 201 张 = 3 个子批(100 / 100 / 1);并发度为 5,三个子批会同时发起。
|
||||
details := make([]*Detail, 0, 201)
|
||||
for i := 0; i < 201; i++ {
|
||||
details = append(details, &Detail{
|
||||
ID: int64(i + 1),
|
||||
ICCID: fmt.Sprintf("iccid-%03d", i),
|
||||
Authorized: true,
|
||||
Card: &Card{
|
||||
ID: int64(1000 + i),
|
||||
ChanKey: "channel",
|
||||
DataServiceStatus: consts.SimDataServiceStatusDisabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
task := &Task{Batch: Batch{BatchNo: "D-6"}, Operation: operation, ApnName: consts.SimApnNameDefault, Details: details}
|
||||
|
||||
repo := &fakeRepository{task: task}
|
||||
release := make(chan struct{})
|
||||
upstream := &subBatchUpstream{blockICCID: "iccid-100", block: release}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- NewExecutor(repo, upstream).Execute(context.Background(), "D-6") }()
|
||||
|
||||
// 第 1、3 个子批共 101 条应先落库,此时第 2 个子批仍阻塞在上游。
|
||||
waitForSavedCount(t, repo, 101)
|
||||
if repo.isFinalized() {
|
||||
t.Fatal("batch must not be finalized while one sub batch is still running")
|
||||
}
|
||||
|
||||
close(release)
|
||||
if err = <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := repo.savedCount(); got != 201 {
|
||||
t.Fatalf("saved count = %d, want 201", got)
|
||||
}
|
||||
if !repo.isFinalized() {
|
||||
t.Fatal("batch was not finalized")
|
||||
}
|
||||
if calls := atomic.LoadInt32(&upstream.calls); calls != 3 {
|
||||
t.Fatalf("upstream calls = %d, want 3", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForSavedCount(t *testing.T, repo *fakeRepository, want int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if repo.savedCount() >= want {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("saved count = %d, want at least %d", repo.savedCount(), want)
|
||||
}
|
||||
|
||||
func TestOperationByBatchTypeMapping(t *testing.T) {
|
||||
start, err := OperationByBatchType(consts.BatchTypeDataStart)
|
||||
if err != nil || start.Name != "dataStart" || start.ApnOperType != consts.SimApnOperTypeOpen || start.TargetStatus != consts.SimDataServiceStatusEnabled {
|
||||
t.Fatalf("start operation = %+v, err = %v", start, err)
|
||||
}
|
||||
stop, err := OperationByBatchType(consts.BatchTypeDataStop)
|
||||
if err != nil || stop.Name != "dataStop" || stop.ApnOperType != consts.SimApnOperTypeStop || stop.TargetStatus != consts.SimDataServiceStatusDisabled {
|
||||
t.Fatalf("stop operation = %+v, err = %v", stop, err)
|
||||
}
|
||||
cut, err := OperationByBatchType(consts.BatchTypeDataCut)
|
||||
if err != nil || cut.Name != "trafficCutoff" || cut.ApnOperType != consts.SimApnOperTypeStop || cut.TargetStatus != consts.SimDataServiceStatusDisabled {
|
||||
t.Fatalf("cut operation = %+v, err = %v", cut, err)
|
||||
}
|
||||
if _, err = OperationByBatchType(consts.BatchTypeSimCardStart); err == nil {
|
||||
t.Fatal("lifecycle batch type must not map to a data service operation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailSnapshotIncludesOperationContext(t *testing.T) {
|
||||
operation, err := OperationByBatchType(consts.BatchTypeDataStart)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail := &Detail{
|
||||
ID: 1,
|
||||
ICCID: "iccid-1",
|
||||
ChanKey: "channel",
|
||||
StatusBefore: consts.SimDataServiceStatusDisabled,
|
||||
StatusAfter: consts.SimDataServiceStatusEnabled,
|
||||
OrderNum: "order-1",
|
||||
UpstreamCode: 0,
|
||||
UpstreamReason: "ok",
|
||||
}
|
||||
snapshot := detail.Snapshot(operation, consts.SimApnNameDefault)
|
||||
if snapshot.Get("operation").String() != "dataStart" ||
|
||||
snapshot.Get("operType").String() != consts.SimApnOperTypeOpen ||
|
||||
snapshot.Get("apnName").String() != consts.SimApnNameDefault ||
|
||||
snapshot.Get("statusBefore").Int() != consts.SimDataServiceStatusDisabled ||
|
||||
snapshot.Get("statusAfter").Int() != consts.SimDataServiceStatusEnabled ||
|
||||
snapshot.Get("orderNum").String() != "order-1" {
|
||||
t.Fatalf("snapshot = %s", snapshot.MustToJsonString())
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRepository 会被多个子批次 goroutine 并发写入,因此内部状态统一用锁保护。
|
||||
type fakeRepository struct {
|
||||
task *Task
|
||||
refresh func(*Task)
|
||||
|
||||
mu sync.Mutex
|
||||
validationSaved bool
|
||||
finalized bool
|
||||
saved []Outcome
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Load(context.Context, string) (*Task, error) { return r.task, nil }
|
||||
|
||||
func (r *fakeRepository) RefreshDataServiceState(_ context.Context, task *Task) error {
|
||||
if r.refresh != nil {
|
||||
r.refresh(task)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) SaveValidationFailure(context.Context, *Task) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.validationSaved = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) SaveResult(_ context.Context, _ *Task, detail *Detail, outcome Outcome) (Outcome, error) {
|
||||
detail.ApplyOutcome(outcome)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.saved = append(r.saved, outcome)
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Finalize(context.Context, *Task) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.finalized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) savedCount() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.saved)
|
||||
}
|
||||
|
||||
func (r *fakeRepository) isFinalized() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.finalized
|
||||
}
|
||||
|
||||
// subBatchUpstream 按子批次返回成功结果,并可在指定子批(以其首张 ICCID 标识)上阻塞,
|
||||
// 用于验证"某个子批还没结束时,其他子批的结果已经落库"。
|
||||
type subBatchUpstream struct {
|
||||
calls int32
|
||||
blockICCID string
|
||||
block chan struct{}
|
||||
}
|
||||
|
||||
func (u *subBatchUpstream) ChangeSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
atomic.AddInt32(&u.calls, 1)
|
||||
if u.block != nil && len(input.SimKeys) > 0 && strings.TrimSpace(input.SimKeys[0].ICCID) == u.blockICCID {
|
||||
select {
|
||||
case <-u.block:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
response := make([]model.SimBaseRes[model.SimCardApnChangeOutputItem], 0, len(input.SimKeys))
|
||||
for _, key := range input.SimKeys {
|
||||
response = append(response, model.SimBaseRes[model.SimCardApnChangeOutputItem]{
|
||||
Code: 0,
|
||||
Message: "ok",
|
||||
SimKey: &model.SimKey{ICCID: key.ICCID},
|
||||
})
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type fakeUpstream struct {
|
||||
calls int
|
||||
input *model.SimCardApnChangeInput
|
||||
response []model.SimBaseRes[model.SimCardApnChangeOutputItem]
|
||||
responseError error
|
||||
}
|
||||
|
||||
func (u *fakeUpstream) ChangeSimApnFunction(_ context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
u.calls++
|
||||
u.input = input
|
||||
return u.response, u.responseError
|
||||
}
|
||||
|
||||
func TestValidateDetailCollectsIndependentDiagnostics(t *testing.T) {
|
||||
detail := &Detail{ICCID: "card", Authorized: true, InputMSISDN: "wrong-phone", InputIMSI: "wrong-imsi", Card: &Card{MSISDN: "phone", IMSI: "imsi"}}
|
||||
validateDetail(Operation{}, detail)
|
||||
want := "ICCID缺少通道标识;MSISDN与卡档案不一致;IMSI与卡档案不一致"
|
||||
if detail.Reason != want {
|
||||
t.Fatalf("独立校验原因丢失: got=%q want=%q", detail.Reason, want)
|
||||
}
|
||||
unauthorized := &Detail{ICCID: "card", InputMSISDN: "wrong", Card: &Card{MSISDN: "phone"}}
|
||||
validateDetail(Operation{}, unauthorized)
|
||||
if unauthorized.Reason != "ICCID不在当前操作员授权范围内" {
|
||||
t.Fatalf("越权卡不应返回其他数据差异: %q", unauthorized.Reason)
|
||||
}
|
||||
existing := &Detail{Reason: "原始输入格式错误", Authorized: true, Card: &Card{}}
|
||||
validateDetail(Operation{}, existing)
|
||||
if existing.Reason != "原始输入格式错误" {
|
||||
t.Fatal("原始原因被覆盖")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
type Operation struct {
|
||||
BatchType int
|
||||
Name string
|
||||
ApnOperType string
|
||||
TargetStatus int
|
||||
}
|
||||
|
||||
type Batch struct {
|
||||
ID int64
|
||||
BatchNo string
|
||||
BatchType int
|
||||
TenantID string
|
||||
CreatedBy int64
|
||||
CreatedDept int64
|
||||
StartTime *gtime.Time
|
||||
BatchInfo *gjson.Json
|
||||
}
|
||||
|
||||
type Card struct {
|
||||
ID int64
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
ChanKey string
|
||||
DataServiceStatus int
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
ID int64
|
||||
DetailStatus int
|
||||
RowNo int
|
||||
ICCID string
|
||||
InputMSISDN string
|
||||
InputIMSI string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
Authorized bool
|
||||
ChanKey string
|
||||
StatusBefore int
|
||||
StatusAfter int
|
||||
TrafficCutoffCrossMonthHold int
|
||||
Reason string
|
||||
ValidationPassed bool
|
||||
OrderNum string
|
||||
UpstreamCode int
|
||||
UpstreamReason string
|
||||
Card *Card
|
||||
}
|
||||
|
||||
type Outcome struct {
|
||||
Pending bool
|
||||
Success bool
|
||||
Code int
|
||||
Message string
|
||||
OrderNum string
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Batch Batch
|
||||
Operation Operation
|
||||
ApnName string
|
||||
Details []*Detail
|
||||
ValidationOK bool
|
||||
FailureReason string
|
||||
}
|
||||
|
||||
func (d *Detail) ApplyOutcome(outcome Outcome) {
|
||||
d.OrderNum = strings.TrimSpace(outcome.OrderNum)
|
||||
if outcome.Success {
|
||||
d.DetailStatus = 2
|
||||
} else {
|
||||
d.DetailStatus = 3
|
||||
}
|
||||
d.UpstreamCode = outcome.Code
|
||||
d.UpstreamReason = strings.TrimSpace(outcome.Message)
|
||||
if outcome.Success {
|
||||
d.Reason = ""
|
||||
return
|
||||
}
|
||||
if d.UpstreamReason == "" {
|
||||
d.UpstreamReason = "上游处理失败"
|
||||
}
|
||||
d.Reason = d.UpstreamReason
|
||||
}
|
||||
|
||||
func (d *Detail) Snapshot(operation Operation, apnName string) *gjson.Json {
|
||||
if d == nil {
|
||||
return gjson.New(nil)
|
||||
}
|
||||
return gjson.New(detailSnapshot{
|
||||
ICCID: d.ICCID,
|
||||
InputMSISDN: d.InputMSISDN,
|
||||
InputIMSI: d.InputIMSI,
|
||||
MSISDN: d.MSISDN,
|
||||
IMSI: d.IMSI,
|
||||
Authorized: d.Authorized,
|
||||
ChanKey: d.ChanKey,
|
||||
Operation: operation.Name,
|
||||
OperType: operation.ApnOperType,
|
||||
ApnName: apnName,
|
||||
StatusBefore: d.StatusBefore,
|
||||
StatusAfter: d.StatusAfter,
|
||||
TrafficCutoffCrossMonthHold: d.TrafficCutoffCrossMonthHold,
|
||||
OrderNum: d.OrderNum,
|
||||
UpstreamCode: d.UpstreamCode,
|
||||
UpstreamMessage: d.UpstreamReason,
|
||||
})
|
||||
}
|
||||
|
||||
type detailSnapshot struct {
|
||||
ICCID string `json:"ICCID"`
|
||||
InputMSISDN string `json:"inputMsisdn"`
|
||||
InputIMSI string `json:"inputImsi"`
|
||||
MSISDN string `json:"MSISDN"`
|
||||
IMSI string `json:"IMSI"`
|
||||
Authorized bool `json:"authorized"`
|
||||
ChanKey string `json:"chanKey"`
|
||||
Operation string `json:"operation"`
|
||||
OperType string `json:"operType"`
|
||||
ApnName string `json:"apnName"`
|
||||
StatusBefore int `json:"statusBefore"`
|
||||
StatusAfter int `json:"statusAfter"`
|
||||
TrafficCutoffCrossMonthHold int `json:"trafficCutoffCrossMonthHold,omitempty"`
|
||||
OrderNum string `json:"orderNum"`
|
||||
UpstreamCode int `json:"upstreamCode"`
|
||||
UpstreamMessage string `json:"upstreamMessage"`
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
Load(ctx context.Context, batchNo string) (*Task, error)
|
||||
SaveValidationFailure(ctx context.Context, task *Task) error
|
||||
SaveResult(ctx context.Context, task *Task, detail *Detail, outcome Outcome) (Outcome, error)
|
||||
Finalize(ctx context.Context, task *Task) error
|
||||
}
|
||||
|
||||
// SubBatch is a durable, channel-isolated intent; Request is frozen before submission.
|
||||
type SubBatch struct {
|
||||
ID int64
|
||||
BatchNo string
|
||||
BatchType int
|
||||
ChanKey string
|
||||
OperationID string
|
||||
DetailIDs []int64
|
||||
Request model.SimCardApnChangeInput
|
||||
JobID string
|
||||
Status string
|
||||
PollCount int
|
||||
SubmittedAt time.Time
|
||||
LeaseToken string
|
||||
LeaseUntil time.Time
|
||||
NextCheckAt time.Time
|
||||
}
|
||||
|
||||
// SubBatchRepository keeps the synchronous legacy tests separate from durable scheduling.
|
||||
type SubBatchRepository interface {
|
||||
HasDurableParent(ctx context.Context, batchNo string) (bool, error)
|
||||
PrepareSubBatch(ctx context.Context, batch SubBatch) error
|
||||
ResumeOrPrepare(ctx context.Context, batchNo string, prepare func(context.Context) error) error
|
||||
ClaimSubBatch(ctx context.Context, operationID, token string, now time.Time) (*SubBatch, error)
|
||||
ListDueSubBatches(ctx context.Context, now time.Time, limit int) ([]string, error)
|
||||
GetSubBatch(ctx context.Context, operationID string) (*SubBatch, error)
|
||||
DelaySubBatchPrecheck(ctx context.Context, snapshot SubBatch, next time.Time, reason string) error
|
||||
ReleaseUnsentSubBatch(ctx context.Context, batch SubBatch, reason string) error
|
||||
SubBatchChannel(ctx context.Context, operationID string) (string, error)
|
||||
UpdateSubBatch(ctx context.Context, batch SubBatch, status, jobID, reason string, next time.Time, pollCount int) error
|
||||
CompleteSubBatch(ctx context.Context, batch SubBatch, outcomes []Outcome) error
|
||||
SaveSubBatchProgress(ctx context.Context, batch SubBatch, outcomes []Outcome) error
|
||||
ProtectExecutionFailure(ctx context.Context, batchNo, reason string) error
|
||||
FinalizeReadyBatch(ctx context.Context, batchNo string) error
|
||||
ListRecoverableBatches(ctx context.Context, limit int) ([]string, error)
|
||||
}
|
||||
|
||||
type Upstream interface {
|
||||
ChangeSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
|
||||
}
|
||||
|
||||
// AsyncUpstream provides an explicit submit/recovery/query protocol: never resend a write request.
|
||||
type AsyncUpstream interface {
|
||||
SubmitSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
|
||||
QuerySimApnFunctionResult(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
|
||||
}
|
||||
|
||||
type ReviewRepository interface {
|
||||
ClaimReviewSubBatch(ctx context.Context, operationID, token string, now time.Time) (*SubBatch, error)
|
||||
}
|
||||
|
||||
// ReviewBatchRepository 支撑"手动同步上游结果":按父批找出待核查子批并只读补查,
|
||||
// 不重发办理、不重置自动补查计数。
|
||||
type ReviewBatchRepository interface {
|
||||
ListReviewSubBatches(ctx context.Context, batchNo string) ([]string, error)
|
||||
FinalizeReadyBatch(ctx context.Context, batchNo string) error
|
||||
}
|
||||
|
||||
type Executor interface {
|
||||
Execute(ctx context.Context, batchNo string) error
|
||||
QueryReview(ctx context.Context, operationID string) error
|
||||
QueryReviewBatch(ctx context.Context, batchNo string) (int, error)
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package imei
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
zsetconsumer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consumer/zset"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
imeirepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/imei"
|
||||
)
|
||||
|
||||
const (
|
||||
// chanGatewayInternalErrorCode is emitted by the gateway for an internal
|
||||
// fault. An empty errCode distinguishes it from a carrier business code.
|
||||
chanGatewayInternalErrorCode = 9999
|
||||
|
||||
// tokenExpiredCode is the carrier code for an expired/invalid upstream
|
||||
// token. The gateway already invalidates and refreshes the token once on
|
||||
// this code; when it still reaches the worker, retrying in a later round
|
||||
// lets a refreshed token succeed instead of parking the card as a terminal
|
||||
// business failure.
|
||||
tokenExpiredCode = 12021
|
||||
|
||||
// Mobile OneLink business codes 13000-13007 mean "system busy". Per the
|
||||
// QPS field test this is an HTTP 200 business rejection that still occurs
|
||||
// on roughly 70% of requests at 1 QPS, so it must be retried across rounds
|
||||
// rather than in place.
|
||||
mobileBusyFirstCode = 13000
|
||||
mobileBusyLastCode = 13007
|
||||
|
||||
// chanNoRecordCode is the carrier answer for a card it holds no record of
|
||||
// ("系统无记录,请检查输入参数是否正确"). An IMEI is reported by the device, so
|
||||
// a card that was never inserted into one is a permanent, expected answer
|
||||
// rather than a failure: it is covered by the pass, never retried and never
|
||||
// parked in the failure pool. Measured on 2026-10-20 across channels
|
||||
// 220/250/251: all 4900 recorded failures were this code.
|
||||
chanNoRecordCode = 14000
|
||||
|
||||
// chanNoDeviceInfoCode is the Telecom 5G CMP answer for a card with no bound
|
||||
// device: {"result":"-1","resultMsg":"查询失败,查询不到指定卡的设备信息"}.
|
||||
// The chan layer passes the upstream string through gconv.Int, so it arrives
|
||||
// here as -1. Measured on 2026-10-22 run imei-20261022000000: 11,240 such
|
||||
// answers across channels 142/111/187, all with that exact message.
|
||||
chanNoDeviceInfoCode = -1
|
||||
|
||||
// chanTibiotNoInfoCode is Tibiot's "查询不到信息" business rejection, which
|
||||
// arrives as HTTP status 500 with a JSON body status=500. It is the same
|
||||
// semantic as chanNoDeviceInfoCode: the card has no device record. Measured
|
||||
// on the same run: 1,612 answers, of which 1,494 were 查询不到信息 and 118
|
||||
// were 系统无记录. Tibiot answers with the same shape for genuine upstream
|
||||
// faults, so the code alone is not enough — see isNoDeviceMessage.
|
||||
chanTibiotNoInfoCode = 500
|
||||
|
||||
// mobileNoRecordMessage and tibiotNoInfoMessage are the carrier texts that
|
||||
// mean "this card has no device, so there is no IMEI". They gate
|
||||
// chanTibiotNoInfoCode because 500 is also used for real faults; matching
|
||||
// text here is safer than treating every 500 as a definitive answer.
|
||||
// chanNoDeviceInfoCode does not need this gate: -1 is not used for faults by
|
||||
// the Telecom endpoint.
|
||||
//
|
||||
// Matching on text is normally fragile, so these strings are intentionally
|
||||
// narrow and come from captured production traffic (2026-10-22).
|
||||
mobileNoRecordMessage = "系统无记录"
|
||||
tibiotNoInfoMessage = "查询不到信息"
|
||||
|
||||
mobileBusyClass = "mobile_busy"
|
||||
unsupportedClass = "unsupported"
|
||||
dataInvalidClass = "data_invalid"
|
||||
noRecordClass = "no_record"
|
||||
networkClass = "network"
|
||||
protocolClass = "protocol"
|
||||
tokenExpiredClass = "token_expired"
|
||||
gatewayClass = "gateway"
|
||||
businessClass = "business"
|
||||
)
|
||||
|
||||
// imeiUnsupportedTemplates lists the templates whose upstream implementation
|
||||
// has no IMEI endpoint at all. Such a template is a deterministic terminal
|
||||
// state: retrying it every round would flood the failure pool with tens of
|
||||
// thousands of meaningless records and distort the run failure rate.
|
||||
//
|
||||
// Template keys are used rather than error text because the text changes while
|
||||
// the template does not. Keep this list aligned with the chan registry
|
||||
// (iotlcm-upstream-chan/internal/logic/upstreamimpl):
|
||||
//
|
||||
// - sim_mingqian_v3 → mingqian.Adapter (per-card IMEI endpoint unavailable)
|
||||
// - sim_daihe_v1 → sSimDaihe.QuerySimImei returns daiheUnsupported
|
||||
// - sim_qianxun_v3 → sSimQianxun.QuerySimImei returns qianxunUnsupported
|
||||
//
|
||||
// Note that the shared-token templates (sim_onelink_*) are NOT listed here:
|
||||
// they are registered to sSimYD, which does implement QuerySimImei. Verified
|
||||
// against production data on 2026-10-12 — every sim_onelink_* template with
|
||||
// cards is served by sSimYD.
|
||||
var imeiUnsupportedTemplates = map[string]struct{}{
|
||||
"sim_mingqian_v3": {},
|
||||
"sim_daihe_v1": {},
|
||||
"sim_qianxun_v3": {},
|
||||
}
|
||||
|
||||
// mobileTemplates lists the templates served by the China Mobile OneLink
|
||||
// implementation, so numeric codes 13000-13007 can be classified as
|
||||
// "system busy" for them. Derived from the chan registration table; keep in
|
||||
// sync with that table. Unknown templates stay generic and are never treated
|
||||
// as mobile.
|
||||
var mobileTemplates = map[string]struct{}{
|
||||
"sim_yd_v5": {},
|
||||
"sim_yd_mock_v5": {},
|
||||
"sim_sdtywl_v1": {},
|
||||
"sim_onelink_tengyu_v1": {},
|
||||
"sim_onelink_xuzhou_mobile_v1": {},
|
||||
"sim_onelink_lelian_v1": {},
|
||||
"sim_onelink_qianxun_v1": {},
|
||||
"sim_onelink_henan_xinjing_v1": {},
|
||||
"sim_henan_xinjing_direct_v1": {},
|
||||
"sim_onelink_zibo_v1": {},
|
||||
"sim_tibiot_v1": {},
|
||||
"sim_yunda_v1": {},
|
||||
"sim_onelink_daihe_v1": {},
|
||||
"sim_onelink_daliuliang_v1": {},
|
||||
"sim__daliuliang_v1": {},
|
||||
"sim_onelink_mobile_public_v1": {},
|
||||
"sim_onelink_kedang_v1": {},
|
||||
"sim_onelink_beixin_v1": {},
|
||||
"lanshang_8000": {},
|
||||
"lanshang_8003": {},
|
||||
}
|
||||
|
||||
func isMobileTemplate(tempKey string) bool {
|
||||
_, ok := mobileTemplates[strings.ToLower(strings.TrimSpace(tempKey))]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isImeiUnsupportedTemplate(tempKey string) bool {
|
||||
_, ok := imeiUnsupportedTemplates[strings.ToLower(strings.TrimSpace(tempKey))]
|
||||
return ok
|
||||
}
|
||||
|
||||
// isNoDeviceMessage reports whether a carrier message means "this card has no
|
||||
// device bound, so no IMEI exists".
|
||||
//
|
||||
// This is only consulted for codes that carriers reuse for genuine faults (the
|
||||
// Tibiot 500). Codes that are unambiguous on their own (14000, -1) do not need
|
||||
// it, and deliberately do not depend on the text: the message is carrier
|
||||
// wording and may be reworded at any time.
|
||||
func isNoDeviceMessage(message string) bool {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(message, tibiotNoInfoMessage) ||
|
||||
strings.Contains(message, mobileNoRecordMessage)
|
||||
}
|
||||
|
||||
// ImeiClient is the boundary to the chan gateway.
|
||||
type ImeiClient interface {
|
||||
QuerySimImei(context.Context, *model.SimImeiInput) ([]model.SimBaseRes[model.SimImeiItem], error)
|
||||
}
|
||||
|
||||
// ImeiRepository persists one batch of sync results.
|
||||
type ImeiRepository interface {
|
||||
ApplyBatch(context.Context, []imeirepo.Result) error
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
client ImeiClient
|
||||
repo ImeiRepository
|
||||
networkRetries int
|
||||
}
|
||||
|
||||
func NewHandler(client ImeiClient, repo ImeiRepository, networkRetries ...int) (*Handler, error) {
|
||||
if client == nil || repo == nil {
|
||||
return nil, fmt.Errorf("imei handler dependencies are incomplete")
|
||||
}
|
||||
retries := 0
|
||||
if len(networkRetries) > 0 && networkRetries[0] > 0 {
|
||||
retries = networkRetries[0]
|
||||
}
|
||||
return &Handler{client: client, repo: repo, networkRetries: retries}, nil
|
||||
}
|
||||
|
||||
// Query performs one carrier IMEI lookup and maps the outcome onto the queue's
|
||||
// retry semantics.
|
||||
//
|
||||
// The classification matrix is deliberately explicit, because the difference
|
||||
// between "retry forever" and "park as terminal" decides whether the failure
|
||||
// pool stays meaningful:
|
||||
//
|
||||
// network error → network retryable
|
||||
// empty result slice → protocol retryable
|
||||
// mismatched card identifier → protocol retryable
|
||||
// Code == 0, non-empty IMEI → success, value written
|
||||
// Code == 0, empty IMEI → data_invalid terminal, covered, heartbeat only
|
||||
// 14000 → no_record terminal, covered, heartbeat only
|
||||
// -1 (no device info) → no_record terminal, covered, heartbeat only
|
||||
// 500 + "查询不到信息" (Tibiot) → no_record terminal, covered, heartbeat only
|
||||
// 13000-13007 on a mobile template → mobile_busy retryable
|
||||
// unsupported template → unsupported terminal failure
|
||||
// 9999 with empty errCode → gateway retryable
|
||||
// 12021 → token_expired retryable
|
||||
// any other non-zero Code → business terminal failure
|
||||
//
|
||||
// "Covered" marks a terminal answer that says something definitive about the
|
||||
// card but produces no value. It is deliberately not a failure: it records
|
||||
// coverage (ImeiSynced) so the heartbeat is written, while the queue keeps it
|
||||
// out of the failure pool and out of the run's failure count.
|
||||
func (h *Handler) Query(ctx context.Context, task queue.Task) zsetconsumer.Result {
|
||||
result := zsetconsumer.Result{Task: task}
|
||||
|
||||
// An unsupported template is known before any network call: skip the
|
||||
// request entirely and park the card immediately.
|
||||
if isImeiUnsupportedTemplate(task.TempKey) {
|
||||
result.ErrorClass = unsupportedClass
|
||||
result.Message = "template does not support imei query"
|
||||
return result
|
||||
}
|
||||
|
||||
input := &model.SimImeiInput{SimKeys: []*model.SimKey{{
|
||||
ChanKey: task.ChanKey,
|
||||
ICCID: task.ICCID,
|
||||
IMSI: task.IMSI,
|
||||
MSISDN: task.MSISDN,
|
||||
}}}
|
||||
|
||||
var (
|
||||
output []model.SimBaseRes[model.SimImeiItem]
|
||||
err error
|
||||
)
|
||||
for attempt := 0; attempt <= h.networkRetries; attempt++ {
|
||||
output, err = h.client.QuerySimImei(ctx, input)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
result.ErrorClass = networkClass
|
||||
result.Retryable = true
|
||||
result.Message = err.Error()
|
||||
return result
|
||||
}
|
||||
if len(output) == 0 {
|
||||
result.ErrorClass = protocolClass
|
||||
result.Retryable = true
|
||||
result.Message = "chan returned empty imei result"
|
||||
return result
|
||||
}
|
||||
|
||||
item := output[0]
|
||||
if item.SimKey == nil {
|
||||
item.SimKey = &model.SimKey{ChanKey: task.ChanKey, ICCID: task.ICCID, IMSI: task.IMSI, MSISDN: task.MSISDN}
|
||||
}
|
||||
if task.ICCID != "" && item.SimKey.ICCID != task.ICCID {
|
||||
result.ErrorClass = protocolClass
|
||||
result.Retryable = true
|
||||
result.Message = "chan returned mismatched card identifier"
|
||||
return result
|
||||
}
|
||||
|
||||
result.Code = item.Code
|
||||
result.Message = item.Message
|
||||
result.ErrCode = item.ErrCode
|
||||
|
||||
if item.Code != 0 {
|
||||
switch {
|
||||
case isMobileTemplate(task.TempKey) && item.Code >= mobileBusyFirstCode && item.Code <= mobileBusyLastCode:
|
||||
result.ErrorClass = mobileBusyClass
|
||||
result.Retryable = true
|
||||
case item.Code == chanGatewayInternalErrorCode && strings.TrimSpace(item.ErrCode) == "":
|
||||
result.ErrorClass = gatewayClass
|
||||
result.Retryable = true
|
||||
case item.Code == tokenExpiredCode:
|
||||
result.ErrorClass = tokenExpiredClass
|
||||
result.Retryable = true
|
||||
case item.Code == chanNoRecordCode:
|
||||
// The carrier answered definitively: this card has no IMEI on record
|
||||
// and never will until a device reports one. Retrying cannot change
|
||||
// that, and counting it as a failure reported a 21% success rate for
|
||||
// a run that had in fact queried every card successfully.
|
||||
result.ErrorClass = noRecordClass
|
||||
result.ImeiSynced = true
|
||||
result.Covered = true
|
||||
case item.Code == chanNoDeviceInfoCode:
|
||||
// Telecom 5G CMP: "查询失败,查询不到指定卡的设备信息". The card is
|
||||
// valid and the query succeeded; there is simply no device bound to
|
||||
// it, so no IMEI exists to report. Same terminal semantics as 14000.
|
||||
result.ErrorClass = noRecordClass
|
||||
result.ImeiSynced = true
|
||||
result.Covered = true
|
||||
case item.Code == chanTibiotNoInfoCode && isNoDeviceMessage(item.Message):
|
||||
// Tibiot uses 500 both for "查询不到信息" and for genuine faults,
|
||||
// so the text decides. Only the no-info wording is a terminal answer;
|
||||
// any other 500 keeps falling through to business below.
|
||||
result.ErrorClass = noRecordClass
|
||||
result.ImeiSynced = true
|
||||
result.Covered = true
|
||||
default:
|
||||
result.ErrorClass = businessClass
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Code == 0: the carrier answered. Coverage is recorded either way; the
|
||||
// value is only written when the carrier actually returned one.
|
||||
result.Success = true
|
||||
result.ImeiSynced = true
|
||||
imei := strings.TrimSpace(item.ResData.Imei)
|
||||
if imei == "" {
|
||||
// A successful query with no value is a legitimate terminal outcome
|
||||
// (device never reported an IMEI, or a multi-IMEI answer that the
|
||||
// chan layer drops). It is NOT a failure to retry, and it must never
|
||||
// clear the stored value. Like code 14000 it only records coverage.
|
||||
result.Success = false
|
||||
result.ErrorClass = dataInvalidClass
|
||||
result.Covered = true
|
||||
result.Message = "carrier returned no imei value"
|
||||
return result
|
||||
}
|
||||
result.Imei = imei
|
||||
return result
|
||||
}
|
||||
|
||||
// Apply persists one batch. Only successfully synchronized cards produce an
|
||||
// update; a value is written only when the carrier returned a non-empty one.
|
||||
//
|
||||
// The extra `result.Imei != ""` guard is intentional duplication of the
|
||||
// handler check: it makes "never clear a stored IMEI" a property of the write
|
||||
// path itself, so a future handler change cannot silently start writing empty
|
||||
// values over good data.
|
||||
func (h *Handler) Apply(ctx context.Context, results []zsetconsumer.Result) error {
|
||||
updates := make([]imeirepo.Result, 0, len(results))
|
||||
for _, result := range results {
|
||||
if !result.ImeiSynced {
|
||||
continue
|
||||
}
|
||||
update := imeirepo.Result{
|
||||
CardID: result.Task.CardID,
|
||||
Synced: true,
|
||||
ValueChanged: result.Success && result.Imei != "",
|
||||
Imei: result.Imei,
|
||||
}
|
||||
if !update.ValueChanged {
|
||||
// Clear the value so a heartbeat-only result cannot carry a stale
|
||||
// IMEI into the CASE expression.
|
||||
update.Imei = ""
|
||||
}
|
||||
updates = append(updates, update)
|
||||
}
|
||||
return h.repo.ApplyBatch(ctx, updates)
|
||||
}
|
||||
|
||||
// ShouldRetryNextRound is the full-sync retry policy: retryable failures are
|
||||
// deferred into the next run round. Terminal failures go straight to the
|
||||
// failure pool; covered results are terminal but are not failures at all.
|
||||
func ShouldRetryNextRound(result zsetconsumer.Result) bool {
|
||||
return !result.Success && result.Retryable
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
package imei
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
zsetconsumer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consumer/zset"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
imeirepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/imei"
|
||||
)
|
||||
|
||||
// fakeClient returns a canned response or error and records the call count.
|
||||
type fakeClient struct {
|
||||
output []model.SimBaseRes[model.SimImeiItem]
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeClient) QuerySimImei(context.Context, *model.SimImeiInput) ([]model.SimBaseRes[model.SimImeiItem], error) {
|
||||
f.calls++
|
||||
return f.output, f.err
|
||||
}
|
||||
|
||||
// fakeRepo captures what the handler asked to persist.
|
||||
type fakeRepo struct {
|
||||
results []imeirepo.Result
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRepo) ApplyBatch(_ context.Context, results []imeirepo.Result) error {
|
||||
f.calls++
|
||||
f.results = append(f.results, results...)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func task(tempKey string) queue.Task {
|
||||
return queue.Task{
|
||||
TaskID: "run:imei_full_sync:1",
|
||||
RunID: "run",
|
||||
ChanKey: "173",
|
||||
TempKey: tempKey,
|
||||
CardID: 1,
|
||||
ICCID: "898604F4152391115474",
|
||||
MSISDN: "1064000000001",
|
||||
IMSI: "460000000000001",
|
||||
Operator: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func item(code int, errCode, imei string) model.SimBaseRes[model.SimImeiItem] {
|
||||
return model.SimBaseRes[model.SimImeiItem]{
|
||||
Code: code,
|
||||
ErrCode: errCode,
|
||||
SimKey: &model.SimKey{ChanKey: "173", ICCID: "898604F4152391115474"},
|
||||
ResData: model.SimImeiItem{Imei: imei},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestHandler(t *testing.T, client ImeiClient, repo ImeiRepository, retries ...int) *Handler {
|
||||
t.Helper()
|
||||
h, err := NewHandler(client, repo, retries...)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandler: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// TestQuerySuccessWithValue 覆盖 §7 的正常命中路径。
|
||||
func TestQuerySuccessWithValue(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(0, "", "865466070817587")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if !result.Success {
|
||||
t.Fatalf("应成功: %+v", result)
|
||||
}
|
||||
if !result.ImeiSynced {
|
||||
t.Fatal("ImeiSynced 应为 true")
|
||||
}
|
||||
if result.Imei != "865466070817587" {
|
||||
t.Fatalf("imei = %q", result.Imei)
|
||||
}
|
||||
if result.Retryable {
|
||||
t.Fatal("成功不应可重试")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryEmptyValueIsTerminalAndSynced 覆盖 §7 T5/T6:上游成功但无值。
|
||||
// 这是最关键的语义——必须记录覆盖(ImeiSynced)但不得视为失败重试。
|
||||
func TestQueryEmptyValueIsTerminalAndSynced(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(0, "", "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.Success {
|
||||
t.Fatal("空值不应标记为 Success")
|
||||
}
|
||||
if result.Retryable {
|
||||
t.Fatal("空值是终态,不应重试")
|
||||
}
|
||||
if !result.ImeiSynced {
|
||||
t.Fatal("空值仍应记录为已同步(心跳)")
|
||||
}
|
||||
if !result.Covered {
|
||||
t.Fatal("空值与 14000 同族,不应进失败池")
|
||||
}
|
||||
if result.ErrorClass != dataInvalidClass {
|
||||
t.Fatalf("errorClass = %q, want %q", result.ErrorClass, dataInvalidClass)
|
||||
}
|
||||
if result.Imei != "" {
|
||||
t.Fatalf("imei 应为空: %q", result.Imei)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryNoRecordIsCoveredNotFailed 覆盖生产实测的主路径(2026-10-20 chan
|
||||
// 220/250/251 的 4900 条“失败”全部是 code=14000)。运营商持有卡但没有 IMEI
|
||||
// 记录,这是终态正常结果:记录覆盖、绝不重试、不进失败池。
|
||||
func TestQueryNoRecordIsCoveredNotFailed(t *testing.T) {
|
||||
for _, tempKey := range []string{"sim_yd_v5", "sim_dx_v1"} {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{
|
||||
item(chanNoRecordCode, "", ""),
|
||||
}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
// 14000 的 message 是运营商原文,处理逻辑不得依赖它。
|
||||
client.output[0].Message = "系统无记录,请检查输入参数是否正确"
|
||||
|
||||
result := handler.Query(context.Background(), task(tempKey))
|
||||
if result.Success {
|
||||
t.Fatalf("%s: 无记录不得标记为 Success", tempKey)
|
||||
}
|
||||
if result.Retryable {
|
||||
t.Fatalf("%s: 无记录是终态,不应重试", tempKey)
|
||||
}
|
||||
if !result.ImeiSynced {
|
||||
t.Fatalf("%s: 无记录仍应记录覆盖(心跳)", tempKey)
|
||||
}
|
||||
if !result.Covered {
|
||||
t.Fatalf("%s: 无记录不得计入失败池", tempKey)
|
||||
}
|
||||
if result.ErrorClass != noRecordClass {
|
||||
t.Fatalf("%s: errorClass = %q, want %q", tempKey, result.ErrorClass, noRecordClass)
|
||||
}
|
||||
if result.Imei != "" {
|
||||
t.Fatalf("%s: 无记录不得携带值: %q", tempKey, result.Imei)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryNoRecordBeatsMobileBusyRange 14000 不在 13000-13007 内,
|
||||
// 但仍要求移动模板走同一条“无记录”分类——不能被 busy 分支或业务错误吞掉。
|
||||
func TestQueryNoRecordDoesNotRetryOnMobileTemplate(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(chanNoRecordCode, "", "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.Retryable || result.ErrorClass == mobileBusyClass || result.ErrorClass == businessClass {
|
||||
t.Fatalf("14000 应为 no_record 终态: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryUnsupportedTemplateSkipsUpstream 覆盖 §7 T3:
|
||||
// 不支持的模板必须在不发请求的前提下直接终态,避免失败池爆炸。
|
||||
func TestQueryUnsupportedTemplateSkipsUpstream(t *testing.T) {
|
||||
for _, tempKey := range []string{"sim_mingqian_v3", "sim_daihe_v1", "sim_qianxun_v3"} {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(0, "", "865466070817587")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task(tempKey))
|
||||
if result.Retryable {
|
||||
t.Fatalf("%s: 不支持应为终态,不应重试", tempKey)
|
||||
}
|
||||
if result.ErrorClass != unsupportedClass {
|
||||
t.Fatalf("%s: errorClass = %q, want %q", tempKey, result.ErrorClass, unsupportedClass)
|
||||
}
|
||||
if client.calls != 0 {
|
||||
t.Fatalf("%s: 不支持模板不应发起上游请求,实际调用 %d 次", tempKey, client.calls)
|
||||
}
|
||||
if result.ImeiSynced {
|
||||
t.Fatalf("%s: 未查询不应记录覆盖", tempKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryUnsupportedTemplatesAreCaseInsensitive 模板键大小写不应影响判定。
|
||||
func TestQueryUnsupportedTemplatesAreCaseInsensitive(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
result := handler.Query(context.Background(), task("SIM_MingQian_V3"))
|
||||
if result.ErrorClass != unsupportedClass || client.calls != 0 {
|
||||
t.Fatalf("大小写变体未被识别: %+v, calls=%d", result, client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuerySharedTokenTemplatesAreSupported 回归保护:
|
||||
// sim_onelink_* 走 shared_token 模型 → sSimYD(支持 IMEI),
|
||||
// 不得被误列入不支持白名单(这是 2026-10-12 生产基线纠正过的判断)。
|
||||
func TestQuerySharedTokenTemplatesAreSupported(t *testing.T) {
|
||||
for _, tempKey := range []string{
|
||||
"sim_onelink_tengyu_v1",
|
||||
"sim_onelink_daihe_v1",
|
||||
"sim_onelink_qianxun_v1",
|
||||
"sim_onelink_kedang_v1",
|
||||
"sim_onelink_daliuliang_v1",
|
||||
"sim_onelink_mengwang_v1",
|
||||
} {
|
||||
if isImeiUnsupportedTemplate(tempKey) {
|
||||
t.Fatalf("%s 走 sSimYD 支持 IMEI,不应在不支持白名单", tempKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryMobileBusyIsRetryable 覆盖 §7 T4:移动 13000 区间跨轮重试。
|
||||
func TestQueryMobileBusyIsRetryable(t *testing.T) {
|
||||
for _, code := range []int{13000, 13003, 13007} {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(code, "", "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.ErrorClass != mobileBusyClass || !result.Retryable {
|
||||
t.Fatalf("code %d: errorClass=%q retryable=%v, want %q/true", code, result.ErrorClass, result.Retryable, mobileBusyClass)
|
||||
}
|
||||
if result.Covered {
|
||||
t.Fatalf("code %d: 系统忙必须计入失败", code)
|
||||
}
|
||||
if result.ImeiSynced {
|
||||
t.Fatalf("code %d: 系统忙不应记录覆盖", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryMobileBusyOnlyForMobileTemplates 13000 只对移动族判定为 busy。
|
||||
func TestQueryMobileBusyOnlyForMobileTemplates(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(13000, "", "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
// 电信模板收到 13000 不是移动系统忙语义,应按普通业务错误处理
|
||||
result := handler.Query(context.Background(), task("sim_dx_v1"))
|
||||
if result.ErrorClass == mobileBusyClass {
|
||||
t.Fatalf("非移动模板不应判定为 mobile_busy: %+v", result)
|
||||
}
|
||||
if result.Retryable {
|
||||
t.Fatalf("非移动模板的 13000 应为终态: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryTelecomRetryableCodeIsNotTreatedAsRetryableForImei 回归保护:
|
||||
// S00004 是电信「渠道限流」业务码,属于状态/流量链路的可重试语义;
|
||||
// IMEI 链路没有对应的限流处理,必须按普通业务错误终态处理,
|
||||
// 否则会无限重试同一张卡。
|
||||
func TestQueryTelecomRetryableCodeIsNotTreatedAsRetryableForImei(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(1, "S00004", "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_dx_v1"))
|
||||
if result.Retryable {
|
||||
t.Fatalf("IMEI 链路不应把 S00004 当作可重试: %+v", result)
|
||||
}
|
||||
if result.ErrorClass != businessClass {
|
||||
t.Fatalf("errorClass = %q, want %q", result.ErrorClass, businessClass)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryGatewayAndTokenErrorsAreRetryable 覆盖 §7 T8。
|
||||
func TestQueryGatewayAndTokenErrorsAreRetryable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code int
|
||||
errCode string
|
||||
wantClass string
|
||||
}{
|
||||
{"网关内部错误", 9999, "", gatewayClass},
|
||||
{"token 过期", tokenExpiredCode, "", tokenExpiredClass},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(tc.code, tc.errCode, "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.ErrorClass != tc.wantClass || !result.Retryable {
|
||||
t.Fatalf("%s: errorClass=%q retryable=%v, want %q/true", tc.name, result.ErrorClass, result.Retryable, tc.wantClass)
|
||||
}
|
||||
if result.Covered {
|
||||
t.Fatalf("%s: 可重试错误必须计入失败", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryBusinessErrorIsTerminal 其他业务码为终态,避免无意义重试。
|
||||
func TestQueryBusinessErrorIsTerminal(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(11011, "", "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.Retryable {
|
||||
t.Fatal("11011 码号信息错误是确定性终态,不应重试")
|
||||
}
|
||||
if result.ErrorClass != businessClass {
|
||||
t.Fatalf("errorClass = %q, want %q", result.ErrorClass, businessClass)
|
||||
}
|
||||
if result.Covered {
|
||||
t.Fatal("11011 是真失败,必须进失败池")
|
||||
}
|
||||
if result.ImeiSynced {
|
||||
t.Fatal("11011 未拿到运营商结果,不应记录覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryNetworkErrorIsRetryable 网络错误跨轮重试,且受 networkRetries 控制。
|
||||
func TestQueryNetworkErrorIsRetryable(t *testing.T) {
|
||||
client := &fakeClient{err: errors.New("connection refused")}
|
||||
handler := newTestHandler(t, client, &fakeRepo{}, 2)
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.ErrorClass != networkClass || !result.Retryable {
|
||||
t.Fatalf("errorClass=%q retryable=%v, want network/true", result.ErrorClass, result.Retryable)
|
||||
}
|
||||
// 1 次初始 + 2 次重试
|
||||
if client.calls != 3 {
|
||||
t.Fatalf("calls = %d, want 3(1 次初始 + 2 次重试)", client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryEmptyResultSliceIsProtocolError 空切片是协议异常,应重试。
|
||||
func TestQueryEmptyResultSliceIsProtocolError(t *testing.T) {
|
||||
client := &fakeClient{output: nil}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.ErrorClass != protocolClass || !result.Retryable {
|
||||
t.Fatalf("errorClass=%q retryable=%v, want protocol/true", result.ErrorClass, result.Retryable)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryMismatchedIdentifierIsProtocolError 防止上游串卡。
|
||||
func TestQueryMismatchedIdentifierIsProtocolError(t *testing.T) {
|
||||
mismatched := item(0, "", "865466070817587")
|
||||
mismatched.SimKey = &model.SimKey{ChanKey: "173", ICCID: "OTHER-CARD"}
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{mismatched}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_yd_v5"))
|
||||
if result.ErrorClass != protocolClass || !result.Retryable {
|
||||
t.Fatalf("串卡应判为协议错误: %+v", result)
|
||||
}
|
||||
if result.ImeiSynced {
|
||||
t.Fatal("串卡不应记录覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyWritesValueOnlyWhenChanged 覆盖 §7 T9/T10 与 §5.2 守卫:
|
||||
// - 有值 → ValueChanged=true,带值写入
|
||||
// - 空值 → ValueChanged=false,且 Imei 必须被清空(不得覆盖已有值)
|
||||
// - 失败 → 完全不入库
|
||||
func TestApplyWritesValueOnlyWhenChanged(t *testing.T) {
|
||||
repo := &fakeRepo{}
|
||||
handler := newTestHandler(t, &fakeClient{}, repo)
|
||||
|
||||
successTask := task("sim_yd_v5")
|
||||
emptyTask := task("sim_yd_v5")
|
||||
emptyTask.CardID = 2
|
||||
failedTask := task("sim_yd_v5")
|
||||
failedTask.CardID = 3
|
||||
|
||||
err := handler.Apply(context.Background(), []zsetconsumer.Result{
|
||||
{Task: successTask, Success: true, ImeiSynced: true, Imei: "865466070817587"},
|
||||
{Task: emptyTask, Success: false, ImeiSynced: true, ErrorClass: dataInvalidClass},
|
||||
{Task: failedTask, Success: false, Retryable: true, ErrorClass: networkClass, ImeiSynced: false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if len(repo.results) != 2 {
|
||||
t.Fatalf("应只写入 2 条(成功的 + 心跳的),实际 %d 条: %+v", len(repo.results), repo.results)
|
||||
}
|
||||
|
||||
// 第 1 条:值变更
|
||||
first := repo.results[0]
|
||||
if first.CardID != 1 || !first.ValueChanged || first.Imei != "865466070817587" {
|
||||
t.Fatalf("第一条应为值变更: %+v", first)
|
||||
}
|
||||
// 第 2 条:心跳,值必须为空(防止覆盖)
|
||||
second := repo.results[1]
|
||||
if second.CardID != 2 || second.ValueChanged {
|
||||
t.Fatalf("第二条应为心跳: %+v", second)
|
||||
}
|
||||
if second.Imei != "" {
|
||||
t.Fatalf("心跳结果的 Imei 必须为空,否则会覆盖已有值: %q", second.Imei)
|
||||
}
|
||||
// 第 3 条:失败完全不入库
|
||||
for _, r := range repo.results {
|
||||
if r.CardID == 3 {
|
||||
t.Fatal("失败结果不应入库")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyNeverWritesEmptyOverValue 是 §5.2 的直接回归测试:
|
||||
// 即使上游大面积返回空值,写路径也不得产生任何带值的更新。
|
||||
func TestApplyNeverWritesEmptyOverValue(t *testing.T) {
|
||||
repo := &fakeRepo{}
|
||||
handler := newTestHandler(t, &fakeClient{}, repo)
|
||||
|
||||
results := make([]zsetconsumer.Result, 0, 50)
|
||||
for i := int64(1); i <= 50; i++ {
|
||||
tk := task("sim_yd_v5")
|
||||
tk.CardID = i
|
||||
results = append(results, zsetconsumer.Result{
|
||||
Task: tk, Success: false, ImeiSynced: true, ErrorClass: dataInvalidClass,
|
||||
})
|
||||
}
|
||||
if err := handler.Apply(context.Background(), results); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if len(repo.results) != 50 {
|
||||
t.Fatalf("50 张卡都应记录心跳,实际 %d", len(repo.results))
|
||||
}
|
||||
for _, r := range repo.results {
|
||||
if r.ValueChanged || r.Imei != "" {
|
||||
t.Fatalf("空值结果不得携带值: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyNoRecordWritesHeartbeatOnly 14000 只能写心跳,绝不能清掉已有 IMEI。
|
||||
func TestApplyNoRecordWritesHeartbeatOnly(t *testing.T) {
|
||||
repo := &fakeRepo{}
|
||||
handler := newTestHandler(t, &fakeClient{}, repo)
|
||||
|
||||
noRecord := task("sim_yd_v5")
|
||||
if err := handler.Apply(context.Background(), []zsetconsumer.Result{
|
||||
{Task: noRecord, Success: false, ImeiSynced: true, Covered: true, ErrorClass: noRecordClass, Code: chanNoRecordCode},
|
||||
}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if len(repo.results) != 1 {
|
||||
t.Fatalf("无记录仍应写入 1 条心跳,实际 %d: %+v", len(repo.results), repo.results)
|
||||
}
|
||||
got := repo.results[0]
|
||||
if !got.Synced || got.ValueChanged || got.Imei != "" {
|
||||
t.Fatalf("无记录应只写心跳: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShouldRetryNextRound 覆盖重试策略。
|
||||
func TestShouldRetryNextRound(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result zsetconsumer.Result
|
||||
want bool
|
||||
}{
|
||||
{"成功不重试", zsetconsumer.Result{Success: true}, false},
|
||||
{"可重试失败", zsetconsumer.Result{Success: false, Retryable: true}, true},
|
||||
{"终态失败不重试", zsetconsumer.Result{Success: false, Retryable: false}, false},
|
||||
{"无记录不重试", zsetconsumer.Result{Success: false, Covered: true, ErrorClass: noRecordClass}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ShouldRetryNextRound(tc.result); got != tc.want {
|
||||
t.Fatalf("%s: got %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryNoDeviceIsCoveredNotFailed 覆盖 2026-10-22 生产 run
|
||||
// imei-20261022000000 的 12,865 条终态失败:全部是「卡上没有绑定设备,
|
||||
// 所以没有 IMEI」,不是真失败。
|
||||
//
|
||||
// 证据(生产日志实采):
|
||||
// - 电信 5G CMP 11,240 条:result="-1",message=查询失败,查询不到指定卡的设备信息
|
||||
// (通道 142/111/187,template sim_dx_v1)
|
||||
// - Tibiot 1,612 条:HTTP 500 + body status=500,message=查询不到信息(1,494)
|
||||
// 或 系统无记录(118)
|
||||
//
|
||||
// 三者都是终态:记录覆盖(写心跳)、不重试、不进失败池。
|
||||
func TestQueryNoDeviceIsCoveredNotFailed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tempKey string
|
||||
code int
|
||||
message string
|
||||
}{
|
||||
{"电信无绑定设备", "sim_dx_v1", chanNoDeviceInfoCode, "查询失败,查询不到指定卡的设备信息"},
|
||||
{"Tibiot 查询不到信息", "sim_tibiot_v1", chanTibiotNoInfoCode, "查询不到信息"},
|
||||
{"Tibiot 系统无记录", "sim_tibiot_v1", chanTibiotNoInfoCode, "系统无记录,请检查输入参数是否正确"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
it := item(tc.code, "", "")
|
||||
it.Message = tc.message
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{it}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task(tc.tempKey))
|
||||
if result.Success {
|
||||
t.Fatalf("%s: 无设备不得标记为 Success", tc.name)
|
||||
}
|
||||
if result.Retryable {
|
||||
t.Fatalf("%s: 无设备是终态,不应重试", tc.name)
|
||||
}
|
||||
if !result.ImeiSynced {
|
||||
t.Fatalf("%s: 无设备仍应记录覆盖(心跳)", tc.name)
|
||||
}
|
||||
if !result.Covered {
|
||||
t.Fatalf("%s: 无设备不得计入失败池", tc.name)
|
||||
}
|
||||
if result.ErrorClass != noRecordClass {
|
||||
t.Fatalf("%s: errorClass = %q, want %q", tc.name, result.ErrorClass, noRecordClass)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryTibiotNonNoInfo500StaysBusiness 锁住 500 的边界:Tibiot 也用 500
|
||||
// 表达真实故障,只有「查询不到信息/系统无记录」的文案才是终态无记录。
|
||||
// 不加这道闸门会把真故障当成功覆盖掉,那是比多算失败更严重的错误。
|
||||
func TestQueryTibiotNonNoInfo500StaysBusiness(t *testing.T) {
|
||||
for _, message := range []string{"系统繁忙,请稍后重试", "服务异常", "internal error", ""} {
|
||||
it := item(chanTibiotNoInfoCode, "", "")
|
||||
it.Message = message
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{it}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_tibiot_v1"))
|
||||
if result.Covered {
|
||||
t.Fatalf("message=%q: 非无记录文案的 500 不得计入覆盖", message)
|
||||
}
|
||||
if result.ImeiSynced {
|
||||
t.Fatalf("message=%q: 非无记录文案的 500 不应记录覆盖", message)
|
||||
}
|
||||
if result.ErrorClass != businessClass {
|
||||
t.Fatalf("message=%q: errorClass = %q, want %q", message, result.ErrorClass, businessClass)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryNoDeviceCodeIgnoresMessage 14000 与 -1 语义明确,不得依赖运营商文案
|
||||
// (文案随时可能改写),即使 message 为空也要判为无记录。
|
||||
func TestQueryNoDeviceCodeIgnoresMessage(t *testing.T) {
|
||||
for _, code := range []int{chanNoRecordCode, chanNoDeviceInfoCode} {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimImeiItem]{item(code, "", "")}}
|
||||
handler := newTestHandler(t, client, &fakeRepo{})
|
||||
|
||||
result := handler.Query(context.Background(), task("sim_dx_v1"))
|
||||
if !result.Covered || result.ErrorClass != noRecordClass {
|
||||
t.Fatalf("code=%d 空 message 仍应判 no_record: %+v", code, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,806 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
adapter, err := gcfg.NewAdapterContent("batch:\n inbound:\n upstreamBatchSize: 1\n")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestAdminSourceTypeContract(t *testing.T) {
|
||||
if consts.BatchSourceTypeRange != 1 || consts.BatchSourceTypeBatchInput != 2 || consts.BatchSourceTypeFile != 3 {
|
||||
t.Fatalf("source_type contract mismatch: range=%d input=%d file=%d",
|
||||
consts.BatchSourceTypeRange, consts.BatchSourceTypeBatchInput, consts.BatchSourceTypeFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCascadeFailurePreservesPerCardValidation(t *testing.T) {
|
||||
task := &Task{Rows: []*Row{
|
||||
{ICCID: "invalid", Reason: "卡号不合法"},
|
||||
{ICCID: "valid"},
|
||||
}}
|
||||
applyCascadeFailure(task)
|
||||
if countFailedRows(task.Rows) != 1 || !IsCascadeFailure(task.Rows[1]) {
|
||||
t.Fatalf("unexpected validation outcomes: %+v", task.Rows)
|
||||
}
|
||||
if task.Rows[0].Reason != "卡号不合法" || inboundRowStatus(task.Rows[0]) != "失败" || inboundRowStatus(task.Rows[1]) != "未执行" {
|
||||
t.Fatalf("wrong per-card status: %+v", task.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCollectsLaterInboundFailuresBeforeBlockingBatch(t *testing.T) {
|
||||
task := &Task{Rows: []*Row{
|
||||
{ICCID: "bad", ChanKey: "channel"},
|
||||
{ICCID: "8986000000000000001", MSISDN: "13800008000", IMSI: "460001234567890", ChanKey: "channel"},
|
||||
{ICCID: "8986000000000000002", MSISDN: "invalid", IMSI: "invalid", ChanKey: "channel"},
|
||||
}}
|
||||
executor := &executor{repository: &inboundRepositoryStub{}}
|
||||
if err := executor.validate(context.Background(), task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyCascadeFailure(task)
|
||||
if !strings.Contains(task.Rows[0].Reason, "ICCID格式不正确") {
|
||||
t.Fatalf("first failure was lost: %q", task.Rows[0].Reason)
|
||||
}
|
||||
if !IsCascadeFailure(task.Rows[1]) {
|
||||
t.Fatalf("validated card was not blocked: %q", task.Rows[1].Reason)
|
||||
}
|
||||
for _, reason := range []string{"MSISDN格式不正确", "IMSI格式不正确"} {
|
||||
if !strings.Contains(task.Rows[2].Reason, reason) {
|
||||
t.Fatalf("later card was not fully validated: %q", task.Rows[2].Reason)
|
||||
}
|
||||
}
|
||||
if countFailedRows(task.Rows) != 2 || countValidRows(task.Rows) != 0 {
|
||||
t.Fatalf("batch must have two validation failures and no executable cards: %+v", task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRowRequiresAtLeastOneCardIdentifierAndChanKey(t *testing.T) {
|
||||
row := &Row{}
|
||||
validateRow(row, "")
|
||||
|
||||
for _, want := range []string{"ICCID/MSISDN/IMSI至少填写一个", "chanKey不能为空"} {
|
||||
if !containsReason(row.Reason, want) {
|
||||
t.Fatalf("expected reason %q in %q", want, row.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputTextAcceptsOneIdentifierPerLine(t *testing.T) {
|
||||
rows := parseInputText(strings.Join([]string{
|
||||
"89860401786527567308",
|
||||
"14100007308",
|
||||
"460046527567308",
|
||||
}, "\n"))
|
||||
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("row count = %d, want 3", len(rows))
|
||||
}
|
||||
if rows[0].ICCID != "89860401786527567308" || rows[1].MSISDN != "14100007308" || rows[2].IMSI != "460046527567308" {
|
||||
t.Fatalf("unexpected parsed rows: %+v", rows)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.InputCode == "" {
|
||||
t.Fatalf("expected input code to be recorded: %+v", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRowRejectsTooShortIdentifiers(t *testing.T) {
|
||||
row := &Row{ICCID: "4", MSISDN: "4", IMSI: "4", ChanKey: "chan-a"}
|
||||
validateRow(row, "")
|
||||
|
||||
for _, want := range []string{"ICCID格式不正确", "MSISDN格式不正确", "IMSI格式不正确"} {
|
||||
if !containsReason(row.Reason, want) {
|
||||
t.Fatalf("expected reason %q in %q", want, row.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandRangeUsesSelectedMSISDN(t *testing.T) {
|
||||
rows, err := expandRange(Range{RangeType: "msisdn", StartMSISDN: "13800008000", EndMSISDN: "13800008001"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 2 || rows[0].MSISDN != "13800008000" || rows[1].MSISDN != "13800008001" {
|
||||
t.Fatalf("unexpected MSISDN rows: %+v", rows)
|
||||
}
|
||||
if rows[0].ICCID != "" || rows[0].IMSI != "" {
|
||||
t.Fatalf("unselected identifiers must remain empty: %+v", rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandRangeAllowsAlphaNumericICCIDRange(t *testing.T) {
|
||||
rows, err := expandRange(Range{RangeType: "iccid", StartICCID: "898604F000000000001", EndICCID: "898604F000000000003"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"898604F000000000001",
|
||||
"898604F000000000002",
|
||||
"898604F000000000003",
|
||||
}
|
||||
if len(rows) != len(want) {
|
||||
t.Fatalf("row count = %d, want %d", len(rows), len(want))
|
||||
}
|
||||
for i, value := range want {
|
||||
if rows[i].ICCID != value {
|
||||
t.Fatalf("row %d ICCID = %q, want %q", i, rows[i].ICCID, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandRangeRejectsAlphaNumericMSISDNRange(t *testing.T) {
|
||||
_, err := expandRange(Range{RangeType: "msisdn", StartMSISDN: "1380013800A", EndMSISDN: "1380013800B"})
|
||||
if err == nil || !strings.Contains(err.Error(), "range values must be numeric") {
|
||||
t.Fatalf("expected numeric range error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRowSelectedMSISDNDoesNotRequireOtherIdentifiers(t *testing.T) {
|
||||
row := &Row{MSISDN: "13800008000", ChanKey: "chan-a"}
|
||||
validateRow(row, "msisdn")
|
||||
if row.Reason != "" {
|
||||
t.Fatalf("unexpected validation reason: %q", row.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRowAllowsAlphaNumericICCID(t *testing.T) {
|
||||
row := &Row{ICCID: "898604F4152391115473", MSISDN: "13800008000", IMSI: "460001234567890", ChanKey: "chan-a"}
|
||||
validateRow(row, "")
|
||||
if row.Reason != "" {
|
||||
t.Fatalf("unexpected validation reason: %q", row.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkSameBatchDuplicatesChecksICCIDAndMSISDN(t *testing.T) {
|
||||
rows := []*Row{
|
||||
{RowNo: 1, ICCID: "8986", MSISDN: "13800008000"},
|
||||
{RowNo: 2, ICCID: "8986", MSISDN: "13800008001"},
|
||||
{RowNo: 3, ICCID: "8987", MSISDN: "13800008000"},
|
||||
}
|
||||
|
||||
markSameBatchDuplicates(rows)
|
||||
|
||||
if !containsReason(rows[1].Reason, "同批重复ICCID") {
|
||||
t.Fatalf("expected row 2 duplicate ICCID, got %q", rows[1].Reason)
|
||||
}
|
||||
if !containsReason(rows[2].Reason, "同批重复MSISDN") {
|
||||
t.Fatalf("expected row 3 duplicate MSISDN, got %q", rows[2].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkSameBatchDuplicatesChecksIMSI(t *testing.T) {
|
||||
rows := []*Row{
|
||||
{RowNo: 1, IMSI: "460001234567890"},
|
||||
{RowNo: 2, IMSI: "460001234567890"},
|
||||
}
|
||||
|
||||
markSameBatchDuplicates(rows)
|
||||
|
||||
if !containsReason(rows[1].Reason, "同批重复IMSI") {
|
||||
t.Fatalf("expected duplicate IMSI, got %q", rows[1].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpstreamBasicInfoAcceptsMatchingIdentifiers(t *testing.T) {
|
||||
rows := []*Row{{RowNo: 1, ICCID: "898604F4152391115473", MSISDN: "1441543615473", IMSI: "460087436015473", ChanKey: "chan-a"}}
|
||||
upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{
|
||||
{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "898604F4152391115473"},
|
||||
ResData: model.SimBasicInfoItem{
|
||||
ICCID: "898604F4152391115473",
|
||||
MSISDN: "1441543615473",
|
||||
IMSI: "460087436015473",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
err := (&executor{upstream: upstream}).validateUpstreamBasicInfo(context.Background(), &Task{Rows: rows})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows[0].Reason != "" {
|
||||
t.Fatalf("unexpected reason: %q", rows[0].Reason)
|
||||
}
|
||||
if upstream.calls != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", upstream.calls)
|
||||
}
|
||||
if len(upstream.inputs) != 1 || len(upstream.inputs[0].SimKeys) != 1 {
|
||||
t.Fatalf("unexpected upstream inputs: %+v", upstream.inputs)
|
||||
}
|
||||
requestKey := upstream.inputs[0].SimKeys[0]
|
||||
if requestKey.ChanKey != "chan-a" || requestKey.ICCID != "898604F4152391115473" {
|
||||
t.Fatalf("unexpected upstream request key: %+v", requestKey)
|
||||
}
|
||||
if requestKey.MSISDN != "" || requestKey.IMSI != "" {
|
||||
t.Fatalf("upstream basic-info request must only carry chanKey and ICCID: %+v", requestKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpstreamBasicInfoRequiresCompleteResData(t *testing.T) {
|
||||
rows := []*Row{{RowNo: 1, ICCID: "898604F4152391115473", MSISDN: "1441543615473", IMSI: "460087436015473", ChanKey: "chan-a"}}
|
||||
upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{
|
||||
{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "898604f4152391115473", MSISDN: "1441543615473", IMSI: "460087436015473"},
|
||||
ResData: model.SimBasicInfoItem{
|
||||
ActiveDate: "2026-08-12 00:00:00",
|
||||
OpenDate: "2026-08-12 00:00:00",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
err := (&executor{upstream: upstream}).validateUpstreamBasicInfo(context.Background(), &Task{Rows: rows})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !containsReason(rows[0].Reason, "上游未返回三码信息") {
|
||||
t.Fatalf("expected missing upstream data failure, got %q", rows[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpstreamBasicInfoPreservesNotFoundMessage(t *testing.T) {
|
||||
rows := []*Row{{RowNo: 1, ICCID: "8986112223600594371", ChanKey: "chan-a"}}
|
||||
upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{
|
||||
{
|
||||
Code: 0,
|
||||
Message: "未查询到对应SIM卡信息",
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "8986112223600594371"},
|
||||
},
|
||||
}}
|
||||
|
||||
err := (&executor{upstream: upstream}).validateUpstreamBasicInfo(context.Background(), &Task{Rows: rows})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows[0].Reason != "上游三码校验失败: 未查询到对应SIM卡信息" {
|
||||
t.Fatalf("unexpected reason: %q", rows[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpstreamBasicInfoMatchesByMSISDNAndBackfillsCodes(t *testing.T) {
|
||||
rows := []*Row{{RowNo: 1, MSISDN: "14100007308", ChanKey: "chan-a"}}
|
||||
upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{
|
||||
{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", MSISDN: "14100007308"},
|
||||
ResData: model.SimBasicInfoItem{
|
||||
ICCID: "89860401786527567308",
|
||||
MSISDN: "14100007308",
|
||||
IMSI: "460046527567308",
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
err := (&executor{upstream: upstream}).validateUpstreamBasicInfo(context.Background(), &Task{Rows: rows})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows[0].Reason != "" {
|
||||
t.Fatalf("unexpected reason: %q", rows[0].Reason)
|
||||
}
|
||||
if rows[0].ICCID != "89860401786527567308" || rows[0].IMSI != "460046527567308" {
|
||||
t.Fatalf("expected upstream codes to be backfilled, got %+v", rows[0])
|
||||
}
|
||||
requestKey := upstream.inputs[0].SimKeys[0]
|
||||
if requestKey.ICCID != "" || requestKey.MSISDN != "14100007308" || requestKey.IMSI != "" {
|
||||
t.Fatalf("unexpected upstream request key: %+v", requestKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBatchInputSingleMSISDNBackfillsBeforeCompletenessCheck(t *testing.T) {
|
||||
rows := parseInputText("14100007308")
|
||||
repo := &inboundRepositoryStub{}
|
||||
upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{
|
||||
{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", MSISDN: "14100007308"},
|
||||
ResData: model.SimBasicInfoItem{
|
||||
ICCID: "89860401786527567308",
|
||||
MSISDN: "14100007308",
|
||||
IMSI: "460046527567308",
|
||||
},
|
||||
},
|
||||
}}
|
||||
task := &Task{
|
||||
Batch: Batch{SourceType: consts.BatchSourceTypeBatchInput},
|
||||
Options: Options{ChanKey: "chan-a", ValidateUpstreamBasicInfo: "1"},
|
||||
Rows: rows,
|
||||
}
|
||||
|
||||
err := (&executor{repository: repo, upstream: upstream}).validate(context.Background(), task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if task.ValidCount != 1 {
|
||||
t.Fatalf("valid count = %d, want 1, reason=%q", task.ValidCount, rows[0].Reason)
|
||||
}
|
||||
if rows[0].ICCID != "89860401786527567308" || rows[0].IMSI != "460046527567308" {
|
||||
t.Fatalf("expected upstream codes to be backfilled, got %+v", rows[0])
|
||||
}
|
||||
if upstream.calls != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", upstream.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSkipsUpstreamBasicInfoWhenDisabled(t *testing.T) {
|
||||
rows := []*Row{{
|
||||
RowNo: 1,
|
||||
ICCID: "89860401786527567308",
|
||||
MSISDN: "14100007308",
|
||||
IMSI: "460046527567308",
|
||||
ChanKey: "chan-a",
|
||||
}}
|
||||
upstream := &basicInfoUpstreamStub{err: errors.New("gateway unavailable")}
|
||||
task := &Task{
|
||||
Batch: Batch{SourceType: consts.BatchSourceTypeFile},
|
||||
Options: Options{ValidateUpstreamBasicInfo: "0"},
|
||||
Rows: rows,
|
||||
}
|
||||
|
||||
err := (&executor{repository: &inboundRepositoryStub{}, upstream: upstream}).validate(context.Background(), task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("upstream calls = %d, want 0", upstream.calls)
|
||||
}
|
||||
if task.ValidCount != 1 {
|
||||
t.Fatalf("valid count = %d, want 1, reason=%q", task.ValidCount, rows[0].Reason)
|
||||
}
|
||||
if task.Options.ValidateUpstreamBasicInfo != "0" {
|
||||
t.Fatalf("validate option = %q, want 0", task.Options.ValidateUpstreamBasicInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunsUpstreamBasicInfoWhenEnabled(t *testing.T) {
|
||||
rows := []*Row{{
|
||||
RowNo: 1,
|
||||
ICCID: "89860401786527567308",
|
||||
MSISDN: "14100007308",
|
||||
IMSI: "460046527567308",
|
||||
ChanKey: "chan-a",
|
||||
}}
|
||||
upstream := &basicInfoUpstreamStub{err: errors.New("gateway unavailable")}
|
||||
task := &Task{
|
||||
Batch: Batch{SourceType: consts.BatchSourceTypeFile},
|
||||
Options: Options{ValidateUpstreamBasicInfo: "1"},
|
||||
Rows: rows,
|
||||
}
|
||||
|
||||
err := (&executor{repository: &inboundRepositoryStub{}, upstream: upstream}).validate(context.Background(), task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", upstream.calls)
|
||||
}
|
||||
if task.ValidCount != 0 {
|
||||
t.Fatalf("valid count = %d, want 0, reason=%q", task.ValidCount, rows[0].Reason)
|
||||
}
|
||||
if task.Options.ValidateUpstreamBasicInfo != "1" {
|
||||
t.Fatalf("validate option = %q, want 1", task.Options.ValidateUpstreamBasicInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopulateExcelRowsReturnsRetryableErrorWhenSourceIsUnreadable(t *testing.T) {
|
||||
sourceErr := errors.New("OSS local file does not exist")
|
||||
task := &Task{Source: Source{ImportFileOssID: 1270}}
|
||||
repository := &inboundRepositoryStub{readOssErr: sourceErr}
|
||||
|
||||
err := (&executor{repository: repository}).populateExcelRows(context.Background(), task)
|
||||
if !errors.Is(err, sourceErr) || !batch.IsRetryable(err) {
|
||||
t.Fatalf("populateExcelRows() error = %v, want retryable wrapper of %v", err, sourceErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpstreamBasicInfoWaitsForRateLimitBeforeQuery(t *testing.T) {
|
||||
var order []string
|
||||
rows := []*Row{{RowNo: 1, ICCID: "89860401786527567308", MSISDN: "14100007308", IMSI: "460046527567308", ChanKey: "chan-a"}}
|
||||
limiter := &recordingRateLimiter{
|
||||
onWait: func(context.Context, string, int) error {
|
||||
order = append(order, "limit")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
upstream := &basicInfoUpstreamStub{
|
||||
onQuery: func() {
|
||||
order = append(order, "query")
|
||||
},
|
||||
response: []model.SimBaseRes[model.SimBasicInfoItem]{
|
||||
{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "89860401786527567308"},
|
||||
ResData: model.SimBasicInfoItem{
|
||||
ICCID: "89860401786527567308",
|
||||
MSISDN: "14100007308",
|
||||
IMSI: "460046527567308",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := (&executor{upstream: upstream, rateLimiter: limiter}).validateUpstreamBasicInfo(context.Background(), &Task{
|
||||
Batch: Batch{BatchNo: "B-1"},
|
||||
Rows: rows,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if limiter.calls != 1 || limiter.chanKey != "chan-a" || limiter.itemCount != 1 {
|
||||
t.Fatalf("unexpected limiter call: %+v", limiter)
|
||||
}
|
||||
if upstream.calls != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", upstream.calls)
|
||||
}
|
||||
if len(order) != 2 || order[0] != "limit" || order[1] != "query" {
|
||||
t.Fatalf("unexpected call order: %+v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpstreamBasicInfoStopsWhenRateLimitFails(t *testing.T) {
|
||||
rows := []*Row{{RowNo: 1, ICCID: "89860401786527567308", ChanKey: "chan-a"}}
|
||||
upstream := &basicInfoUpstreamStub{}
|
||||
limiter := &recordingRateLimiter{err: errors.New("redis down")}
|
||||
|
||||
err := (&executor{upstream: upstream, rateLimiter: limiter}).validateUpstreamBasicInfo(context.Background(), &Task{
|
||||
Batch: Batch{BatchNo: "B-1"},
|
||||
Rows: rows,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("upstream calls = %d, want 0", upstream.calls)
|
||||
}
|
||||
if !strings.Contains(rows[0].Reason, "redis down") {
|
||||
t.Fatalf("expected rate limit failure reason, got %q", rows[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpstreamBasicInfoRejectsMismatch(t *testing.T) {
|
||||
rows := []*Row{{RowNo: 1, ICCID: "89860001", MSISDN: "13800008000", IMSI: "460001", ChanKey: "chan-a"}}
|
||||
upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{
|
||||
{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "89860001"},
|
||||
ResData: model.SimBasicInfoItem{ICCID: "89860001", MSISDN: "13800008099", IMSI: "460001"},
|
||||
},
|
||||
}}
|
||||
|
||||
err := (&executor{upstream: upstream}).validateUpstreamBasicInfo(context.Background(), &Task{Rows: rows})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(rows[0].Reason, "MSISDN") {
|
||||
t.Fatalf("expected MSISDN mismatch reason, got %q", rows[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpstreamBasicInfoRejectsUpstreamError(t *testing.T) {
|
||||
rows := []*Row{{RowNo: 1, ICCID: "89860001", ChanKey: "chan-a"}}
|
||||
upstream := &basicInfoUpstreamStub{err: errors.New("gateway unavailable")}
|
||||
|
||||
err := (&executor{upstream: upstream}).validateUpstreamBasicInfo(context.Background(), &Task{Rows: rows})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(rows[0].Reason, "gateway unavailable") {
|
||||
t.Fatalf("expected upstream error reason, got %q", rows[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableRowsAcceptsChineseInboundHeaders(t *testing.T) {
|
||||
rows := parseTableRows([][]string{
|
||||
{"ICCID号", "手机号", "国际移动用户识别码", "国际移动设备识别码", "通道编码", "卡类型", "上游套餐编码", "供应商编码"},
|
||||
{"898604F4152391115473", "1441543615473", "460087436015473", "865111111111111", "channel_003", "通用插拔 SIM 卡", "UP001", "SUP001"},
|
||||
})
|
||||
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("row count = %d, want 1", len(rows))
|
||||
}
|
||||
row := rows[0]
|
||||
if row.ICCID != "898604F4152391115473" || row.MSISDN != "1441543615473" ||
|
||||
row.IMSI != "460087436015473" || row.IMEI != "865111111111111" || row.ChanKey != "channel_003" {
|
||||
t.Fatalf("unexpected parsed row: %+v", row)
|
||||
}
|
||||
if row.CardType != "通用插拔 SIM 卡" || row.UpPkgCode != "UP001" || row.SuppliersCode != "SUP001" {
|
||||
t.Fatalf("unexpected inbound options: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableRowsAcceptsRequiredMarkerHeaders(t *testing.T) {
|
||||
rows := parseTableRows([][]string{
|
||||
{"ICCID*", "MSISDN*", "IMSI*", "通道编号*"},
|
||||
{"89860401786527567308", "14100007308", "460046527567308", "channel_003"},
|
||||
})
|
||||
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("row count = %d, want 1", len(rows))
|
||||
}
|
||||
row := rows[0]
|
||||
if row.ICCID != "89860401786527567308" || row.MSISDN != "14100007308" ||
|
||||
row.IMSI != "460046527567308" || row.ChanKey != "channel_003" {
|
||||
t.Fatalf("unexpected parsed row: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundExcelHeaderRequiresRecognizedIdentifier(t *testing.T) {
|
||||
for _, fields := range [][]string{{"随机内容", "乱码"}, {"通道编号", "卡类型"}, {"ICCID", "通道编号"}, {"MSISDN", "通道编号"}, {"IMSI", "通道编号"}} {
|
||||
got := hasInboundIdentifierHeader(headerIndex(fields))
|
||||
want := fields[0] == "ICCID" || fields[0] == "MSISDN" || fields[0] == "IMSI"
|
||||
if got != want {
|
||||
t.Fatalf("header %v valid = %t, want %t", fields, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopulateExcelRowsRejectsInvalidHeaderAndAcceptsMSISDNHeader(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
header []string
|
||||
valid bool
|
||||
}{
|
||||
{name: "invalid", header: []string{"乱码", "通道编号"}},
|
||||
{name: "msisdn", header: []string{"MSISDN", "通道编号"}, valid: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
workbook := excelize.NewFile()
|
||||
defer func() { _ = workbook.Close() }()
|
||||
for index, value := range test.header {
|
||||
cell, err := excelize.CoordinatesToCellName(index+1, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := workbook.SetCellValue("Sheet1", cell, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := workbook.SetCellValue("Sheet1", "A2", "14100007308"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := workbook.WriteToBuffer()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{Source: Source{ImportFileOssID: 1}}
|
||||
repository := &inboundRepositoryStub{readOssContent: content.Bytes(), readOssName: "cards.xlsx"}
|
||||
err = (&executor{repository: repository}).populateExcelRows(context.Background(), task)
|
||||
if !test.valid {
|
||||
if err == nil || len(task.Rows) != 0 {
|
||||
t.Fatalf("invalid header: err=%v, rows=%v", err, task.Rows)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || len(task.Rows) != 1 || task.Rows[0].MSISDN != "14100007308" {
|
||||
t.Fatalf("valid MSISDN file: err=%v, rows=%v", err, task.Rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInvalidRowSkipsUpstreamForEntireBatch(t *testing.T) {
|
||||
rows := []*Row{
|
||||
{RowNo: 2, ICCID: "invalid", ChanKey: "channel_003"},
|
||||
{RowNo: 3, ICCID: "89860401786527567308", ChanKey: "channel_003"},
|
||||
}
|
||||
upstream := &basicInfoUpstreamStub{}
|
||||
task := &Task{Rows: rows, Options: Options{ChanKey: "channel_003", ValidateUpstreamBasicInfo: "1"}}
|
||||
if err := (&executor{repository: &inboundRepositoryStub{}, upstream: upstream}).validate(context.Background(), task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 || task.ValidCount != 0 || !hasFailedRows(rows) {
|
||||
t.Fatalf("invalid batch should skip upstream: calls=%d valid=%d", upstream.calls, task.ValidCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSkipsUpstreamWhenLocalIdentifierFormatFails(t *testing.T) {
|
||||
rows := []*Row{{RowNo: 1, ICCID: "4", MSISDN: "4", IMSI: "4", ChanKey: "chan-a"}}
|
||||
upstream := &basicInfoUpstreamStub{}
|
||||
repo := &inboundRepositoryStub{}
|
||||
task := &Task{
|
||||
Rows: rows,
|
||||
Options: Options{ChanKey: "chan-a"},
|
||||
}
|
||||
|
||||
err := (&executor{repository: repo, upstream: upstream}).validate(context.Background(), task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("upstream calls = %d, want 0", upstream.calls)
|
||||
}
|
||||
if !containsReason(rows[0].Reason, "ICCID格式不正确") {
|
||||
t.Fatalf("expected local validation failure, got %q", rows[0].Reason)
|
||||
}
|
||||
if task.ValidCount != 0 {
|
||||
t.Fatalf("valid count = %d, want 0", task.ValidCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequiresCompleteIdentifiersAfterUpstreamStep(t *testing.T) {
|
||||
rows := []*Row{{RowNo: 1, MSISDN: "14100007308", ChanKey: "chan-a"}}
|
||||
repo := &inboundRepositoryStub{}
|
||||
task := &Task{
|
||||
Rows: rows,
|
||||
Source: Source{Range: Range{RangeType: "msisdn"}},
|
||||
}
|
||||
|
||||
err := (&executor{repository: repo}).validate(context.Background(), task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !containsReason(rows[0].Reason, "三码信息不完整") {
|
||||
t.Fatalf("expected incomplete identifiers failure, got %q", rows[0].Reason)
|
||||
}
|
||||
if task.ValidCount != 0 {
|
||||
t.Fatalf("valid count = %d, want 0", task.ValidCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNormalizesExcelInboundOptionsAndBackfillsBatchInfo(t *testing.T) {
|
||||
rows := parseTableRows([][]string{
|
||||
{"ICCID*", "MSISDN*", "IMSI*", "通道编号*", "卡类型", "上游套餐编码", "供应商编码", "是否贴片转插拔", "是否运营商达量断网"},
|
||||
{"89860401786527567308", "14100007308", "460046527567308", "channel_003", "MP1-消费级插拔物联卡", "UP001", "SUP001", "是", "否"},
|
||||
{"89860401786527567309", "14100007309", "460046527567309", "channel_003", "", "UP001", "SUP001", "", ""},
|
||||
})
|
||||
task := &Task{Rows: rows, Batch: Batch{SourceType: consts.BatchSourceTypeFile}}
|
||||
|
||||
err := (&executor{repository: &inboundRepositoryStub{}}).validate(context.Background(), task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if task.ValidCount != 2 {
|
||||
t.Fatalf("valid count = %d, want 2", task.ValidCount)
|
||||
}
|
||||
if task.Options.ChanKey != "channel_003" || task.Options.CardType != "4" ||
|
||||
task.Options.UpPkgCode != "UP001" || task.Options.SuppliersCode != "SUP001" {
|
||||
t.Fatalf("unexpected task options: %+v", task.Options)
|
||||
}
|
||||
if rows[1].CardType != "4" || rows[1].InsertToPlugEnabled != "0" || rows[1].CarrierTrafficCutoffEnabled != "1" {
|
||||
t.Fatalf("expected blank row options to be backfilled from batch, got %+v", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsMixedInboundOptionsInOneExcelBatch(t *testing.T) {
|
||||
rows := parseTableRows([][]string{
|
||||
{"ICCID*", "MSISDN*", "IMSI*", "通道编号*", "卡类型"},
|
||||
{"89860401786527567308", "14100007308", "460046527567308", "channel_003", "1"},
|
||||
{"89860401786527567309", "14100007309", "460046527567309", "channel_003", "2"},
|
||||
})
|
||||
task := &Task{Rows: rows, Batch: Batch{SourceType: consts.BatchSourceTypeFile}}
|
||||
|
||||
err := (&executor{repository: &inboundRepositoryStub{}}).validate(context.Background(), task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if task.ValidCount != 0 {
|
||||
t.Fatalf("valid count = %d, want 0", task.ValidCount)
|
||||
}
|
||||
if task.FailureReason != "同批入库配置必须一致" {
|
||||
t.Fatalf("unexpected task failure reason: %q", task.FailureReason)
|
||||
}
|
||||
if !containsReason(rows[0].Reason, "卡类型必须同批一致") || !containsReason(rows[1].Reason, "卡类型必须同批一致") {
|
||||
t.Fatalf("expected consistency errors, got row1=%q row2=%q", rows[0].Reason, rows[1].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func containsReason(actual, want string) bool {
|
||||
return strings.Contains(actual, want)
|
||||
}
|
||||
|
||||
type basicInfoUpstreamStub struct {
|
||||
calls int
|
||||
inputs []*model.SimBasicInfoInput
|
||||
response []model.SimBaseRes[model.SimBasicInfoItem]
|
||||
err error
|
||||
onQuery func()
|
||||
}
|
||||
|
||||
func (s *basicInfoUpstreamStub) QuerySimBasicInfo(_ context.Context, input *model.SimBasicInfoInput) ([]model.SimBaseRes[model.SimBasicInfoItem], error) {
|
||||
if s.onQuery != nil {
|
||||
s.onQuery()
|
||||
}
|
||||
s.calls++
|
||||
if input == nil || len(input.SimKeys) == 0 {
|
||||
return nil, errors.New("missing sim keys")
|
||||
}
|
||||
s.inputs = append(s.inputs, cloneBasicInfoInput(input))
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.response, nil
|
||||
}
|
||||
|
||||
func cloneBasicInfoInput(input *model.SimBasicInfoInput) *model.SimBasicInfoInput {
|
||||
clone := &model.SimBasicInfoInput{
|
||||
ReqData: input.ReqData,
|
||||
SimKeys: make([]*model.SimKey, 0, len(input.SimKeys)),
|
||||
}
|
||||
for _, item := range input.SimKeys {
|
||||
if item == nil {
|
||||
clone.SimKeys = append(clone.SimKeys, nil)
|
||||
continue
|
||||
}
|
||||
copied := *item
|
||||
clone.SimKeys = append(clone.SimKeys, &copied)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
type recordingRateLimiter struct {
|
||||
calls int
|
||||
chanKey string
|
||||
itemCount int
|
||||
err error
|
||||
onWait func(context.Context, string, int) error
|
||||
}
|
||||
|
||||
func (l *recordingRateLimiter) Wait(ctx context.Context, chanKey string, itemCount int) error {
|
||||
l.calls++
|
||||
l.chanKey = chanKey
|
||||
l.itemCount = itemCount
|
||||
if l.onWait != nil {
|
||||
return l.onWait(ctx, chanKey, itemCount)
|
||||
}
|
||||
return l.err
|
||||
}
|
||||
|
||||
type inboundRepositoryStub struct {
|
||||
readOssContent []byte
|
||||
readOssName string
|
||||
readOssErr error
|
||||
}
|
||||
|
||||
func (s *inboundRepositoryStub) Load(context.Context, string) (*Task, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (s *inboundRepositoryStub) ReadOssFile(context.Context, int64) ([]byte, string, error) {
|
||||
if s.readOssErr != nil {
|
||||
return nil, "", s.readOssErr
|
||||
}
|
||||
return s.readOssContent, s.readOssName, nil
|
||||
}
|
||||
|
||||
func (s *inboundRepositoryStub) FindExistingStocks(context.Context, string, []string, []string, []string) ([]Stock, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *inboundRepositoryStub) SaveValidationFailure(context.Context, *Task) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (s *inboundRepositoryStub) SaveSuccess(context.Context, *Task) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (s *inboundRepositoryStub) SaveBusinessFailure(context.Context, *Task, string) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (s *inboundRepositoryStub) SaveResultFile(context.Context, *Task, batchresult.Request) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
inboundUpstreamRateLimitConfigKey = "batch.inbound.upstreamRateLimit"
|
||||
|
||||
defaultInboundUpstreamRateLimitMode = "redis"
|
||||
defaultInboundUpstreamRateLimitRedisGroup = "default"
|
||||
defaultInboundUpstreamRateLimitKeyPrefix = "iotlcm:worker:inbound:upstream_basic_info"
|
||||
defaultInboundUpstreamQPS = 20
|
||||
defaultInboundUpstreamBurst = 1
|
||||
defaultInboundUpstreamRequestCost = 1
|
||||
defaultInboundChannelTempKeyCacheTTL = time.Minute
|
||||
|
||||
inboundRateLimitFallbackLocal = "local"
|
||||
inboundRateLimitFallbackError = "error"
|
||||
|
||||
inboundCarrierTelecom = "telecom"
|
||||
inboundCarrierMobile = "mobile"
|
||||
)
|
||||
|
||||
type upstreamBasicInfoRateLimiter interface {
|
||||
Wait(ctx context.Context, chanKey string, requestCount int) error
|
||||
}
|
||||
|
||||
type redisEvaler interface {
|
||||
Eval(ctx context.Context, script string, numKeys int64, keys []string, args []any) (*gvar.Var, error)
|
||||
}
|
||||
|
||||
type inboundUpstreamRateLimitConfig struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
RedisGroup string `json:"redisGroup"`
|
||||
KeyPrefix string `json:"keyPrefix"`
|
||||
RedisFailureFallback string `json:"redisFailureFallback"`
|
||||
QPS float64 `json:"qps"`
|
||||
Burst int `json:"burst"`
|
||||
RequestCost int `json:"requestCost"`
|
||||
Default inboundUpstreamRateLimitRule `json:"default"`
|
||||
Carriers map[string]inboundUpstreamRateLimitRule `json:"carriers"`
|
||||
Channels map[string]inboundUpstreamRateLimitRule `json:"channels"`
|
||||
}
|
||||
|
||||
type inboundUpstreamRateLimitRule struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
QPS float64 `json:"qps"`
|
||||
Burst int `json:"burst"`
|
||||
RequestCost int `json:"requestCost"`
|
||||
}
|
||||
|
||||
type inboundUpstreamRateLimitSetting struct {
|
||||
enabled bool
|
||||
mode string
|
||||
redisGroup string
|
||||
keyPrefix string
|
||||
redisFailureFallback string
|
||||
qps float64
|
||||
burst int
|
||||
requestCost int
|
||||
carrier string
|
||||
tempKey string
|
||||
skipReason string
|
||||
}
|
||||
|
||||
type inboundChannelMetadata struct {
|
||||
TempKey string `orm:"temp_key"`
|
||||
Operator int `orm:"operator"`
|
||||
}
|
||||
|
||||
type inboundChannelTempKeyCacheEntry struct {
|
||||
metadata inboundChannelMetadata
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type configUpstreamBasicInfoRateLimiter struct {
|
||||
redis func(string) redisEvaler
|
||||
channelMetadata func(context.Context, string) (inboundChannelMetadata, error)
|
||||
channelTempKey func(context.Context, string) (string, error)
|
||||
local *localTokenBucketLimiter
|
||||
channelCache sync.Map
|
||||
redisLogTimes sync.Map
|
||||
skipLogTimes sync.Map
|
||||
}
|
||||
|
||||
func newUpstreamBasicInfoRateLimiter() *configUpstreamBasicInfoRateLimiter {
|
||||
return &configUpstreamBasicInfoRateLimiter{
|
||||
redis: func(name string) redisEvaler {
|
||||
return g.Redis(name)
|
||||
},
|
||||
channelMetadata: queryInboundChannelMetadata,
|
||||
local: newLocalTokenBucketLimiter(),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *configUpstreamBasicInfoRateLimiter) Wait(ctx context.Context, chanKey string, requestCount int) error {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
cfg := loadInboundUpstreamRateLimitConfig(ctx)
|
||||
if !inboundUpstreamRateLimitGloballyEnabled(cfg) {
|
||||
return nil
|
||||
}
|
||||
metadata, err := l.lookupChannelMetadata(ctx, chanKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setting := resolveInboundUpstreamRateLimitFromChannelMetadata(cfg, chanKey, metadata.Operator, metadata.TempKey)
|
||||
if !setting.enabled || setting.qps <= 0 || setting.mode == "off" {
|
||||
if setting.skipReason != "" {
|
||||
l.logSkipped(ctx, chanKey, setting)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if requestCount <= 0 {
|
||||
requestCount = 1
|
||||
}
|
||||
permits := requestCount * setting.requestCost
|
||||
key := inboundUpstreamRateLimitKey(setting.keyPrefix, chanKey, setting.carrier)
|
||||
for i := 0; i < permits; i++ {
|
||||
if err := l.waitOne(ctx, setting, key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *configUpstreamBasicInfoRateLimiter) waitOne(ctx context.Context, setting inboundUpstreamRateLimitSetting, key string) error {
|
||||
if setting.mode == "local" {
|
||||
if l.local == nil {
|
||||
return fmt.Errorf("local rate limiter is nil")
|
||||
}
|
||||
return l.local.Wait(ctx, key, setting.qps, setting.burst)
|
||||
}
|
||||
if l.redis == nil {
|
||||
return l.handleRedisError(ctx, setting, key, fmt.Errorf("redis client resolver is nil"))
|
||||
}
|
||||
redis := l.redis(setting.redisGroup)
|
||||
if redis == nil {
|
||||
return l.handleRedisError(ctx, setting, key, fmt.Errorf("redis client is nil"))
|
||||
}
|
||||
if err := (&redisTokenBucketLimiter{redis: redis}).Wait(ctx, key, setting.qps, setting.burst); err != nil {
|
||||
return l.handleRedisError(ctx, setting, key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *configUpstreamBasicInfoRateLimiter) handleRedisError(ctx context.Context, setting inboundUpstreamRateLimitSetting, key string, err error) error {
|
||||
if setting.redisFailureFallback == inboundRateLimitFallbackError {
|
||||
return err
|
||||
}
|
||||
if l.local == nil {
|
||||
return err
|
||||
}
|
||||
l.logRedisFallback(ctx, key, err)
|
||||
return l.local.Wait(ctx, key, setting.qps, setting.burst)
|
||||
}
|
||||
|
||||
func (l *configUpstreamBasicInfoRateLimiter) logRedisFallback(ctx context.Context, key string, err error) {
|
||||
now := time.Now()
|
||||
if value, ok := l.redisLogTimes.Load(key); ok {
|
||||
if last, ok := value.(time.Time); ok && now.Sub(last) < 10*time.Second {
|
||||
return
|
||||
}
|
||||
}
|
||||
l.redisLogTimes.Store(key, now)
|
||||
g.Log().Errorf(ctx, "inbound upstream basic-info redis rate limiter failed, fallback local, key:%s, err:%+v", key, err)
|
||||
}
|
||||
|
||||
func (l *configUpstreamBasicInfoRateLimiter) lookupChannelMetadata(ctx context.Context, chanKey string) (inboundChannelMetadata, error) {
|
||||
chanKey = strings.TrimSpace(chanKey)
|
||||
if chanKey == "" {
|
||||
return inboundChannelMetadata{}, nil
|
||||
}
|
||||
now := time.Now()
|
||||
if value, ok := l.channelCache.Load(chanKey); ok {
|
||||
if entry, ok := value.(inboundChannelTempKeyCacheEntry); ok && now.Before(entry.expiresAt) {
|
||||
return entry.metadata, nil
|
||||
}
|
||||
}
|
||||
if l.channelMetadata != nil {
|
||||
metadata, err := l.channelMetadata(ctx, chanKey)
|
||||
if err != nil {
|
||||
return inboundChannelMetadata{}, fmt.Errorf("query channel metadata failed, chanKey:%s: %w", chanKey, err)
|
||||
}
|
||||
metadata.TempKey = strings.TrimSpace(metadata.TempKey)
|
||||
l.channelCache.Store(chanKey, inboundChannelTempKeyCacheEntry{
|
||||
metadata: metadata,
|
||||
expiresAt: now.Add(defaultInboundChannelTempKeyCacheTTL),
|
||||
})
|
||||
return metadata, nil
|
||||
}
|
||||
if l.channelTempKey == nil {
|
||||
return inboundChannelMetadata{}, fmt.Errorf("channel metadata resolver is nil")
|
||||
}
|
||||
tempKey, err := l.channelTempKey(ctx, chanKey)
|
||||
if err != nil {
|
||||
return inboundChannelMetadata{}, fmt.Errorf("query channel template failed, chanKey:%s: %w", chanKey, err)
|
||||
}
|
||||
metadata := inboundChannelMetadata{TempKey: strings.TrimSpace(tempKey)}
|
||||
l.channelCache.Store(chanKey, inboundChannelTempKeyCacheEntry{
|
||||
metadata: metadata,
|
||||
expiresAt: now.Add(defaultInboundChannelTempKeyCacheTTL),
|
||||
})
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func (l *configUpstreamBasicInfoRateLimiter) logSkipped(ctx context.Context, chanKey string, setting inboundUpstreamRateLimitSetting) {
|
||||
key := strings.TrimSpace(chanKey) + ":" + setting.skipReason
|
||||
now := time.Now()
|
||||
if value, ok := l.skipLogTimes.Load(key); ok {
|
||||
if last, ok := value.(time.Time); ok && now.Sub(last) < 10*time.Second {
|
||||
return
|
||||
}
|
||||
}
|
||||
l.skipLogTimes.Store(key, now)
|
||||
g.Log().Warningf(ctx, "inbound upstream basic-info rate limit skipped, chanKey:%s, tempKey:%s, reason:%s",
|
||||
chanKey, setting.tempKey, setting.skipReason)
|
||||
}
|
||||
|
||||
func queryInboundChannelMetadata(ctx context.Context, chanKey string) (inboundChannelMetadata, error) {
|
||||
var row inboundChannelMetadata
|
||||
if err := g.DB().Model("t_sim_channels").Ctx(ctx).
|
||||
Fields("temp_key,operator").
|
||||
Where("chan_key", strings.TrimSpace(chanKey)).
|
||||
Limit(1).
|
||||
Scan(&row); err != nil {
|
||||
return inboundChannelMetadata{}, err
|
||||
}
|
||||
row.TempKey = strings.TrimSpace(row.TempKey)
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func queryInboundChannelTempKey(ctx context.Context, chanKey string) (string, error) {
|
||||
metadata, err := queryInboundChannelMetadata(ctx, chanKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return metadata.TempKey, nil
|
||||
}
|
||||
|
||||
func resolveInboundUpstreamRateLimit(ctx context.Context, chanKey string) inboundUpstreamRateLimitSetting {
|
||||
return resolveInboundUpstreamRateLimitWithTempKey(ctx, chanKey, "")
|
||||
}
|
||||
|
||||
func resolveInboundUpstreamRateLimitWithTempKey(ctx context.Context, chanKey, tempKey string) inboundUpstreamRateLimitSetting {
|
||||
cfg := loadInboundUpstreamRateLimitConfig(ctx)
|
||||
return resolveInboundUpstreamRateLimitFromConfig(cfg, chanKey, tempKey)
|
||||
}
|
||||
|
||||
func resolveInboundUpstreamRateLimitWithChannelMetadata(ctx context.Context, chanKey string, operator int, tempKey string) inboundUpstreamRateLimitSetting {
|
||||
cfg := loadInboundUpstreamRateLimitConfig(ctx)
|
||||
return resolveInboundUpstreamRateLimitFromChannelMetadata(cfg, chanKey, operator, tempKey)
|
||||
}
|
||||
|
||||
func loadInboundUpstreamRateLimitConfig(ctx context.Context) inboundUpstreamRateLimitConfig {
|
||||
cfg := defaultInboundUpstreamRateLimitConfig()
|
||||
value, err := g.Cfg().Get(ctx, inboundUpstreamRateLimitConfigKey)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "load inbound upstream rate limit config failed: %+v", err)
|
||||
return normalizeInboundUpstreamRateLimitConfig(cfg)
|
||||
}
|
||||
if value != nil && !value.IsNil() {
|
||||
if err = value.Scan(&cfg); err != nil {
|
||||
g.Log().Warningf(ctx, "parse inbound upstream rate limit config failed: %+v", err)
|
||||
cfg = defaultInboundUpstreamRateLimitConfig()
|
||||
}
|
||||
}
|
||||
return normalizeInboundUpstreamRateLimitConfig(cfg)
|
||||
}
|
||||
|
||||
func defaultInboundUpstreamRateLimitConfig() inboundUpstreamRateLimitConfig {
|
||||
return inboundUpstreamRateLimitConfig{
|
||||
Mode: defaultInboundUpstreamRateLimitMode,
|
||||
RedisGroup: defaultInboundUpstreamRateLimitRedisGroup,
|
||||
KeyPrefix: defaultInboundUpstreamRateLimitKeyPrefix,
|
||||
RedisFailureFallback: inboundRateLimitFallbackError,
|
||||
Default: inboundUpstreamRateLimitRule{
|
||||
Enabled: inboundBool(false),
|
||||
},
|
||||
Carriers: map[string]inboundUpstreamRateLimitRule{
|
||||
inboundCarrierTelecom: {
|
||||
Enabled: inboundBool(true),
|
||||
QPS: defaultInboundUpstreamQPS,
|
||||
Burst: defaultInboundUpstreamBurst,
|
||||
RequestCost: defaultInboundUpstreamRequestCost,
|
||||
},
|
||||
inboundCarrierMobile: {
|
||||
Enabled: inboundBool(false),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeInboundUpstreamRateLimitConfig(cfg inboundUpstreamRateLimitConfig) inboundUpstreamRateLimitConfig {
|
||||
cfg.Mode = normalizeInboundRateLimitMode(cfg.Mode)
|
||||
cfg.RedisGroup = firstNonEmpty(strings.TrimSpace(cfg.RedisGroup), defaultInboundUpstreamRateLimitRedisGroup)
|
||||
cfg.KeyPrefix = firstNonEmpty(strings.TrimSpace(cfg.KeyPrefix), defaultInboundUpstreamRateLimitKeyPrefix)
|
||||
cfg.RedisFailureFallback = normalizeInboundRateLimitFallback(cfg.RedisFailureFallback)
|
||||
if cfg.Default.Enabled == nil {
|
||||
cfg.Default.Enabled = inboundBool(false)
|
||||
}
|
||||
if len(cfg.Carriers) == 0 {
|
||||
cfg.Carriers = make(map[string]inboundUpstreamRateLimitRule, 2)
|
||||
} else {
|
||||
normalized := make(map[string]inboundUpstreamRateLimitRule, len(cfg.Carriers))
|
||||
for key, rule := range cfg.Carriers {
|
||||
normalized[normalizeInboundCarrier(key)] = rule
|
||||
}
|
||||
cfg.Carriers = normalized
|
||||
}
|
||||
telecomRule, ok := cfg.Carriers[inboundCarrierTelecom]
|
||||
if !ok {
|
||||
telecomRule = inboundUpstreamRateLimitRule{}
|
||||
}
|
||||
if telecomRule.Enabled == nil {
|
||||
telecomRule.Enabled = inboundBool(true)
|
||||
}
|
||||
if telecomRule.QPS <= 0 {
|
||||
telecomRule.QPS = defaultInboundUpstreamQPS
|
||||
}
|
||||
if telecomRule.Burst <= 0 {
|
||||
telecomRule.Burst = defaultInboundUpstreamBurst
|
||||
}
|
||||
if telecomRule.RequestCost <= 0 {
|
||||
telecomRule.RequestCost = defaultInboundUpstreamRequestCost
|
||||
}
|
||||
cfg.Carriers[inboundCarrierTelecom] = telecomRule
|
||||
|
||||
mobileRule, ok := cfg.Carriers[inboundCarrierMobile]
|
||||
if !ok {
|
||||
mobileRule = inboundUpstreamRateLimitRule{}
|
||||
}
|
||||
if mobileRule.Enabled == nil {
|
||||
mobileRule.Enabled = inboundBool(false)
|
||||
}
|
||||
cfg.Carriers[inboundCarrierMobile] = mobileRule
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func resolveInboundUpstreamRateLimitFromConfig(cfg inboundUpstreamRateLimitConfig, chanKey, tempKey string) inboundUpstreamRateLimitSetting {
|
||||
return resolveInboundUpstreamRateLimitFromChannelMetadata(cfg, chanKey, 0, tempKey)
|
||||
}
|
||||
|
||||
func resolveInboundUpstreamRateLimitFromChannelMetadata(cfg inboundUpstreamRateLimitConfig, chanKey string, operator int, tempKey string) inboundUpstreamRateLimitSetting {
|
||||
cfg = normalizeInboundUpstreamRateLimitConfig(cfg)
|
||||
setting := inboundUpstreamRateLimitSetting{
|
||||
enabled: true,
|
||||
mode: cfg.Mode,
|
||||
redisGroup: cfg.RedisGroup,
|
||||
keyPrefix: cfg.KeyPrefix,
|
||||
redisFailureFallback: cfg.RedisFailureFallback,
|
||||
qps: defaultInboundUpstreamQPS,
|
||||
burst: defaultInboundUpstreamBurst,
|
||||
requestCost: defaultInboundUpstreamRequestCost,
|
||||
tempKey: strings.TrimSpace(tempKey),
|
||||
}
|
||||
if cfg.Enabled != nil {
|
||||
setting.enabled = *cfg.Enabled
|
||||
}
|
||||
applyInboundRateLimitRule(&setting, inboundUpstreamRateLimitRule{
|
||||
QPS: cfg.QPS,
|
||||
Burst: cfg.Burst,
|
||||
RequestCost: cfg.RequestCost,
|
||||
})
|
||||
applyInboundRateLimitRule(&setting, cfg.Default)
|
||||
carrier, known := inboundCarrierForOperator(operator)
|
||||
if model.IsTelecomTemplate(tempKey) {
|
||||
carrier, known = inboundCarrierTelecom, true
|
||||
}
|
||||
if known {
|
||||
setting.carrier = carrier
|
||||
if rule, ok := inboundCarrierRateLimitRule(cfg.Carriers, carrier); ok {
|
||||
applyInboundRateLimitRule(&setting, rule)
|
||||
}
|
||||
} else if operator == 0 {
|
||||
setting.skipReason = "missing_channel_operator"
|
||||
} else {
|
||||
setting.skipReason = "unsupported_channel_operator"
|
||||
}
|
||||
if rule, ok := inboundChannelRateLimitRule(cfg.Channels, chanKey); ok {
|
||||
applyInboundRateLimitRule(&setting, rule)
|
||||
}
|
||||
return normalizeInboundRateLimitSetting(setting)
|
||||
}
|
||||
|
||||
func inboundUpstreamRateLimitGloballyEnabled(cfg inboundUpstreamRateLimitConfig) bool {
|
||||
cfg = normalizeInboundUpstreamRateLimitConfig(cfg)
|
||||
if cfg.Mode == "off" {
|
||||
return false
|
||||
}
|
||||
if cfg.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
return *cfg.Enabled
|
||||
}
|
||||
|
||||
func applyInboundRateLimitRule(setting *inboundUpstreamRateLimitSetting, rule inboundUpstreamRateLimitRule) {
|
||||
if setting == nil {
|
||||
return
|
||||
}
|
||||
if rule.Enabled != nil {
|
||||
setting.enabled = *rule.Enabled
|
||||
}
|
||||
if rule.QPS > 0 {
|
||||
setting.qps = rule.QPS
|
||||
}
|
||||
if rule.Burst > 0 {
|
||||
setting.burst = rule.Burst
|
||||
}
|
||||
if rule.RequestCost > 0 {
|
||||
setting.requestCost = rule.RequestCost
|
||||
}
|
||||
}
|
||||
|
||||
func inboundChannelRateLimitRule(configs map[string]inboundUpstreamRateLimitRule, chanKey string) (inboundUpstreamRateLimitRule, bool) {
|
||||
chanKey = strings.TrimSpace(chanKey)
|
||||
if chanKey == "" || len(configs) == 0 {
|
||||
return inboundUpstreamRateLimitRule{}, false
|
||||
}
|
||||
if cfg, ok := configs[chanKey]; ok {
|
||||
return cfg, true
|
||||
}
|
||||
lower := strings.ToLower(chanKey)
|
||||
for key, cfg := range configs {
|
||||
if strings.ToLower(strings.TrimSpace(key)) == lower {
|
||||
return cfg, true
|
||||
}
|
||||
}
|
||||
return inboundUpstreamRateLimitRule{}, false
|
||||
}
|
||||
|
||||
func inboundCarrierRateLimitRule(configs map[string]inboundUpstreamRateLimitRule, carrier string) (inboundUpstreamRateLimitRule, bool) {
|
||||
carrier = normalizeInboundCarrier(carrier)
|
||||
if carrier == "" || len(configs) == 0 {
|
||||
return inboundUpstreamRateLimitRule{}, false
|
||||
}
|
||||
if cfg, ok := configs[carrier]; ok {
|
||||
return cfg, true
|
||||
}
|
||||
for key, cfg := range configs {
|
||||
if normalizeInboundCarrier(key) == carrier {
|
||||
return cfg, true
|
||||
}
|
||||
}
|
||||
return inboundUpstreamRateLimitRule{}, false
|
||||
}
|
||||
|
||||
func inboundCarrierForOperator(operator int) (string, bool) {
|
||||
switch operator {
|
||||
case 1:
|
||||
return inboundCarrierMobile, true
|
||||
case 2:
|
||||
return inboundCarrierTelecom, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeInboundCarrier(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "unknown":
|
||||
return ""
|
||||
case inboundCarrierTelecom, "dx", "ctcc", "china_telecom", "chinatelecom":
|
||||
return inboundCarrierTelecom
|
||||
case inboundCarrierMobile, "yd", "cmcc", "china_mobile", "chinamobile":
|
||||
return inboundCarrierMobile
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeInboundRateLimitSetting(setting inboundUpstreamRateLimitSetting) inboundUpstreamRateLimitSetting {
|
||||
if setting.qps <= 0 || math.IsNaN(setting.qps) || math.IsInf(setting.qps, 0) {
|
||||
setting.qps = defaultInboundUpstreamQPS
|
||||
}
|
||||
if setting.burst <= 0 {
|
||||
setting.burst = defaultInboundUpstreamBurst
|
||||
}
|
||||
if setting.requestCost <= 0 {
|
||||
setting.requestCost = defaultInboundUpstreamRequestCost
|
||||
}
|
||||
return setting
|
||||
}
|
||||
|
||||
func normalizeInboundRateLimitMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "", "redis", "distributed":
|
||||
return defaultInboundUpstreamRateLimitMode
|
||||
case "local", "memory":
|
||||
return "local"
|
||||
case "off", "disable", "disabled", "none":
|
||||
return "off"
|
||||
default:
|
||||
return defaultInboundUpstreamRateLimitMode
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeInboundRateLimitFallback(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", inboundRateLimitFallbackError:
|
||||
return inboundRateLimitFallbackError
|
||||
case inboundRateLimitFallbackLocal:
|
||||
return inboundRateLimitFallbackLocal
|
||||
default:
|
||||
return inboundRateLimitFallbackError
|
||||
}
|
||||
}
|
||||
|
||||
func inboundUpstreamRateLimitKey(prefix, chanKey, carrier string) string {
|
||||
prefix = strings.TrimRight(strings.TrimSpace(prefix), ":")
|
||||
if prefix == "" {
|
||||
prefix = defaultInboundUpstreamRateLimitKeyPrefix
|
||||
}
|
||||
chanKey = strings.TrimLeft(strings.TrimSpace(chanKey), ":")
|
||||
carrier = strings.TrimLeft(normalizeInboundCarrier(carrier), ":")
|
||||
if carrier != "" {
|
||||
return prefix + ":carrier:" + carrier + ":chan:" + chanKey
|
||||
}
|
||||
return prefix + ":chan:" + chanKey
|
||||
}
|
||||
|
||||
func inboundBool(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
type localTokenBucketLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*tokenBucket
|
||||
}
|
||||
|
||||
func newLocalTokenBucketLimiter() *localTokenBucketLimiter {
|
||||
return &localTokenBucketLimiter{buckets: make(map[string]*tokenBucket)}
|
||||
}
|
||||
|
||||
func (l *localTokenBucketLimiter) Wait(ctx context.Context, key string, qps float64, burst int) error {
|
||||
if qps <= 0 {
|
||||
return nil
|
||||
}
|
||||
bucket := l.bucket(key)
|
||||
delay := bucket.reserve(time.Now(), qps, burst)
|
||||
if delay <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (l *localTokenBucketLimiter) bucket(key string) *tokenBucket {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
bucket := l.buckets[key]
|
||||
if bucket == nil {
|
||||
bucket = &tokenBucket{}
|
||||
l.buckets[key] = bucket
|
||||
}
|
||||
return bucket
|
||||
}
|
||||
|
||||
type tokenBucket struct {
|
||||
mu sync.Mutex
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func (b *tokenBucket) reserve(now time.Time, qps float64, burst int) time.Duration {
|
||||
if qps <= 0 {
|
||||
return 0
|
||||
}
|
||||
capacity := float64(burst)
|
||||
if capacity <= 0 {
|
||||
capacity = defaultInboundUpstreamBurst
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.last.IsZero() {
|
||||
b.last = now
|
||||
b.tokens = capacity
|
||||
}
|
||||
if now.After(b.last) {
|
||||
elapsed := now.Sub(b.last).Seconds()
|
||||
b.tokens += elapsed * qps
|
||||
if b.tokens > capacity {
|
||||
b.tokens = capacity
|
||||
}
|
||||
b.last = now
|
||||
}
|
||||
if b.tokens >= 1 {
|
||||
b.tokens--
|
||||
return 0
|
||||
}
|
||||
waitSeconds := (1 - b.tokens) / qps
|
||||
wait := time.Duration(waitSeconds * float64(time.Second))
|
||||
if wait <= 0 {
|
||||
wait = time.Nanosecond
|
||||
}
|
||||
b.tokens = 0
|
||||
b.last = b.last.Add(wait)
|
||||
return b.last.Sub(now)
|
||||
}
|
||||
|
||||
type redisTokenBucketLimiter struct {
|
||||
redis redisEvaler
|
||||
}
|
||||
|
||||
const redisTokenBucketScript = `
|
||||
local now = tonumber(ARGV[1])
|
||||
local qps = tonumber(ARGV[2])
|
||||
local burst = tonumber(ARGV[3])
|
||||
local ttl = tonumber(ARGV[4])
|
||||
|
||||
if qps == nil or qps <= 0 then
|
||||
return {1, 0}
|
||||
end
|
||||
if burst == nil or burst <= 0 then
|
||||
burst = 1
|
||||
end
|
||||
if ttl == nil or ttl <= 0 then
|
||||
ttl = 60000
|
||||
end
|
||||
|
||||
local values = redis.call("HMGET", KEYS[1], "tokens", "updated_at")
|
||||
local tokens = tonumber(values[1])
|
||||
local updated_at = tonumber(values[2])
|
||||
local capacity = burst
|
||||
|
||||
if tokens == nil or updated_at == nil then
|
||||
tokens = capacity
|
||||
updated_at = now
|
||||
end
|
||||
if tokens > capacity then
|
||||
tokens = capacity
|
||||
end
|
||||
if now > updated_at then
|
||||
local elapsed = (now - updated_at) / 1000
|
||||
tokens = math.min(capacity, tokens + elapsed * qps)
|
||||
updated_at = now
|
||||
end
|
||||
|
||||
local wait = 0
|
||||
if tokens >= 1 then
|
||||
tokens = tokens - 1
|
||||
else
|
||||
wait = math.ceil((1 - tokens) / qps * 1000)
|
||||
if wait < 1 then
|
||||
wait = 1
|
||||
end
|
||||
tokens = 0
|
||||
updated_at = updated_at + wait
|
||||
wait = updated_at - now
|
||||
if wait < 0 then
|
||||
wait = 0
|
||||
end
|
||||
end
|
||||
|
||||
redis.call("HSET", KEYS[1], "tokens", tostring(tokens), "updated_at", tostring(updated_at))
|
||||
redis.call("PEXPIRE", KEYS[1], ttl)
|
||||
return {1, wait}
|
||||
`
|
||||
|
||||
func (l *redisTokenBucketLimiter) Wait(ctx context.Context, key string, qps float64, burst int) error {
|
||||
wait, err := l.reserve(ctx, key, qps, burst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wait <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (l *redisTokenBucketLimiter) reserve(ctx context.Context, key string, qps float64, burst int) (time.Duration, error) {
|
||||
if l == nil || l.redis == nil || qps <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if burst <= 0 {
|
||||
burst = defaultInboundUpstreamBurst
|
||||
}
|
||||
reply, err := l.redis.Eval(ctx, redisTokenBucketScript, 1, []string{key}, []any{
|
||||
time.Now().UnixMilli(),
|
||||
strconv.FormatFloat(qps, 'f', -1, 64),
|
||||
burst,
|
||||
rateLimitRedisKeyTTLMillis(qps, burst),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
values := reply.Int64s()
|
||||
if len(values) < 2 || values[0] != 1 {
|
||||
return 0, fmt.Errorf("unexpected redis rate limiter reply: %v", reply.Val())
|
||||
}
|
||||
waitMillis := values[1]
|
||||
if waitMillis <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return time.Duration(waitMillis) * time.Millisecond, nil
|
||||
}
|
||||
|
||||
func rateLimitRedisKeyTTLMillis(qps float64, burst int) int64 {
|
||||
if qps <= 0 {
|
||||
return int64(time.Minute / time.Millisecond)
|
||||
}
|
||||
if burst <= 0 {
|
||||
burst = defaultInboundUpstreamBurst
|
||||
}
|
||||
idleMillis := int64(math.Ceil(float64(burst) / qps * 1000 * 2))
|
||||
minMillis := int64(time.Minute / time.Millisecond)
|
||||
if idleMillis < minMillis {
|
||||
return minMillis
|
||||
}
|
||||
return idleMillis
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package inbound
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInboundCarrierForOperatorUsesDictionaryValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
operator int
|
||||
carrier string
|
||||
ok bool
|
||||
}{
|
||||
{name: "mobile", operator: 1, carrier: inboundCarrierMobile, ok: true},
|
||||
{name: "telecom", operator: 2, carrier: inboundCarrierTelecom, ok: true},
|
||||
{name: "legacy telecom value is unsupported", operator: 3, carrier: "", ok: false},
|
||||
{name: "missing", operator: 0, carrier: "", ok: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
carrier, ok := inboundCarrierForOperator(test.operator)
|
||||
if carrier != test.carrier || ok != test.ok {
|
||||
t.Fatalf("inboundCarrierForOperator(%d) = %q,%v, want %q,%v", test.operator, carrier, ok, test.carrier, test.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInboundUpstreamRateLimitFromChannelMetadataUsesCarrierPolicy(t *testing.T) {
|
||||
cfg := defaultInboundUpstreamRateLimitConfig()
|
||||
|
||||
mobile := resolveInboundUpstreamRateLimitFromChannelMetadata(cfg, "chan-mobile", 1, "sim_yd_v5")
|
||||
if mobile.carrier != inboundCarrierMobile {
|
||||
t.Fatalf("mobile carrier = %q, want %q", mobile.carrier, inboundCarrierMobile)
|
||||
}
|
||||
if mobile.enabled {
|
||||
t.Fatalf("mobile enabled = %v, want false", mobile.enabled)
|
||||
}
|
||||
|
||||
telecom := resolveInboundUpstreamRateLimitFromChannelMetadata(cfg, "chan-telecom", 2, "sim_dx_v1")
|
||||
if telecom.carrier != inboundCarrierTelecom {
|
||||
t.Fatalf("telecom carrier = %q, want %q", telecom.carrier, inboundCarrierTelecom)
|
||||
}
|
||||
if !telecom.enabled {
|
||||
t.Fatalf("telecom enabled = %v, want true", telecom.enabled)
|
||||
}
|
||||
if telecom.qps != defaultInboundUpstreamQPS {
|
||||
t.Fatalf("telecom qps = %v, want %v", telecom.qps, defaultInboundUpstreamQPS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelecomTemplateOverridesInconsistentOperatorMetadata(t *testing.T) {
|
||||
for _, key := range []string{"sim_dx_v1", "sim_dx_v3"} {
|
||||
for _, operator := range []int{0, 1, 2, 3} {
|
||||
setting := resolveInboundUpstreamRateLimitFromChannelMetadata(defaultInboundUpstreamRateLimitConfig(), "telecom", operator, key)
|
||||
if setting.carrier != inboundCarrierTelecom || !setting.enabled || setting.skipReason != "" {
|
||||
t.Fatalf("template %q operator %d: %+v", key, operator, setting)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Load(ctx context.Context, batchNo string) (*Task, error)
|
||||
ReadOssFile(ctx context.Context, ossID int64) ([]byte, string, error)
|
||||
FindExistingStocks(ctx context.Context, tenantID string, iccids, msisdns, imsis []string) ([]Stock, error)
|
||||
SaveValidationFailure(ctx context.Context, task *Task) error
|
||||
SaveSuccess(ctx context.Context, task *Task) error
|
||||
SaveBusinessFailure(ctx context.Context, task *Task, reason string) error
|
||||
SaveResultFile(ctx context.Context, task *Task, request batchresult.Request) error
|
||||
}
|
||||
|
||||
type Upstream interface {
|
||||
QuerySimBasicInfo(ctx context.Context, input *model.SimBasicInfoInput) ([]model.SimBaseRes[model.SimBasicInfoItem], error)
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Batch Batch
|
||||
Source Source
|
||||
Options Options
|
||||
Rows []*Row
|
||||
ValidCount int
|
||||
FailureReason string
|
||||
}
|
||||
|
||||
type Batch struct {
|
||||
ID int64
|
||||
BatchNo string
|
||||
BatchType int
|
||||
SourceType int
|
||||
TenantID string
|
||||
CreatedDept int64
|
||||
CreatedBy int64
|
||||
StartTime *gtime.Time
|
||||
BatchInfo *gjson.Json
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ImportFileOssID int64
|
||||
InputText string
|
||||
InputFileOssID int64
|
||||
Range Range
|
||||
}
|
||||
|
||||
type Range struct {
|
||||
RangeType string
|
||||
StartICCID string
|
||||
EndICCID string
|
||||
StartMSISDN string
|
||||
EndMSISDN string
|
||||
StartIMSI string
|
||||
EndIMSI string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
ChanKey string
|
||||
ValidateUpstreamBasicInfo string
|
||||
CardType string
|
||||
UpPkgCode string
|
||||
SuppliersCode string
|
||||
UpstreamActivationTime string
|
||||
InsertToPlugEnabled string
|
||||
CarrierTrafficCutoffEnabled string
|
||||
AgreementExpiryDate string
|
||||
HasTestPeriod string
|
||||
TestPeriodDays string
|
||||
TestPeriodLatestExpiryDate string
|
||||
HasSilentPeriod string
|
||||
SilentPeriodDays string
|
||||
SilentPeriodLatestExpiryDate string
|
||||
}
|
||||
|
||||
type Row struct {
|
||||
RowNo int
|
||||
InputCode string
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
IMEI string
|
||||
ChanKey string
|
||||
CardType string
|
||||
UpPkgCode string
|
||||
SuppliersCode string
|
||||
UpstreamActivationTime string
|
||||
InsertToPlugEnabled string
|
||||
CarrierTrafficCutoffEnabled string
|
||||
AgreementExpiryDate string
|
||||
HasTestPeriod string
|
||||
TestPeriodDays string
|
||||
TestPeriodLatestExpiryDate string
|
||||
HasSilentPeriod string
|
||||
SilentPeriodDays string
|
||||
SilentPeriodLatestExpiryDate string
|
||||
Reason string
|
||||
Raw map[string]string
|
||||
}
|
||||
|
||||
type Stock struct {
|
||||
ICCID string `orm:"iccid"`
|
||||
MSISDN string `orm:"msisdn"`
|
||||
IMSI string `orm:"imsi"`
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
type executor struct {
|
||||
repository Repository
|
||||
upstream Upstream
|
||||
}
|
||||
|
||||
func NewExecutor(repository Repository, upstream Upstream) Executor {
|
||||
return &executor{repository: repository, upstream: upstream}
|
||||
}
|
||||
|
||||
func (e *executor) Execute(ctx context.Context, batchNo string) error {
|
||||
task, err := e.repository.Load(ctx, strings.TrimSpace(batchNo))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load lifecycle batch")
|
||||
}
|
||||
if !validateTask(task) {
|
||||
if err = e.repository.SaveValidationFailure(ctx, task); err != nil {
|
||||
return gerror.Wrap(err, "save lifecycle validation failure")
|
||||
}
|
||||
return gerror.Wrap(e.repository.Finalize(ctx, task), "finalize lifecycle validation failure")
|
||||
}
|
||||
|
||||
groups := groupByChanKey(task.Details)
|
||||
keys := make([]string, 0, len(groups))
|
||||
for chanKey := range groups {
|
||||
keys = append(keys, chanKey)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, chanKey := range keys {
|
||||
for index, outcome := range e.changeGroup(ctx, task, groups[chanKey]) {
|
||||
detail := groups[chanKey][index]
|
||||
resolved, saveErr := e.repository.SaveResult(ctx, task, detail, outcome)
|
||||
if saveErr != nil {
|
||||
return gerror.Wrapf(saveErr, "save lifecycle detail %d result", detail.ID)
|
||||
}
|
||||
detail.ApplyOutcome(resolved)
|
||||
}
|
||||
}
|
||||
return gerror.Wrap(e.repository.Finalize(ctx, task), "finalize lifecycle batch")
|
||||
}
|
||||
|
||||
func validateTask(task *Task) bool {
|
||||
if task == nil || len(task.Details) == 0 {
|
||||
return false
|
||||
}
|
||||
hasFailure := false
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
hasFailure = true
|
||||
continue
|
||||
}
|
||||
validateDetail(task.Operation, detail)
|
||||
if detail.Reason != "" {
|
||||
hasFailure = true
|
||||
}
|
||||
}
|
||||
if !hasFailure {
|
||||
task.ValidationOK = true
|
||||
return true
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && detail.Reason == "" {
|
||||
detail.Reason = "同批次存在校验失败卡,本卡未请求上游"
|
||||
}
|
||||
}
|
||||
task.FailureReason = "批次校验未通过,未发起上游请求"
|
||||
return false
|
||||
}
|
||||
|
||||
func validateDetail(operation Operation, detail *Detail) {
|
||||
if detail.Reason != "" {
|
||||
return
|
||||
}
|
||||
if detail.Card == nil {
|
||||
detail.Reason = "ICCID不存在或不属于当前租户"
|
||||
return
|
||||
}
|
||||
detail.MSISDN = strings.TrimSpace(detail.Card.MSISDN)
|
||||
detail.IMSI = strings.TrimSpace(detail.Card.IMSI)
|
||||
detail.ChanKey = strings.TrimSpace(detail.Card.ChanKey)
|
||||
detail.StatusBefore = detail.Card.BossCardStatus
|
||||
|
||||
if !detail.Authorized {
|
||||
detail.Reason = "ICCID不在当前操作员授权范围内"
|
||||
return
|
||||
}
|
||||
var reasons []string
|
||||
if detail.ChanKey == "" {
|
||||
reasons = append(reasons, "ICCID缺少通道标识")
|
||||
}
|
||||
if detail.InputMSISDN != "" && detail.InputMSISDN != detail.MSISDN {
|
||||
reasons = append(reasons, "MSISDN与卡档案不一致")
|
||||
}
|
||||
if detail.InputIMSI != "" && detail.InputIMSI != detail.IMSI {
|
||||
reasons = append(reasons, "IMSI与卡档案不一致")
|
||||
}
|
||||
detail.Reason = strings.Join(reasons, ";")
|
||||
// TODO: 正常系统开发完成后恢复卡状态限制
|
||||
// 注释原因:开发环境经常出现卡状态不在预期可操作范围内的情况,先放行
|
||||
// if detail.Card.BossCardStatus != operation.ExpectedStatus {
|
||||
// detail.Reason = "SIM card status does not allow this lifecycle operation"
|
||||
// return
|
||||
// }
|
||||
}
|
||||
|
||||
func groupByChanKey(details []*Detail) map[string][]*Detail {
|
||||
groups := make(map[string][]*Detail)
|
||||
for _, detail := range details {
|
||||
groups[detail.ChanKey] = append(groups[detail.ChanKey], detail)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func (e *executor) changeGroup(ctx context.Context, task *Task, details []*Detail) []Outcome {
|
||||
subBatches := splitDetails(details, consts.BatchSubBatchSize)
|
||||
subOutcomes := make([][]Outcome, len(subBatches))
|
||||
|
||||
sem := make(chan struct{}, consts.BatchSubBatchConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, sub := range subBatches {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(index int, batch []*Detail) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
subOutcomes[index] = e.changeSubBatch(ctx, task, batch)
|
||||
}(i, sub)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
outcomes := make([]Outcome, 0, len(details))
|
||||
for _, subOutcome := range subOutcomes {
|
||||
outcomes = append(outcomes, subOutcome...)
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
// changeSubBatch 处理单个子批次:构建请求并调用上游,返回与该子批次明细等长的 Outcome 切片。
|
||||
func (e *executor) changeSubBatch(ctx context.Context, task *Task, details []*Detail) []Outcome {
|
||||
input := &model.SimCardStatusChangeInput{
|
||||
SimKeys: make([]*model.SimKey, 0, len(details)),
|
||||
ReqData: model.SimCardStatusChangeInputItem{OperType: task.Operation.OperType},
|
||||
}
|
||||
for _, detail := range details {
|
||||
input.SimKeys = append(input.SimKeys, &model.SimKey{ChanKey: detail.ChanKey, ICCID: detail.ICCID, MSISDN: detail.MSISDN, IMSI: detail.IMSI})
|
||||
}
|
||||
response, err := e.upstream.ChangeSimStatus(ctx, input)
|
||||
if err != nil {
|
||||
outcomes := make([]Outcome, len(details))
|
||||
for index := range outcomes {
|
||||
outcomes[index].Message = "请求上游失败:" + err.Error()
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
return mapOutcomes(details, response)
|
||||
}
|
||||
|
||||
// splitDetails 按固定大小把明细切成有序的子批次;空输入返回空切片。
|
||||
func splitDetails(details []*Detail, size int) [][]*Detail {
|
||||
if size <= 0 {
|
||||
size = 1
|
||||
}
|
||||
batches := make([][]*Detail, 0, (len(details)+size-1)/size)
|
||||
for i := 0; i < len(details); i += size {
|
||||
end := i + size
|
||||
if end > len(details) {
|
||||
end = len(details)
|
||||
}
|
||||
batches = append(batches, details[i:end])
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
func mapOutcomes(details []*Detail, response []model.SimBaseRes[model.SimCardStatusChangeOutputItem]) []Outcome {
|
||||
outcomes := make([]Outcome, len(details))
|
||||
used := make([]bool, len(response))
|
||||
for detailIndex, detail := range details {
|
||||
matched := -1
|
||||
for responseIndex, item := range response {
|
||||
if !used[responseIndex] && sameSim(detail, item.SimKey) {
|
||||
matched = responseIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched < 0 && detailIndex < len(response) && !used[detailIndex] && response[detailIndex].SimKey == nil {
|
||||
matched = detailIndex
|
||||
}
|
||||
if matched < 0 {
|
||||
outcomes[detailIndex] = Outcome{Message: "upstream did not return a result for this SIM card"}
|
||||
continue
|
||||
}
|
||||
used[matched] = true
|
||||
item := response[matched]
|
||||
message := strings.TrimSpace(item.Message)
|
||||
if item.Code != 0 && message == "" {
|
||||
message = "upstream processing failed"
|
||||
}
|
||||
outcomes[detailIndex] = Outcome{Success: item.Code == 0, Code: item.Code, Message: message, OrderNum: strings.TrimSpace(item.ResData.OrderNum)}
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
func sameSim(detail *Detail, key *model.SimKey) bool {
|
||||
if detail == nil || key == nil {
|
||||
return false
|
||||
}
|
||||
if value := strings.TrimSpace(key.ICCID); value != "" {
|
||||
return value == detail.ICCID
|
||||
}
|
||||
if value := strings.TrimSpace(key.MSISDN); value != "" {
|
||||
return value == detail.MSISDN
|
||||
}
|
||||
if value := strings.TrimSpace(key.IMSI); value != "" {
|
||||
return value == detail.IMSI
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func OperationByBatchType(batchType int) (Operation, error) {
|
||||
switch batchType {
|
||||
case consts.BatchTypeSimCardStart:
|
||||
return Operation{BatchType: batchType, OperType: consts.SimCardStatusOperTypeStart, ExpectedStatus: consts.SimBossCardStatusStopped, TargetStatus: consts.SimBossCardStatusActivated}, nil
|
||||
case consts.BatchTypeSimCardStop:
|
||||
return Operation{BatchType: batchType, OperType: consts.SimCardStatusOperTypeStop, ExpectedStatus: consts.SimBossCardStatusActivated, TargetStatus: consts.SimBossCardStatusStopped}, nil
|
||||
default:
|
||||
return Operation{}, gerror.Newf("unsupported lifecycle batch type %d", batchType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestExecuteValidationFailureSkipsUpstream(t *testing.T) {
|
||||
operation, err := OperationByBatchType(consts.BatchTypeSimCardStop)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "B-1"},
|
||||
Operation: operation,
|
||||
Details: []*Detail{
|
||||
{ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", BossCardStatus: consts.SimBossCardStatusActivated}},
|
||||
{ID: 2, ICCID: "iccid-2", Authorized: false, Card: &Card{ID: 12, ChanKey: "channel", BossCardStatus: consts.SimBossCardStatusActivated}},
|
||||
},
|
||||
}
|
||||
repo := &fakeRepository{task: task}
|
||||
upstream := &fakeUpstream{responseError: errors.New("must not be called")}
|
||||
|
||||
if err = NewExecutor(repo, upstream).Execute(context.Background(), "B-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("upstream calls = %d, want 0", upstream.calls)
|
||||
}
|
||||
if !repo.validationSaved || !repo.finalized {
|
||||
t.Fatal("validation failure was not persisted and finalized")
|
||||
}
|
||||
if task.Details[0].Reason == "" || task.Details[1].Reason == "" {
|
||||
t.Fatal("every detail must receive a validation failure reason")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMapsMixedResponseAndPreservesOrderNumber(t *testing.T) {
|
||||
operation, err := OperationByBatchType(consts.BatchTypeSimCardStart)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "B-2"},
|
||||
Operation: operation,
|
||||
Details: []*Detail{
|
||||
{ID: 1, ICCID: "iccid-1", MSISDN: "msisdn-1", IMSI: "imsi-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", MSISDN: "msisdn-1", IMSI: "imsi-1", BossCardStatus: consts.SimBossCardStatusStopped}},
|
||||
{ID: 2, ICCID: "iccid-2", MSISDN: "msisdn-2", IMSI: "imsi-2", Authorized: true, Card: &Card{ID: 12, ChanKey: "channel", MSISDN: "msisdn-2", IMSI: "imsi-2", BossCardStatus: consts.SimBossCardStatusStopped}},
|
||||
},
|
||||
}
|
||||
repo := &fakeRepository{task: task}
|
||||
upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardStatusChangeOutputItem]{
|
||||
{Code: 0, Message: "ok", SimKey: nil, ResData: model.SimCardStatusChangeOutputItem{OrderNum: "order-1"}},
|
||||
{Code: 9001, Message: "denied", SimKey: &model.SimKey{ICCID: "iccid-2"}, ResData: model.SimCardStatusChangeOutputItem{OrderNum: "order-2"}},
|
||||
}}
|
||||
|
||||
if err = NewExecutor(repo, upstream).Execute(context.Background(), "B-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.input.ReqData.OperType != consts.SimCardStatusOperTypeStart {
|
||||
t.Fatalf("operType = %q", upstream.input.ReqData.OperType)
|
||||
}
|
||||
if len(repo.saved) != 2 || !repo.saved[0].Success || repo.saved[0].OrderNum != "order-1" {
|
||||
t.Fatalf("first result = %+v", repo.saved)
|
||||
}
|
||||
if repo.saved[1].Success || repo.saved[1].Code != 9001 || repo.saved[1].Message != "denied" || repo.saved[1].OrderNum != "order-2" {
|
||||
t.Fatalf("second result = %+v", repo.saved[1])
|
||||
}
|
||||
if !repo.finalized {
|
||||
t.Fatal("batch was not finalized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationByBatchTypeOperType(t *testing.T) {
|
||||
start, err := OperationByBatchType(consts.BatchTypeSimCardStart)
|
||||
if err != nil || start.OperType != "1" || start.TargetStatus != consts.SimBossCardStatusActivated {
|
||||
t.Fatalf("start operation = %+v, err = %v", start, err)
|
||||
}
|
||||
stop, err := OperationByBatchType(consts.BatchTypeSimCardStop)
|
||||
if err != nil || stop.OperType != "0" || stop.TargetStatus != consts.SimBossCardStatusStopped {
|
||||
t.Fatalf("stop operation = %+v, err = %v", stop, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDetailPopulatesCardFieldsOnStatusFailure(t *testing.T) {
|
||||
t.Skip("TODO: 恢复卡状态限制后启用此测试")
|
||||
operation, err := OperationByBatchType(consts.BatchTypeSimCardStart)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail := &Detail{
|
||||
ID: 1,
|
||||
RowNo: 1,
|
||||
ICCID: "iccid-1",
|
||||
Card: &Card{
|
||||
ID: 11,
|
||||
ICCID: "iccid-1",
|
||||
MSISDN: "msisdn-1",
|
||||
IMSI: "imsi-1",
|
||||
ChanKey: "channel",
|
||||
BossCardStatus: consts.SimBossCardStatusActivated,
|
||||
},
|
||||
}
|
||||
validateDetail(operation, detail)
|
||||
if detail.Reason == "" {
|
||||
t.Fatal("expected validation failure reason")
|
||||
}
|
||||
if detail.MSISDN != "msisdn-1" || detail.IMSI != "imsi-1" || detail.ChanKey != "channel" {
|
||||
t.Fatalf("card fields not populated on status failure: %+v", detail)
|
||||
}
|
||||
if detail.StatusBefore != consts.SimBossCardStatusActivated {
|
||||
t.Fatalf("StatusBefore = %d, want %d", detail.StatusBefore, consts.SimBossCardStatusActivated)
|
||||
}
|
||||
snapshot := detail.Snapshot()
|
||||
if snapshot.Get("MSISDN").String() != "msisdn-1" || snapshot.Get("IMSI").String() != "imsi-1" || snapshot.Get("chanKey").String() != "channel" {
|
||||
t.Fatalf("snapshot missing card fields: %s", snapshot.MustToJsonString())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDetailDetectsMSISDNMismatch(t *testing.T) {
|
||||
operation, err := OperationByBatchType(consts.BatchTypeSimCardStart)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail := &Detail{
|
||||
ID: 1,
|
||||
ICCID: "iccid-1",
|
||||
Authorized: true,
|
||||
InputMSISDN: "msisdn-input",
|
||||
Card: &Card{
|
||||
ID: 11,
|
||||
ICCID: "iccid-1",
|
||||
MSISDN: "msisdn-card",
|
||||
IMSI: "imsi-1",
|
||||
ChanKey: "channel",
|
||||
BossCardStatus: consts.SimBossCardStatusStopped,
|
||||
},
|
||||
}
|
||||
validateDetail(operation, detail)
|
||||
if detail.Reason != "MSISDN与卡档案不一致" {
|
||||
t.Fatalf("unexpected reason: %q", detail.Reason)
|
||||
}
|
||||
if detail.MSISDN != "msisdn-card" || detail.InputMSISDN != "msisdn-input" {
|
||||
t.Fatalf("mismatch not preserved: MSISDN=%q InputMSISDN=%q", detail.MSISDN, detail.InputMSISDN)
|
||||
}
|
||||
snapshot := detail.Snapshot()
|
||||
if snapshot.Get("inputMsisdn").String() != "msisdn-input" || snapshot.Get("MSISDN").String() != "msisdn-card" {
|
||||
t.Fatalf("snapshot mismatch fields wrong: %s", snapshot.MustToJsonString())
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRepository struct {
|
||||
task *Task
|
||||
validationSaved bool
|
||||
finalized bool
|
||||
saved []Outcome
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Load(context.Context, string) (*Task, error) { return r.task, nil }
|
||||
|
||||
func (r *fakeRepository) SaveValidationFailure(_ context.Context, task *Task) error {
|
||||
r.validationSaved = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) SaveResult(_ context.Context, _ *Task, detail *Detail, outcome Outcome) (Outcome, error) {
|
||||
detail.ApplyOutcome(outcome)
|
||||
r.saved = append(r.saved, outcome)
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Finalize(context.Context, *Task) error {
|
||||
r.finalized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeUpstream struct {
|
||||
calls int
|
||||
input *model.SimCardStatusChangeInput
|
||||
response []model.SimBaseRes[model.SimCardStatusChangeOutputItem]
|
||||
responseError error
|
||||
}
|
||||
|
||||
func (u *fakeUpstream) ChangeSimStatus(_ context.Context, input *model.SimCardStatusChangeInput) ([]model.SimBaseRes[model.SimCardStatusChangeOutputItem], error) {
|
||||
u.calls++
|
||||
u.input = input
|
||||
return u.response, u.responseError
|
||||
}
|
||||
|
||||
func TestValidateDetailCollectsIndependentDiagnostics(t *testing.T) {
|
||||
detail := &Detail{ICCID: "card", Authorized: true, InputMSISDN: "wrong-phone", InputIMSI: "wrong-imsi", Card: &Card{MSISDN: "phone", IMSI: "imsi"}}
|
||||
validateDetail(Operation{}, detail)
|
||||
want := "ICCID缺少通道标识;MSISDN与卡档案不一致;IMSI与卡档案不一致"
|
||||
if detail.Reason != want {
|
||||
t.Fatalf("独立校验原因丢失: got=%q want=%q", detail.Reason, want)
|
||||
}
|
||||
unauthorized := &Detail{ICCID: "card", InputMSISDN: "wrong", Card: &Card{MSISDN: "phone"}}
|
||||
validateDetail(Operation{}, unauthorized)
|
||||
if unauthorized.Reason != "ICCID不在当前操作员授权范围内" {
|
||||
t.Fatalf("越权卡不应返回其他数据差异: %q", unauthorized.Reason)
|
||||
}
|
||||
existing := &Detail{Reason: "原始输入格式错误", Authorized: true, Card: &Card{}}
|
||||
validateDetail(Operation{}, existing)
|
||||
if existing.Reason != "原始输入格式错误" {
|
||||
t.Fatal("原始原因被覆盖")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
const (
|
||||
restartStageStop = "stop"
|
||||
restartStageResume = "resume"
|
||||
)
|
||||
|
||||
type RestartRepository interface {
|
||||
FindRestartMain(ctx context.Context, batchNo string) (*entity.BatchMain, error)
|
||||
LoadRestartStop(ctx context.Context, batchNo string) (*Task, error)
|
||||
LoadRestartResume(ctx context.Context, batchNo string) (*Task, error)
|
||||
SaveRestartValidationFailure(ctx context.Context, task *Task, stage string) error
|
||||
SaveRestartResult(ctx context.Context, task *Task, detail *Detail, outcome Outcome, stage string) (Outcome, error)
|
||||
WaitRestartResume(ctx context.Context, task *Task, resumeAt *gtime.Time) error
|
||||
ClaimRestartResume(ctx context.Context, batchNo string, now *gtime.Time) (bool, error)
|
||||
FinalizeRestart(ctx context.Context, batchNo string) error
|
||||
}
|
||||
|
||||
type RestartPublisher func(ctx context.Context, delayQueue, targetQueue string, delay time.Duration, data any) error
|
||||
|
||||
type restartExecutor struct {
|
||||
repository RestartRepository
|
||||
upstream Upstream
|
||||
publishDelayed RestartPublisher
|
||||
now func() *gtime.Time
|
||||
}
|
||||
|
||||
func NewRestartExecutor(repository RestartRepository, upstream Upstream) Executor {
|
||||
return NewRestartExecutorWithPublisher(repository, upstream, nil)
|
||||
}
|
||||
|
||||
func NewRestartExecutorWithPublisher(repository RestartRepository, upstream Upstream, publisher RestartPublisher) Executor {
|
||||
if publisher == nil {
|
||||
publisher = publishRestartDelayed
|
||||
}
|
||||
return &restartExecutor{
|
||||
repository: repository,
|
||||
upstream: upstream,
|
||||
publishDelayed: publisher,
|
||||
now: gtime.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func publishRestartDelayed(ctx context.Context, delayQueue, targetQueue string, delay time.Duration, data any) error {
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "marshal restart resume message")
|
||||
}
|
||||
publishCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
return mq.PublishDelayedBytesWithHeaders(publishCtx, delayQueue, targetQueue, delay, body, nil)
|
||||
}
|
||||
|
||||
func (e *restartExecutor) CanExecuteProcessing(ctx context.Context, main *entity.BatchMain) (bool, error) {
|
||||
if main == nil || main.BatchType != consts.BatchTypeSimCardRestart || main.BatchAsyncStatus != consts.BatchAsyncStatusProcessing {
|
||||
return false, nil
|
||||
}
|
||||
if restartPhase(main.BatchInfo) != consts.RestartPhaseWaitingResume {
|
||||
return false, nil
|
||||
}
|
||||
resumeAt := restartResumeAt(main.BatchInfo)
|
||||
if resumeAt == nil {
|
||||
g.Log().Warningf(ctx, "restart batch waiting without resumeAt, batchNo:%s", main.BatchNo)
|
||||
return false, nil
|
||||
}
|
||||
now := e.currentTime()
|
||||
if resumeAt.After(now) {
|
||||
delay := resumeAt.Sub(now)
|
||||
if delay < time.Millisecond {
|
||||
delay = time.Millisecond
|
||||
}
|
||||
message := batch.Message{BatchNo: main.BatchNo, BatchType: main.BatchType}
|
||||
if err := e.publishDelayed(ctx, consts.QueueBatchRestartResumeDelay, consts.QueueBatchExecute, delay, message); err != nil {
|
||||
return false, gerror.Wrap(err, "reschedule early restart resume message")
|
||||
}
|
||||
g.Log().Infof(ctx, "restart batch resume message rescheduled before resumeAt, batchNo:%s, resumeAt:%s, delay:%s", main.BatchNo, resumeAt.String(), delay)
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (e *restartExecutor) ClaimsProcessingReentry() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *restartExecutor) Execute(ctx context.Context, batchNo string) error {
|
||||
batchNo = strings.TrimSpace(batchNo)
|
||||
if batchNo == "" {
|
||||
return gerror.New("restart batch number is empty")
|
||||
}
|
||||
main, err := e.repository.FindRestartMain(ctx, batchNo)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load restart batch main")
|
||||
}
|
||||
if main == nil {
|
||||
return gerror.New("restart batch does not exist")
|
||||
}
|
||||
if restartPhase(main.BatchInfo) == consts.RestartPhaseWaitingResume {
|
||||
claimed, claimErr := e.repository.ClaimRestartResume(ctx, batchNo, e.currentTime())
|
||||
if claimErr != nil {
|
||||
return gerror.Wrap(claimErr, "claim restart resume phase")
|
||||
}
|
||||
if !claimed {
|
||||
g.Log().Infof(ctx, "restart resume message ignored after CAS lost, batchNo:%s", batchNo)
|
||||
return nil
|
||||
}
|
||||
return e.executeResume(ctx, batchNo)
|
||||
}
|
||||
return e.executeStop(ctx, batchNo)
|
||||
}
|
||||
|
||||
func (e *restartExecutor) executeStop(ctx context.Context, batchNo string) error {
|
||||
task, err := e.repository.LoadRestartStop(ctx, batchNo)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load restart stop phase")
|
||||
}
|
||||
if !validateTask(task) {
|
||||
if err = e.repository.SaveRestartValidationFailure(ctx, task, restartStageStop); err != nil {
|
||||
return gerror.Wrap(err, "save restart stop validation failure")
|
||||
}
|
||||
return gerror.Wrap(e.repository.FinalizeRestart(ctx, batchNo), "finalize restart stop validation failure")
|
||||
}
|
||||
|
||||
successCount, err := e.changeRestartGroups(ctx, task, restartStageStop)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if successCount == 0 {
|
||||
return gerror.Wrap(e.repository.FinalizeRestart(ctx, batchNo), "finalize restart with no stop success")
|
||||
}
|
||||
|
||||
delay := restartDelay(task.Batch.BatchInfo)
|
||||
resumeAt := e.currentTime().Add(delay)
|
||||
if err = e.repository.WaitRestartResume(ctx, task, resumeAt); err != nil {
|
||||
return gerror.Wrap(err, "mark restart waiting resume")
|
||||
}
|
||||
message := batch.Message{BatchNo: task.Batch.BatchNo, BatchType: consts.BatchTypeSimCardRestart}
|
||||
if err = e.publishDelayed(ctx, consts.QueueBatchRestartResumeDelay, consts.QueueBatchExecute, delay, message); err != nil {
|
||||
return gerror.Wrap(err, "publish restart resume delayed message")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *restartExecutor) executeResume(ctx context.Context, batchNo string) error {
|
||||
task, err := e.repository.LoadRestartResume(ctx, batchNo)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load restart resume phase")
|
||||
}
|
||||
if !validateTask(task) {
|
||||
if err = e.repository.SaveRestartValidationFailure(ctx, task, restartStageResume); err != nil {
|
||||
return gerror.Wrap(err, "save restart resume validation failure")
|
||||
}
|
||||
return gerror.Wrap(e.repository.FinalizeRestart(ctx, batchNo), "finalize restart resume validation failure")
|
||||
}
|
||||
if _, err = e.changeRestartGroups(ctx, task, restartStageResume); err != nil {
|
||||
return err
|
||||
}
|
||||
return gerror.Wrap(e.repository.FinalizeRestart(ctx, batchNo), "finalize restart batch")
|
||||
}
|
||||
|
||||
func (e *restartExecutor) changeRestartGroups(ctx context.Context, task *Task, stage string) (int, error) {
|
||||
groups := groupByChanKey(task.Details)
|
||||
keys := make([]string, 0, len(groups))
|
||||
for chanKey := range groups {
|
||||
keys = append(keys, chanKey)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
successCount := 0
|
||||
for _, chanKey := range keys {
|
||||
outcomes := (&executor{upstream: e.upstream}).changeGroup(ctx, task, groups[chanKey])
|
||||
for index, outcome := range outcomes {
|
||||
detail := groups[chanKey][index]
|
||||
resolved, saveErr := e.repository.SaveRestartResult(ctx, task, detail, outcome, stage)
|
||||
if saveErr != nil {
|
||||
return successCount, gerror.Wrapf(saveErr, "save restart %s detail %d result", stage, detail.ID)
|
||||
}
|
||||
detail.ApplyOutcome(resolved)
|
||||
if resolved.Success {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
return successCount, nil
|
||||
}
|
||||
|
||||
func (e *restartExecutor) currentTime() *gtime.Time {
|
||||
if e.now != nil {
|
||||
return e.now()
|
||||
}
|
||||
return gtime.Now()
|
||||
}
|
||||
|
||||
func restartDelay(info *gjson.Json) time.Duration {
|
||||
seconds := 300
|
||||
if info != nil {
|
||||
if configured := info.Get("resumeDelaySeconds").Int(); configured > 0 {
|
||||
seconds = configured
|
||||
}
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func restartPhase(info *gjson.Json) string {
|
||||
if info == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(info.Get("restart.phase").String())
|
||||
}
|
||||
|
||||
func restartResumeAt(info *gjson.Json) *gtime.Time {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
value := strings.TrimSpace(info.Get("restart.resumeAt").String())
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parsed := gtime.NewFromStr(value)
|
||||
if parsed == nil || parsed.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"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/model/entity"
|
||||
)
|
||||
|
||||
func TestRestartResumeBeforeScheduledTimeIsRescheduled(t *testing.T) {
|
||||
now := gtime.New("2026-10-10 13:46:42")
|
||||
repo := &fakeRestartRepository{
|
||||
main: restartMain(consts.RestartPhaseWaitingResume, gjson.New(map[string]any{
|
||||
"restart": map[string]any{"resumeAt": "2026-10-10 13:46:44"},
|
||||
})),
|
||||
}
|
||||
publisher := &recordingRestartPublisher{}
|
||||
executor := NewRestartExecutorWithPublisher(repo, &fakeUpstream{}, publisher.PublishDelayed).(*restartExecutor)
|
||||
executor.now = func() *gtime.Time { return now }
|
||||
|
||||
ok, err := executor.CanExecuteProcessing(context.Background(), repo.main)
|
||||
if ok || err != nil {
|
||||
t.Fatalf("CanExecuteProcessing() = %t, %v; want false, nil", ok, err)
|
||||
}
|
||||
if publisher.calls != 1 || publisher.delay != 2*time.Second {
|
||||
t.Fatalf("publisher calls=%d delay=%s; want one call with 2s delay", publisher.calls, publisher.delay)
|
||||
}
|
||||
if publisher.message != (batch.Message{BatchNo: repo.main.BatchNo, BatchType: repo.main.BatchType}) {
|
||||
t.Fatalf("rescheduled message = %+v", publisher.message)
|
||||
}
|
||||
if repo.claimed || repo.finalized {
|
||||
t.Fatal("early message must not claim or finalize the batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartResumeRescheduleFailureIsReturned(t *testing.T) {
|
||||
now := gtime.New("2026-10-10 13:46:42")
|
||||
main := restartMain(consts.RestartPhaseWaitingResume, gjson.New(map[string]any{
|
||||
"restart": map[string]any{"resumeAt": "2026-10-10 13:46:44"},
|
||||
}))
|
||||
publishErr := errors.New("publish failed")
|
||||
publisher := func(context.Context, string, string, time.Duration, any) error { return publishErr }
|
||||
executor := NewRestartExecutorWithPublisher(&fakeRestartRepository{main: main}, &fakeUpstream{}, publisher).(*restartExecutor)
|
||||
executor.now = func() *gtime.Time { return now }
|
||||
ok, err := executor.CanExecuteProcessing(context.Background(), main)
|
||||
if ok || !errors.Is(err, publishErr) || batch.IsRetryable(err) {
|
||||
t.Fatalf("CanExecuteProcessing() = %t, %v; want publish error without execution retry flag", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartStopValidationFailureSkipsUpstream(t *testing.T) {
|
||||
task := restartStopTask()
|
||||
task.Details = append(task.Details, &Detail{
|
||||
ID: 2,
|
||||
ICCID: "iccid-2",
|
||||
Authorized: false,
|
||||
Card: &Card{ID: 12, ChanKey: "channel", BossCardStatus: consts.SimBossCardStatusActivated},
|
||||
})
|
||||
repo := &fakeRestartRepository{
|
||||
main: restartMain(consts.RestartPhaseCreated, nil),
|
||||
stopTask: task,
|
||||
}
|
||||
upstream := &fakeUpstream{}
|
||||
executor := NewRestartExecutorWithPublisher(repo, upstream, fakeRestartPublisher(t)).(*restartExecutor)
|
||||
|
||||
if err := executor.Execute(context.Background(), "RST-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("upstream calls = %d, want 0", upstream.calls)
|
||||
}
|
||||
if repo.validationStage != restartStageStop || !repo.finalized {
|
||||
t.Fatalf("validation/finalize state = stage:%q finalized:%t", repo.validationStage, repo.finalized)
|
||||
}
|
||||
if len(repo.saved) != 0 || repo.waited {
|
||||
t.Fatalf("unexpected stop result or wait: saved=%d waited=%t", len(repo.saved), repo.waited)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartStopSuccessSchedulesDelayedResume(t *testing.T) {
|
||||
now := gtime.NewFromStr("2026-07-20 10:00:00")
|
||||
task := restartStopTask()
|
||||
task.Batch.BatchInfo = gjson.New(map[string]any{
|
||||
"resumeDelaySeconds": 1,
|
||||
"restart": map[string]any{"phase": consts.RestartPhaseCreated},
|
||||
})
|
||||
repo := &fakeRestartRepository{
|
||||
main: restartMain(consts.RestartPhaseCreated, gjson.New(map[string]any{"resumeDelaySeconds": 1})),
|
||||
stopTask: task,
|
||||
}
|
||||
upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardStatusChangeOutputItem]{
|
||||
{Code: 0, Message: "ok", ResData: model.SimCardStatusChangeOutputItem{OrderNum: "stop-order"}},
|
||||
}}
|
||||
publisher := &recordingRestartPublisher{}
|
||||
executor := NewRestartExecutorWithPublisher(repo, upstream, publisher.PublishDelayed).(*restartExecutor)
|
||||
executor.now = func() *gtime.Time { return now }
|
||||
|
||||
if err := executor.Execute(context.Background(), "RST-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 1 || upstream.input.ReqData.OperType != consts.SimCardStatusOperTypeStop {
|
||||
t.Fatalf("upstream stop input = %+v calls=%d", upstream.input, upstream.calls)
|
||||
}
|
||||
if len(repo.saved) != 1 || repo.saved[0].stage != restartStageStop || !repo.saved[0].outcome.Success {
|
||||
t.Fatalf("saved stop results = %+v", repo.saved)
|
||||
}
|
||||
if !repo.waited || repo.resumeAt.String() != now.Add(time.Second).String() {
|
||||
t.Fatalf("resume wait = waited:%t resumeAt:%v", repo.waited, repo.resumeAt)
|
||||
}
|
||||
message, ok := publisher.message.(batch.Message)
|
||||
if !ok || publisher.calls != 1 || publisher.delay != time.Second || message.BatchType != consts.BatchTypeSimCardRestart {
|
||||
t.Fatalf("publisher = %+v", publisher)
|
||||
}
|
||||
if repo.finalized {
|
||||
t.Fatal("batch must not finalize before resume when stop succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartResumeClaimLostSkipsDuplicateUpstream(t *testing.T) {
|
||||
now := gtime.NewFromStr("2026-07-20 10:05:00")
|
||||
repo := &fakeRestartRepository{
|
||||
main: restartMain(consts.RestartPhaseWaitingResume, gjson.New(map[string]any{"restart": map[string]any{"resumeAt": now.String()}})),
|
||||
claimResult: false,
|
||||
resumeTask: restartResumeTask(),
|
||||
}
|
||||
upstream := &fakeUpstream{}
|
||||
executor := NewRestartExecutorWithPublisher(repo, upstream, fakeRestartPublisher(t)).(*restartExecutor)
|
||||
executor.now = func() *gtime.Time { return now }
|
||||
|
||||
if err := executor.Execute(context.Background(), "RST-3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !repo.claimed {
|
||||
t.Fatal("resume CAS was not attempted")
|
||||
}
|
||||
if upstream.calls != 0 || repo.loadedResume || repo.finalized {
|
||||
t.Fatalf("duplicate was not skipped: upstream=%d loadedResume=%t finalized=%t", upstream.calls, repo.loadedResume, repo.finalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartResumeExecutesAfterClaim(t *testing.T) {
|
||||
now := gtime.NewFromStr("2026-07-20 10:05:00")
|
||||
repo := &fakeRestartRepository{
|
||||
main: restartMain(consts.RestartPhaseWaitingResume, gjson.New(map[string]any{"restart": map[string]any{"resumeAt": now.String()}})),
|
||||
claimResult: true,
|
||||
resumeTask: restartResumeTask(),
|
||||
}
|
||||
upstream := &fakeUpstream{response: []model.SimBaseRes[model.SimCardStatusChangeOutputItem]{
|
||||
{Code: 0, Message: "ok", ResData: model.SimCardStatusChangeOutputItem{OrderNum: "resume-order"}},
|
||||
}}
|
||||
executor := NewRestartExecutorWithPublisher(repo, upstream, fakeRestartPublisher(t)).(*restartExecutor)
|
||||
executor.now = func() *gtime.Time { return now }
|
||||
|
||||
if ok, err := executor.CanExecuteProcessing(context.Background(), repo.main); err != nil || !ok {
|
||||
t.Fatalf("CanExecuteProcessing() = %t, %v; want true, nil", ok, err)
|
||||
}
|
||||
if err := executor.Execute(context.Background(), "RST-4"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 1 || upstream.input.ReqData.OperType != consts.SimCardStatusOperTypeStart {
|
||||
t.Fatalf("upstream resume input = %+v calls=%d", upstream.input, upstream.calls)
|
||||
}
|
||||
if len(repo.saved) != 1 || repo.saved[0].stage != restartStageResume || !repo.saved[0].outcome.Success {
|
||||
t.Fatalf("saved resume results = %+v", repo.saved)
|
||||
}
|
||||
if !repo.finalized {
|
||||
t.Fatal("resume phase did not finalize the RST batch")
|
||||
}
|
||||
}
|
||||
|
||||
func restartMain(phase string, info *gjson.Json) *entity.BatchMain {
|
||||
data := map[string]any{"restart": map[string]any{"phase": phase}}
|
||||
if info != nil {
|
||||
for key, value := range info.Map() {
|
||||
if key == "restart" {
|
||||
restart := data["restart"].(map[string]any)
|
||||
for restartKey, restartValue := range gjson.New(value).Map() {
|
||||
restart[restartKey] = restartValue
|
||||
}
|
||||
continue
|
||||
}
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
return &entity.BatchMain{
|
||||
BatchNo: "RST",
|
||||
BatchType: consts.BatchTypeSimCardRestart,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
BatchInfo: gjson.New(data),
|
||||
}
|
||||
}
|
||||
|
||||
func restartStopTask() *Task {
|
||||
return &Task{
|
||||
Batch: Batch{
|
||||
BatchNo: "RST",
|
||||
BatchType: consts.BatchTypeSimCardRestart,
|
||||
BatchInfo: gjson.New(map[string]any{"restart": map[string]any{"phase": consts.RestartPhaseCreated}}),
|
||||
},
|
||||
Operation: Operation{OperType: consts.SimCardStatusOperTypeStop, ExpectedStatus: consts.SimBossCardStatusActivated, TargetStatus: consts.SimBossCardStatusStopped},
|
||||
Details: []*Detail{
|
||||
{ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", BossCardStatus: consts.SimBossCardStatusActivated}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func restartResumeTask() *Task {
|
||||
return &Task{
|
||||
Batch: Batch{BatchNo: "RST", BatchType: consts.BatchTypeSimCardRestart},
|
||||
Operation: Operation{OperType: consts.SimCardStatusOperTypeStart, ExpectedStatus: consts.SimBossCardStatusStopped, TargetStatus: consts.SimBossCardStatusActivated},
|
||||
Details: []*Detail{
|
||||
{ID: 1, ICCID: "iccid-1", Authorized: true, Card: &Card{ID: 11, ChanKey: "channel", BossCardStatus: consts.SimBossCardStatusStopped}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRestartRepository struct {
|
||||
main *entity.BatchMain
|
||||
stopTask *Task
|
||||
resumeTask *Task
|
||||
validationStage string
|
||||
saved []restartSavedResult
|
||||
waited bool
|
||||
resumeAt *gtime.Time
|
||||
claimed bool
|
||||
claimResult bool
|
||||
loadedResume bool
|
||||
finalized bool
|
||||
}
|
||||
|
||||
type restartSavedResult struct {
|
||||
stage string
|
||||
outcome Outcome
|
||||
}
|
||||
|
||||
func (r *fakeRestartRepository) FindRestartMain(context.Context, string) (*entity.BatchMain, error) {
|
||||
return r.main, nil
|
||||
}
|
||||
|
||||
func (r *fakeRestartRepository) LoadRestartStop(context.Context, string) (*Task, error) {
|
||||
return r.stopTask, nil
|
||||
}
|
||||
|
||||
func (r *fakeRestartRepository) LoadRestartResume(context.Context, string) (*Task, error) {
|
||||
r.loadedResume = true
|
||||
return r.resumeTask, nil
|
||||
}
|
||||
|
||||
func (r *fakeRestartRepository) SaveRestartValidationFailure(_ context.Context, _ *Task, stage string) error {
|
||||
r.validationStage = stage
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRestartRepository) SaveRestartResult(_ context.Context, _ *Task, detail *Detail, outcome Outcome, stage string) (Outcome, error) {
|
||||
detail.ApplyOutcome(outcome)
|
||||
r.saved = append(r.saved, restartSavedResult{stage: stage, outcome: outcome})
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
func (r *fakeRestartRepository) WaitRestartResume(_ context.Context, _ *Task, resumeAt *gtime.Time) error {
|
||||
r.waited = true
|
||||
r.resumeAt = resumeAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRestartRepository) ClaimRestartResume(context.Context, string, *gtime.Time) (bool, error) {
|
||||
r.claimed = true
|
||||
return r.claimResult, nil
|
||||
}
|
||||
|
||||
func (r *fakeRestartRepository) FinalizeRestart(context.Context, string) error {
|
||||
r.finalized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type recordingRestartPublisher struct {
|
||||
calls int
|
||||
delay time.Duration
|
||||
message any
|
||||
}
|
||||
|
||||
func (p *recordingRestartPublisher) PublishDelayed(_ context.Context, _, _ string, delay time.Duration, data any) error {
|
||||
p.calls++
|
||||
p.delay = delay
|
||||
p.message = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func fakeRestartPublisher(t *testing.T) RestartPublisher {
|
||||
t.Helper()
|
||||
return func(context.Context, string, string, time.Duration, any) error {
|
||||
t.Fatal("publisher must not be called")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
type Operation struct {
|
||||
BatchType int
|
||||
OperType string
|
||||
ExpectedStatus int
|
||||
TargetStatus int
|
||||
}
|
||||
|
||||
type Batch struct {
|
||||
ID int64
|
||||
BatchNo string
|
||||
BatchType int
|
||||
TenantID string
|
||||
CreatedBy int64
|
||||
CreatedDept int64
|
||||
StartTime *gtime.Time
|
||||
BatchInfo *gjson.Json
|
||||
}
|
||||
|
||||
type Card struct {
|
||||
ID int64
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
ChanKey string
|
||||
BossCardStatus int
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
ID int64
|
||||
RowNo int
|
||||
ICCID string
|
||||
InputMSISDN string
|
||||
InputIMSI string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
Authorized bool
|
||||
ChanKey string
|
||||
StatusBefore int
|
||||
Reason string
|
||||
OrderNum string
|
||||
UpstreamCode int
|
||||
UpstreamReason string
|
||||
Card *Card
|
||||
OriginalSnapshot *gjson.Json
|
||||
}
|
||||
|
||||
type Outcome struct {
|
||||
Success bool
|
||||
Code int
|
||||
Message string
|
||||
OrderNum string
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Batch Batch
|
||||
Operation Operation
|
||||
Details []*Detail
|
||||
ValidationOK bool
|
||||
FailureReason string
|
||||
}
|
||||
|
||||
func (d *Detail) ApplyOutcome(outcome Outcome) {
|
||||
d.OrderNum = strings.TrimSpace(outcome.OrderNum)
|
||||
d.UpstreamCode = outcome.Code
|
||||
d.UpstreamReason = strings.TrimSpace(outcome.Message)
|
||||
if outcome.Success {
|
||||
d.Reason = ""
|
||||
return
|
||||
}
|
||||
if d.UpstreamReason == "" {
|
||||
d.UpstreamReason = "upstream processing failed"
|
||||
}
|
||||
d.Reason = d.UpstreamReason
|
||||
}
|
||||
|
||||
func (d *Detail) Snapshot() *gjson.Json {
|
||||
if d == nil {
|
||||
return gjson.New(nil)
|
||||
}
|
||||
return gjson.New(detailSnapshot{
|
||||
ICCID: d.ICCID,
|
||||
InputMSISDN: d.InputMSISDN,
|
||||
InputIMSI: d.InputIMSI,
|
||||
MSISDN: d.MSISDN,
|
||||
IMSI: d.IMSI,
|
||||
Authorized: d.Authorized,
|
||||
ChanKey: d.ChanKey,
|
||||
StatusBefore: d.StatusBefore,
|
||||
OrderNum: d.OrderNum,
|
||||
UpstreamCode: d.UpstreamCode,
|
||||
UpstreamMessage: d.UpstreamReason,
|
||||
})
|
||||
}
|
||||
|
||||
type detailSnapshot struct {
|
||||
ICCID string `json:"ICCID"`
|
||||
InputMSISDN string `json:"inputMsisdn"`
|
||||
InputIMSI string `json:"inputImsi"`
|
||||
MSISDN string `json:"MSISDN"`
|
||||
IMSI string `json:"IMSI"`
|
||||
Authorized bool `json:"authorized"`
|
||||
ChanKey string `json:"chanKey"`
|
||||
StatusBefore int `json:"statusBefore"`
|
||||
OrderNum string `json:"orderNum"`
|
||||
UpstreamCode int `json:"upstreamCode"`
|
||||
UpstreamMessage string `json:"upstreamMessage"`
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
Load(ctx context.Context, batchNo string) (*Task, error)
|
||||
SaveValidationFailure(ctx context.Context, task *Task) error
|
||||
SaveResult(ctx context.Context, task *Task, detail *Detail, outcome Outcome) (Outcome, error)
|
||||
Finalize(ctx context.Context, task *Task) error
|
||||
}
|
||||
|
||||
type Upstream interface {
|
||||
ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) ([]model.SimBaseRes[model.SimCardStatusChangeOutputItem], error)
|
||||
}
|
||||
|
||||
type Executor interface {
|
||||
Execute(ctx context.Context, batchNo string) error
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package monthlyusage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
zsetconsumer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consumer/zset"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
monthlyusagerepository "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/monthlyusage"
|
||||
)
|
||||
|
||||
// errCodeTelecomChannelLimited is the carrier error code that means the whole
|
||||
// channel is throttled or disabled and must stop for this run.
|
||||
const errCodeTelecomChannelLimited = "S00004"
|
||||
|
||||
// The gateway uses code 9999 without an errCode for transport, HTTP, and
|
||||
// response-decoding failures. These failures are safe to retry from the task
|
||||
// lease; a populated errCode is treated as a business result.
|
||||
const chanGatewayInternalErrorCode = 9999
|
||||
|
||||
// tokenExpiredCode is the carrier code for an expired/invalid upstream token.
|
||||
// The gateway already invalidates and refreshes the token once on this code;
|
||||
// when it still reaches the worker, retrying in a later round lets a refreshed
|
||||
// token succeed instead of parking the card as a terminal business failure.
|
||||
const tokenExpiredCode = 12021
|
||||
|
||||
const (
|
||||
mobileBusyFirstCode = 13000
|
||||
mobileBusyLastCode = 13007
|
||||
mobileBusyClass = "mobile_busy"
|
||||
)
|
||||
|
||||
// Mobile usage templates are identified by the template returned with the
|
||||
// channel metadata. The channel name and error message are not stable
|
||||
// carrier identifiers.
|
||||
//
|
||||
// The set is derived from the OneLink registration table in the upstream
|
||||
// proxy (iotlcm-upstream-proxy/internal/logic/upstreamimpl/sim_yd.go
|
||||
// init()): every key below is registered to the China Mobile OneLink
|
||||
// sSimYD implementation, so numeric codes 13000-13007 emitted by any of
|
||||
// them carry OneLink semantics and are safe to retry across rounds.
|
||||
// Keep this list in sync with that registration table; templates that are
|
||||
// not registered there (including unknown future keys) stay "generic" and
|
||||
// are never classified as mobile.
|
||||
var mobileUsageTemplates = map[string]struct{}{
|
||||
"sim_yd_v5": {},
|
||||
"sim_yd_mock_v5": {},
|
||||
"sim_sdtywl_v1": {},
|
||||
"sim_onelink_tengyu_v1": {},
|
||||
"sim_onelink_xuzhou_mobile_v1": {},
|
||||
"sim_onelink_lelian_v1": {},
|
||||
"sim_onelink_qianxun_v1": {},
|
||||
"sim_onelink_henan_xinjing_v1": {},
|
||||
"sim_henan_xinjing_direct_v1": {},
|
||||
"sim_onelink_zibo_v1": {},
|
||||
"sim_tibiot_v1": {},
|
||||
"sim_qianxun_v3": {},
|
||||
"sim_yunda_v1": {},
|
||||
"sim_mingqian_v3": {},
|
||||
"sim_daihe_v1": {},
|
||||
"sim_onelink_daihe_v1": {},
|
||||
"sim_onelink_daliuliang_v1": {},
|
||||
"sim__daliuliang_v1": {},
|
||||
"sim_onelink_mobile_public_v1": {},
|
||||
"sim_onelink_kedang_v1": {},
|
||||
"sim_onelink_beixin_v1": {},
|
||||
"lanshang_8000": {},
|
||||
"lanshang_8003": {},
|
||||
}
|
||||
|
||||
const telecomUsageTemplate = "sim_dx_v1"
|
||||
|
||||
func usageCarrierForTemplate(tempKey string) string {
|
||||
tempKey = strings.ToLower(strings.TrimSpace(tempKey))
|
||||
if model.IsTelecomTemplate(tempKey) {
|
||||
return "telecom"
|
||||
}
|
||||
if _, ok := mobileUsageTemplates[tempKey]; ok {
|
||||
return "mobile"
|
||||
}
|
||||
return "generic"
|
||||
}
|
||||
|
||||
// classifyUsageFailure maps a gateway item to the queue semantics owned by
|
||||
// this service. Carrier identity is resolved from the template first; error
|
||||
// text and channel names are deliberately excluded from the decision.
|
||||
func classifyUsageFailure(tempKey string, code int, errCode string) (errorClass string, retryable, blockChannel bool) {
|
||||
errorClass = "business"
|
||||
carrier := usageCarrierForTemplate(tempKey)
|
||||
switch {
|
||||
case carrier == "telecom" && strings.EqualFold(strings.TrimSpace(errCode), errCodeTelecomChannelLimited):
|
||||
return "channel_limited", false, true
|
||||
case carrier == "mobile" && code >= mobileBusyFirstCode && code <= mobileBusyLastCode:
|
||||
return mobileBusyClass, true, false
|
||||
case code == chanGatewayInternalErrorCode && strings.TrimSpace(errCode) == "":
|
||||
return "network", true, false
|
||||
case code == tokenExpiredCode:
|
||||
return "token_expired", true, false
|
||||
default:
|
||||
return errorClass, false, false
|
||||
}
|
||||
}
|
||||
|
||||
type UsageClient interface {
|
||||
QuerySimDataUsage(context.Context, *model.SimDataUsageInput) ([]model.SimBaseRes[model.SimDataUsageItem], error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
client UsageClient
|
||||
repo UsageRepository
|
||||
}
|
||||
|
||||
type UsageRepository interface {
|
||||
ApplyBatch(context.Context, []monthlyusagerepository.UsageResult) error
|
||||
}
|
||||
|
||||
func NewHandler(client UsageClient, repo UsageRepository) (*Handler, error) {
|
||||
if client == nil || repo == nil {
|
||||
return nil, fmt.Errorf("monthly usage handler dependencies are incomplete")
|
||||
}
|
||||
return &Handler{client: client, repo: repo}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) Query(ctx context.Context, task queue.Task) zsetconsumer.Result {
|
||||
result := zsetconsumer.Result{Task: task}
|
||||
output, err := h.client.QuerySimDataUsage(ctx, &model.SimDataUsageInput{
|
||||
SimKeys: []*model.SimKey{{
|
||||
ChanKey: task.ChanKey,
|
||||
ICCID: task.ICCID,
|
||||
IMSI: task.IMSI,
|
||||
MSISDN: task.MSISDN,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
result.ErrorClass = "network"
|
||||
result.Retryable = true
|
||||
result.Message = err.Error()
|
||||
return result
|
||||
}
|
||||
if len(output) == 0 || output[0].SimKey == nil {
|
||||
result.ErrorClass = "protocol"
|
||||
result.Retryable = true
|
||||
result.Message = "chan returned empty current usage result"
|
||||
return result
|
||||
}
|
||||
item := output[0]
|
||||
if task.ICCID != "" && item.SimKey.ICCID != task.ICCID {
|
||||
result.ErrorClass = "protocol"
|
||||
result.Retryable = true
|
||||
result.Message = "chan returned mismatched card identifier"
|
||||
return result
|
||||
}
|
||||
result.Code = item.Code
|
||||
result.Message = item.Message
|
||||
result.ErrCode = item.ErrCode
|
||||
if item.Code != 0 {
|
||||
result.ErrorClass, result.Retryable, result.BlockChannel = classifyUsageFailure(task.TempKey, item.Code, item.ErrCode)
|
||||
return result
|
||||
}
|
||||
result.Success = true
|
||||
result.UsageMB = item.ResData.DataUsage
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *Handler) Apply(ctx context.Context, results []zsetconsumer.Result) error {
|
||||
updates := make([]monthlyusagerepository.UsageResult, 0, len(results))
|
||||
for _, result := range results {
|
||||
if !result.Success {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, monthlyusagerepository.UsageResult{
|
||||
CardID: result.Task.CardID,
|
||||
UsageMB: result.UsageMB,
|
||||
})
|
||||
}
|
||||
return h.repo.ApplyBatch(ctx, updates)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package monthlyusage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
zsetconsumer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consumer/zset"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
monthlyusagerepository "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/monthlyusage"
|
||||
)
|
||||
|
||||
type usageClientStub struct {
|
||||
output []model.SimBaseRes[model.SimDataUsageItem]
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *usageClientStub) QuerySimDataUsage(context.Context, *model.SimDataUsageInput) ([]model.SimBaseRes[model.SimDataUsageItem], error) {
|
||||
return s.output, s.err
|
||||
}
|
||||
|
||||
type usageRepositoryStub struct {
|
||||
calls int
|
||||
updates []monthlyusagerepository.UsageResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *usageRepositoryStub) ApplyBatch(_ context.Context, updates []monthlyusagerepository.UsageResult) error {
|
||||
s.calls++
|
||||
s.updates = append(s.updates, updates...)
|
||||
return s.err
|
||||
}
|
||||
|
||||
func TestQueryClassifiesCurrentUsageResults(t *testing.T) {
|
||||
task := queue.Task{TaskID: "1", ChanKey: "telecom-a", TempKey: telecomUsageTemplate, CardID: 1, ICCID: "89860001"}
|
||||
tests := []struct {
|
||||
name string
|
||||
client *usageClientStub
|
||||
success bool
|
||||
errorClass string
|
||||
retryable bool
|
||||
blockChannel bool
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
client: &usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{
|
||||
SimKey: &model.SimKey{ICCID: task.ICCID},
|
||||
ResData: model.SimDataUsageItem{DataUsage: 12.5},
|
||||
}}},
|
||||
success: true,
|
||||
},
|
||||
{
|
||||
name: "mobile busy is terminal business failure",
|
||||
client: &usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{SimKey: &model.SimKey{ICCID: task.ICCID}, Code: 13000, Message: "busy"}}},
|
||||
errorClass: "business",
|
||||
},
|
||||
{
|
||||
name: "gateway internal failure is retryable",
|
||||
client: &usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{SimKey: &model.SimKey{ICCID: task.ICCID}, Code: 9999, Message: "upstream timeout"}}},
|
||||
errorClass: "network",
|
||||
retryable: true,
|
||||
},
|
||||
{
|
||||
name: "gateway business error code remains terminal",
|
||||
client: &usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{SimKey: &model.SimKey{ICCID: task.ICCID}, Code: 9999, ErrCode: "9999", Message: "invalid request"}}},
|
||||
errorClass: "business",
|
||||
},
|
||||
{
|
||||
name: "telecom threshold blocks channel via errCode",
|
||||
client: &usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{SimKey: &model.SimKey{ICCID: task.ICCID}, Code: 9999, Message: "流量查询失败", ErrCode: "S00004"}}},
|
||||
errorClass: "channel_limited",
|
||||
blockChannel: true,
|
||||
},
|
||||
{
|
||||
name: "message text alone does not block channel",
|
||||
client: &usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{SimKey: &model.SimKey{ICCID: task.ICCID}, Code: 13000, Message: "carrier S00004 mentioned in text"}}},
|
||||
errorClass: "business",
|
||||
},
|
||||
{
|
||||
name: "network failure is terminal for run",
|
||||
client: &usageClientStub{err: errors.New("connection reset")},
|
||||
errorClass: "network",
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
handler, err := NewHandler(test.client, &usageRepositoryStub{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := handler.Query(context.Background(), task)
|
||||
if result.Success != test.success || result.ErrorClass != test.errorClass || result.Retryable != test.retryable || result.BlockChannel != test.blockChannel {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileTemplatesClassifyBusyForRoundRetry(t *testing.T) {
|
||||
for template := range mobileUsageTemplates {
|
||||
for code := mobileBusyFirstCode; code <= mobileBusyLastCode; code++ {
|
||||
t.Run(fmt.Sprintf("%s/code-%d", template, code), func(t *testing.T) {
|
||||
handler, err := NewHandler(&usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{
|
||||
SimKey: &model.SimKey{ICCID: "89860001"}, Code: code, Message: "system busy",
|
||||
}}}, &usageRepositoryStub{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := handler.Query(context.Background(), queue.Task{TaskID: "busy", TempKey: template, ICCID: "89860001"})
|
||||
if result.ErrorClass != mobileBusyClass || !result.Retryable || result.BlockChannel {
|
||||
t.Fatalf("template %q code %d result = %+v", template, code, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanshangSharedTokenTemplatesRetryAcrossRounds(t *testing.T) {
|
||||
for _, template := range []string{"lanshang_8000", "lanshang_8003"} {
|
||||
for _, code := range []int{13000, 13001} {
|
||||
t.Run(fmt.Sprintf("%s/code-%d", template, code), func(t *testing.T) {
|
||||
errorClass, retryable, blockChannel := classifyUsageFailure(template, code, "")
|
||||
if errorClass != mobileBusyClass || !retryable || blockChannel {
|
||||
t.Fatalf("template %q code %d = (%q, %t, %t)", template, code, errorClass, retryable, blockChannel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileBusyRangeBoundariesAndPermanentCodes(t *testing.T) {
|
||||
template := "sim_onelink_kedang_v1"
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
errCode string
|
||||
mobileBusy bool
|
||||
retryable bool
|
||||
}{
|
||||
{name: "13007 is last busy code", code: 13007, mobileBusy: true, retryable: true},
|
||||
{name: "13008 is outside busy range", code: 13008},
|
||||
{name: "12021 token expiry is retryable but not busy", code: 12021, retryable: true},
|
||||
{name: "11011 permanent failure is not busy", code: 11011},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
errorClass, retryable, _ := classifyUsageFailure(template, test.code, test.errCode)
|
||||
if test.mobileBusy {
|
||||
if errorClass != mobileBusyClass || !retryable {
|
||||
t.Fatalf("code %d = (%q, %t)", test.code, errorClass, retryable)
|
||||
}
|
||||
return
|
||||
}
|
||||
if errorClass == mobileBusyClass {
|
||||
t.Fatalf("code %d = (%q, %t)", test.code, errorClass, retryable)
|
||||
}
|
||||
if retryable != test.retryable {
|
||||
t.Fatalf("code %d retryable = %t, want %t (errorClass=%q)", test.code, retryable, test.retryable, errorClass)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelecomTemplateIsNeverMobileBusy(t *testing.T) {
|
||||
for code := mobileBusyFirstCode; code <= mobileBusyLastCode; code++ {
|
||||
errorClass, retryable, blockChannel := classifyUsageFailure(telecomUsageTemplate, code, "")
|
||||
if errorClass == mobileBusyClass || retryable || blockChannel {
|
||||
t.Fatalf("telecom code %d = (%q, %t, %t)", code, errorClass, retryable, blockChannel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownTemplateDefaultsToGenericNotMobile(t *testing.T) {
|
||||
if carrier := usageCarrierForTemplate("sim_dx_v2_future"); carrier != "generic" {
|
||||
t.Fatalf("unknown template carrier = %q", carrier)
|
||||
}
|
||||
errorClass, retryable, _ := classifyUsageFailure("sim_dx_v2_future", 13000, "")
|
||||
if errorClass == mobileBusyClass || retryable {
|
||||
t.Fatalf("unknown template 13000 = (%q, %t)", errorClass, retryable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelecomV3ChannelLimitClassification(t *testing.T) {
|
||||
errorClass, retryable, blockChannel := classifyUsageFailure(" SIM_DX_V3 ", 9999, "S00004")
|
||||
if errorClass != "channel_limited" || retryable || !blockChannel {
|
||||
t.Fatalf("v3 channel limit = (%q, %t, %t)", errorClass, retryable, blockChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonTelecomS00004DoesNotBlockChannel(t *testing.T) {
|
||||
handler, err := NewHandler(&usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{
|
||||
SimKey: &model.SimKey{ICCID: "89860001"}, Code: 9999, ErrCode: errCodeTelecomChannelLimited,
|
||||
}}}, &usageRepositoryStub{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := handler.Query(context.Background(), queue.Task{TaskID: "mobile", TempKey: "sim_yd_v5", ICCID: "89860001"})
|
||||
if result.ErrorClass != "business" || result.BlockChannel {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWritesSuccessfulResultsInOneBatch(t *testing.T) {
|
||||
repository := &usageRepositoryStub{}
|
||||
handler, err := NewHandler(&usageClientStub{}, repository)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := []zsetconsumer.Result{
|
||||
{Task: queue.Task{CardID: 1}, Success: true, UsageMB: 1.5},
|
||||
{Task: queue.Task{CardID: 2}, ErrorClass: "business", Code: 13000},
|
||||
{Task: queue.Task{CardID: 3}, Success: true, UsageMB: 3.5},
|
||||
}
|
||||
if err = handler.Apply(context.Background(), results); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repository.calls != 1 || len(repository.updates) != 2 {
|
||||
t.Fatalf("repository calls = %d, updates = %+v", repository.calls, repository.updates)
|
||||
}
|
||||
if repository.updates[0].CardID != 1 || repository.updates[1].CardID != 3 {
|
||||
t.Fatalf("updates = %+v", repository.updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryPreservesNetworkFailureMessage(t *testing.T) {
|
||||
handler, err := NewHandler(&usageClientStub{err: errors.New("connection refused")}, &usageRepositoryStub{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := handler.Query(context.Background(), queue.Task{ICCID: "89860001"})
|
||||
if result.ErrorClass != "network" || !strings.Contains(result.Message, "connection refused") {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySkipsIccidCheckWhenTaskHasNoIccid(t *testing.T) {
|
||||
handler, err := NewHandler(&usageClientStub{output: []model.SimBaseRes[model.SimDataUsageItem]{{
|
||||
SimKey: &model.SimKey{ICCID: ""},
|
||||
ResData: model.SimDataUsageItem{DataUsage: 7.5},
|
||||
}}}, &usageRepositoryStub{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := handler.Query(context.Background(), queue.Task{TaskID: "1", ChanKey: "telecom-a", CardID: 1, MSISDN: "14900000001"})
|
||||
if !result.Success || result.UsageMB != 7.5 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package monthlyusage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
zsetconsumer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consumer/zset"
|
||||
fullsyncarchive "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/archive"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
|
||||
fullsyncruntime "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/runtime"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil"
|
||||
)
|
||||
|
||||
type runUsageClient struct{}
|
||||
|
||||
func (runUsageClient) QuerySimDataUsage(_ context.Context, input *model.SimDataUsageInput) ([]model.SimBaseRes[model.SimDataUsageItem], error) {
|
||||
iccid := input.SimKeys[0].ICCID
|
||||
switch iccid {
|
||||
case "retry":
|
||||
return nil, errors.New("temporary network failure")
|
||||
case "business":
|
||||
return []model.SimBaseRes[model.SimDataUsageItem]{{SimKey: &model.SimKey{ICCID: iccid}, Code: 11011, Message: "business failure"}}, nil
|
||||
default:
|
||||
return []model.SimBaseRes[model.SimDataUsageItem]{{SimKey: &model.SimKey{ICCID: iccid}, ResData: model.SimDataUsageItem{DataUsage: 12.5}}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthlyRunRetryConvergesAndArchives(t *testing.T) {
|
||||
redis := testutil.Redis(t)
|
||||
ctx := context.Background()
|
||||
store, err := zset.New(redis, "flow:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.EnableArchiveIndex(true)
|
||||
const runID = "monthly-retry-archive"
|
||||
if err := store.CreateRun(ctx, zset.RunMeta{RunID: runID, Operation: "current_month_usage", QueryMonth: "2026-09"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tasks := []zset.Task{
|
||||
{RunID: runID, TaskID: "retry", CardID: 1, ChanKey: "channel", TempKey: "sim_yd_v5", ICCID: "retry"},
|
||||
{RunID: runID, TaskID: "business", CardID: 2, ChanKey: "channel", TempKey: "sim_yd_v5", ICCID: "business"},
|
||||
{RunID: runID, TaskID: "success", CardID: 3, ChanKey: "channel", TempKey: "sim_yd_v5", ICCID: "success"},
|
||||
}
|
||||
if n, err := store.EnqueueRunBatch(ctx, runID, "channel", tasks, time.Now()); err != nil || n != len(tasks) {
|
||||
t.Fatalf("enqueue = %d, err = %v", n, err)
|
||||
}
|
||||
if err := store.MarkProducerDone(ctx, runID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &usageRepositoryStub{}
|
||||
handler, err := NewHandler(runUsageClient{}, repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
consumer, err := zsetconsumer.New(store, handler, zsetconsumer.Config{
|
||||
RunQueueEnabled: true,
|
||||
RoundRetryEnabled: true,
|
||||
MaxRounds: 2,
|
||||
ShouldRetryNextRound: func(result zsetconsumer.Result) bool {
|
||||
return !result.Success && result.Retryable
|
||||
},
|
||||
PollInterval: 500 * time.Millisecond,
|
||||
ClaimBatch: 3,
|
||||
TemplateQPS: map[string]float64{"sim_yd_v5": 1000000},
|
||||
LeaseTTL: time.Minute,
|
||||
TaskTimeout: 30 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- consumer.Run(runCtx) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
if err := <-done; err != nil {
|
||||
t.Errorf("consumer stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
var meta zset.RunMeta
|
||||
for time.Now().Before(deadline) {
|
||||
meta, err = store.GetRunMeta(ctx, runID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.NextRoundCount == 1 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if meta.CurrentRound != 1 || meta.ActiveCount != 0 || meta.InflightCount != 0 || meta.NextRoundCount != 1 || meta.SuccessCount != 1 || meta.FailedCount != 1 || meta.Status != "running" {
|
||||
t.Fatalf("first round did not defer only retryable failure: %+v", meta)
|
||||
}
|
||||
failedKey, _ := store.FailedKey(runID)
|
||||
if count, err := redis.Do(ctx, "HLEN", failedKey); err != nil || count.Int() != 1 {
|
||||
t.Fatalf("first round final failures = %v, err = %v", count, err)
|
||||
}
|
||||
for time.Now().Before(deadline) {
|
||||
meta, err = store.GetRunMeta(ctx, runID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.Status == "completed_with_failures" {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if meta.Status != "completed_with_failures" || meta.CurrentRound != 2 || meta.SuccessCount != 1 || meta.FailedCount != 2 || meta.ActiveCount != 0 || meta.NextRoundCount != 0 || meta.CompletedAt.IsZero() {
|
||||
t.Fatalf("run did not converge at max rounds: %+v", meta)
|
||||
}
|
||||
if len(repo.updates) != 1 || repo.updates[0].CardID != 3 {
|
||||
t.Fatalf("successful usage update changed: %+v", repo.updates)
|
||||
}
|
||||
failed, err := redis.Do(ctx, "HGET", failedKey, "retry")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var record zset.FailureRecord
|
||||
if err := json.Unmarshal([]byte(failed.String()), &record); err != nil || record.Round != 2 || record.FailureType != "network" {
|
||||
t.Fatalf("exhausted failure = %+v, err = %v", record, err)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
runtimeRepo, err := fullsyncruntime.New(store, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := runtimeRepo.ListArchiveWork(ctx, contract.PageRequest{Limit: 10})
|
||||
if err != nil || len(page.Items) != 1 || page.Items[0].RunID != runID {
|
||||
t.Fatalf("pending archive = %+v, err = %v", page, err)
|
||||
}
|
||||
service := fullsyncarchive.New(root, runtimeRepo, runtimeRepo, runtimeRepo)
|
||||
if err := service.RunOnce(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, "run-"+runID+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var archive contract.RunArchive
|
||||
if err := json.Unmarshal(data, &archive); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archive.Run.Status != contract.RunCompletedWithFailures || archive.Run.SuccessCount != 1 || archive.Run.FailedCount != 2 || len(archive.Rounds) != 2 {
|
||||
t.Fatalf("archived run summary = %+v", archive)
|
||||
}
|
||||
if first, last := archive.Rounds[0], archive.Rounds[1]; first.Round != 1 || first.EnqueuedCount != 3 || first.SuccessCount != 1 || first.FailedCount != 1 || first.NextRoundCount != 1 || last.Round != 2 || last.EnqueuedCount != 1 || last.FailedCount != 1 || last.NextRoundCount != 0 {
|
||||
t.Fatalf("archived round stats = %+v", archive.Rounds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Package monthlyusagehistory implements the zset.Handler for previous-month
|
||||
// usage history.
|
||||
//
|
||||
// It queries one card per task through the upstream gateway (the same path the
|
||||
// status sync uses), validates the returned period and identity, and writes the
|
||||
// usage to the TDengine monthly table.
|
||||
//
|
||||
// The failure classifier is intentionally separate from the monthly-usage one
|
||||
// and deliberately conservative: only errors that are known to be transient are
|
||||
// retried. An unknown carrier code is parked as a terminal business failure
|
||||
// instead of being retried, because a retry storm against a rate-limited carrier
|
||||
// is worse than a card that needs manual review (see the 2026-09-16 disk-full
|
||||
// incident). Like the monthly classifier, it never inspects the error text.
|
||||
package monthlyusagehistory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
zsetconsumer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consumer/zset"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
)
|
||||
|
||||
const (
|
||||
// errCodeTelecomChannelLimited is the carrier error code that means the
|
||||
// whole channel is throttled or disabled.
|
||||
errCodeTelecomChannelLimited = "S00004" // chanGatewayInternalError is the gateway code for transport, HTTP and
|
||||
// response-decoding failures. An empty errCode distinguishes those from a
|
||||
// carrier business result that happens to use the same number.
|
||||
chanGatewayInternalError = 9999
|
||||
// tokenExpiredCode is the carrier code for an expired upstream token. The
|
||||
// gateway refreshes the token once; a later round can then succeed.
|
||||
tokenExpiredCode = 12021
|
||||
|
||||
mobileBusyFirstCode = 13000
|
||||
mobileBusyLastCode = 13007
|
||||
mobileBusyClass = "mobile_busy"
|
||||
)
|
||||
|
||||
// mobileUsageTemplates identifies the China Mobile OneLink implementations.
|
||||
// Carrier identity is resolved from the channel template rather than from the
|
||||
// channel name or the error text, both of which are unstable.
|
||||
//
|
||||
// The set mirrors the OneLink registration table in the upstream proxy and is
|
||||
// kept in sync with the monthly-usage classifier
|
||||
// (service/monthlyusage/handler.go). Unknown templates stay "generic" and are
|
||||
// never classified as mobile.
|
||||
var mobileUsageTemplates = map[string]struct{}{
|
||||
"sim_yd_v5": {},
|
||||
"sim_yd_mock_v5": {},
|
||||
"sim_sdtywl_v1": {},
|
||||
"sim_onelink_tengyu_v1": {},
|
||||
"sim_onelink_xuzhou_mobile_v1": {},
|
||||
"sim_onelink_lelian_v1": {},
|
||||
"sim_onelink_qianxun_v1": {},
|
||||
"sim_onelink_henan_xinjing_v1": {},
|
||||
"sim_henan_xinjing_direct_v1": {},
|
||||
"sim_onelink_zibo_v1": {},
|
||||
"sim_tibiot_v1": {},
|
||||
"sim_qianxun_v3": {},
|
||||
"sim_yunda_v1": {},
|
||||
"sim_mingqian_v3": {},
|
||||
"sim_daihe_v1": {},
|
||||
"sim_onelink_daihe_v1": {},
|
||||
"sim_onelink_daliuliang_v1": {},
|
||||
"sim__daliuliang_v1": {},
|
||||
"sim_onelink_mobile_public_v1": {},
|
||||
"sim_onelink_kedang_v1": {},
|
||||
"sim_onelink_beixin_v1": {},
|
||||
"lanshang_8000": {},
|
||||
"lanshang_8003": {},
|
||||
}
|
||||
|
||||
// usageCarrierForTemplate resolves a channel template to its carrier.
|
||||
//
|
||||
// Telecom is detected through model.IsTelecomTemplate so every worker path
|
||||
// shares one definition (it covers both sim_dx_v1 and sim_dx_v3). Hard-coding
|
||||
// the v1 template here would silently classify a v3 channel as generic, which
|
||||
// in turn would drop its S00004 channel-blocking behaviour.
|
||||
func usageCarrierForTemplate(tempKey string) string {
|
||||
tempKey = strings.ToLower(strings.TrimSpace(tempKey))
|
||||
if model.IsTelecomTemplate(tempKey) {
|
||||
return "telecom"
|
||||
}
|
||||
if _, ok := mobileUsageTemplates[tempKey]; ok {
|
||||
return "mobile"
|
||||
}
|
||||
return "generic"
|
||||
}
|
||||
|
||||
// classifyMonthlyHistoryFailure maps one upstream item to the queue semantics owned
|
||||
// by this service.
|
||||
//
|
||||
// message is accepted only so callers can log it; it never influences the
|
||||
// decision, because text matching was the root cause of the 2026-09-14 channel
|
||||
// retry loop. The default is a non-retryable business failure: a wrong retry
|
||||
// multiplies carrier throttling, while a missing retry costs one manual replay.
|
||||
func classifyMonthlyHistoryFailure(tempKey string, code int, errCode string) (errorClass string, retryable, blockChannel bool) {
|
||||
carrier := usageCarrierForTemplate(tempKey)
|
||||
switch {
|
||||
case carrier == "telecom" && strings.EqualFold(strings.TrimSpace(errCode), errCodeTelecomChannelLimited):
|
||||
return "channel_limited", false, true
|
||||
case carrier == "mobile" && code >= mobileBusyFirstCode && code <= mobileBusyLastCode:
|
||||
return mobileBusyClass, true, false
|
||||
case code == tokenExpiredCode:
|
||||
return "token_expired", true, false
|
||||
case code == chanGatewayInternalError && strings.TrimSpace(errCode) == "":
|
||||
return "network", true, false
|
||||
default:
|
||||
return "business", false, false
|
||||
}
|
||||
}
|
||||
|
||||
// UsageClient is the upstream surface needed by the handler.
|
||||
type UsageClient interface {
|
||||
QuerySimDatausageMonthly(context.Context, *model.SimDataUsageMonthlyInput) ([]model.SimBaseRes[model.SimDataUsageMonthlyOutputItem], error)
|
||||
}
|
||||
|
||||
// UsageRepository persists the monthly usage history rows.
|
||||
type UsageRepository interface {
|
||||
UpdateMonthly(context.Context, []model.TrafficUsageMonthlyItem) error
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
client UsageClient
|
||||
repo UsageRepository
|
||||
}
|
||||
|
||||
func NewHandler(client UsageClient, repo UsageRepository) (*Handler, error) {
|
||||
if client == nil || repo == nil {
|
||||
return nil, fmt.Errorf("monthly history handler dependencies are incomplete")
|
||||
}
|
||||
return &Handler{client: client, repo: repo}, nil
|
||||
}
|
||||
|
||||
// Query performs one upstream monthly-usage request for a single card.
|
||||
//
|
||||
// task.QueryMonth carries the run's period identifier: for a monthly history run
|
||||
// that is yyyyMM, and it is sent upstream verbatim as queryDate.
|
||||
func (h *Handler) Query(ctx context.Context, task queue.Task) zsetconsumer.Result {
|
||||
result := zsetconsumer.Result{Task: task}
|
||||
queryMonth := strings.TrimSpace(task.QueryMonth)
|
||||
if queryMonth == "" {
|
||||
result.ErrorClass = "protocol"
|
||||
result.Message = "monthly history task is missing its query month"
|
||||
return result
|
||||
}
|
||||
output, err := h.client.QuerySimDatausageMonthly(ctx, &model.SimDataUsageMonthlyInput{
|
||||
SimKeys: []*model.SimKey{{
|
||||
ChanKey: task.ChanKey,
|
||||
ICCID: task.ICCID,
|
||||
IMSI: task.IMSI,
|
||||
MSISDN: task.MSISDN,
|
||||
}},
|
||||
ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: queryMonth},
|
||||
})
|
||||
if err != nil {
|
||||
result.ErrorClass = "network"
|
||||
result.Retryable = true
|
||||
result.Message = err.Error()
|
||||
return result
|
||||
}
|
||||
if len(output) == 0 || output[0].SimKey == nil {
|
||||
result.ErrorClass = "protocol"
|
||||
result.Retryable = true
|
||||
result.Message = "chan returned empty monthly usage result"
|
||||
return result
|
||||
}
|
||||
item := output[0]
|
||||
if task.ICCID != "" && item.SimKey.ICCID != task.ICCID {
|
||||
result.ErrorClass = "protocol"
|
||||
result.Retryable = true
|
||||
result.Message = "chan returned mismatched card identifier"
|
||||
return result
|
||||
}
|
||||
result.Code = item.Code
|
||||
result.Message = item.Message
|
||||
result.ErrCode = item.ErrCode
|
||||
if item.Code != 0 {
|
||||
result.ErrorClass, result.Retryable, result.BlockChannel = classifyMonthlyHistoryFailure(task.TempKey, item.Code, item.ErrCode)
|
||||
return result
|
||||
}
|
||||
// A successful code with a different (or missing) period would write the
|
||||
// wrong month, so it is treated as a protocol failure rather than silently
|
||||
// accepted. This mirrors the legacy admin reconcile requirement.
|
||||
if period, ok := normalizeMonthlyHistoryQueryMonth(item.ResData.DeadLine); !ok || period != queryMonth {
|
||||
result.ErrorClass = "protocol"
|
||||
result.Retryable = true
|
||||
result.Message = "chan returned mismatched monthly usage period"
|
||||
return result
|
||||
}
|
||||
result.Success = true
|
||||
result.UsageMB = item.ResData.DataAmount
|
||||
return result
|
||||
}
|
||||
|
||||
// Apply writes every successful result to the TDengine monthly table.
|
||||
//
|
||||
// The timestamp is derived from the run period rather than from the response, so
|
||||
// every card in a run lands on the same 23:59:59.000 row that the dashboard and
|
||||
// the diagnosis page read.
|
||||
func (h *Handler) Apply(ctx context.Context, results []zsetconsumer.Result) error {
|
||||
items := make([]model.TrafficUsageMonthlyItem, 0, len(results))
|
||||
for _, result := range results {
|
||||
if !result.Success {
|
||||
continue
|
||||
}
|
||||
month, ok := parseMonthlyHistoryQueryMonth(strings.TrimSpace(result.Task.QueryMonth))
|
||||
if !ok {
|
||||
return fmt.Errorf("monthly history task %s has an invalid period %q", result.Task.TaskID, result.Task.QueryMonth)
|
||||
}
|
||||
items = append(items, model.TrafficUsageMonthlyItem{
|
||||
TrafficUsageBase: model.TrafficUsageBase{
|
||||
Iccid: result.Task.ICCID,
|
||||
UsageMb: result.UsageMB,
|
||||
PackageCode: result.Task.PackageCode,
|
||||
},
|
||||
Month: month,
|
||||
})
|
||||
}
|
||||
return h.repo.UpdateMonthly(ctx, items)
|
||||
}
|
||||
|
||||
// ShouldRetryNextRound is the monthly-history retry policy. It is passed to the
|
||||
// run-aware ZSet consumer only; it keeps the classifier's verdict authoritative.
|
||||
func ShouldRetryNextRound(result zsetconsumer.Result) bool {
|
||||
return !result.Success && result.Retryable
|
||||
}
|
||||
|
||||
var monthlyHistoryLocation = time.FixedZone("UTC+8", 8*60*60)
|
||||
|
||||
// normalizeMonthlyHistoryQueryMonth canonicalizes an upstream period to yyyyMM
|
||||
// and reports whether the value was interpretable at all.
|
||||
//
|
||||
// The monthly usage endpoint answers with DeadLine, which may be a month, a
|
||||
// month-first date, or a full date; all of those collapse to the same yyyyMM so
|
||||
// the run period can be compared against the response.
|
||||
func normalizeMonthlyHistoryQueryMonth(value string) (string, bool) {
|
||||
for _, layout := range []string{"200601", "2006-01", "20060102", "2006-01-02"} {
|
||||
parsed, err := time.ParseInLocation(layout, strings.TrimSpace(value), monthlyHistoryLocation)
|
||||
if err == nil {
|
||||
return parsed.Format("200601"), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// parseMonthlyHistoryQueryMonth parses a run period. The month-first day is
|
||||
// used because the repository derives the end-of-month timestamp from it.
|
||||
func parseMonthlyHistoryQueryMonth(value string) (time.Time, bool) {
|
||||
parsed, err := time.ParseInLocation("200601", value, monthlyHistoryLocation)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package monthlyusagehistory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
zsetconsumer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consumer/zset"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
)
|
||||
|
||||
type fakeClient struct {
|
||||
output []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]
|
||||
err error
|
||||
// gotQueryDate records the period the handler sent upstream so a test can
|
||||
// prove the task period is forwarded verbatim.
|
||||
gotQueryDate string
|
||||
}
|
||||
|
||||
func (c *fakeClient) QuerySimDatausageMonthly(_ context.Context, input *model.SimDataUsageMonthlyInput) ([]model.SimBaseRes[model.SimDataUsageMonthlyOutputItem], error) {
|
||||
c.gotQueryDate = input.ReqData.QueryDate
|
||||
return c.output, c.err
|
||||
}
|
||||
|
||||
type fakeRepo struct {
|
||||
items []model.TrafficUsageMonthlyItem
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpdateMonthly(_ context.Context, items []model.TrafficUsageMonthlyItem) error {
|
||||
r.items = append(r.items, items...)
|
||||
return r.err
|
||||
}
|
||||
|
||||
func monthlyTask() queue.Task {
|
||||
return queue.Task{
|
||||
TaskID: "run:op:42",
|
||||
RunID: "run",
|
||||
Operation: model.OperationPreviousDayUsageFullSync,
|
||||
QueryMonth: "202609",
|
||||
ChanKey: "217",
|
||||
TempKey: "sim_yd_v5",
|
||||
CardID: 42,
|
||||
ICCID: "89860042",
|
||||
PackageCode: "PKG-A",
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryForwardsTaskMonthUpstream(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ICCID: "89860042"},
|
||||
ResData: model.SimDataUsageMonthlyOutputItem{
|
||||
DeadLine: "202609",
|
||||
DataAmount: 12.5,
|
||||
},
|
||||
}}}
|
||||
handler, err := NewHandler(client, &fakeRepo{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := handler.Query(context.Background(), monthlyTask())
|
||||
if !result.Success {
|
||||
t.Fatalf("expected success, got class=%s message=%s", result.ErrorClass, result.Message)
|
||||
}
|
||||
if client.gotQueryDate != "202609" {
|
||||
t.Fatalf("upstream queryDate = %q, want the task period 202609", client.gotQueryDate)
|
||||
}
|
||||
if result.UsageMB != 12.5 {
|
||||
t.Fatalf("usage = %v, want 12.5", result.UsageMB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAcceptsEquivalentPeriodFormats(t *testing.T) {
|
||||
for _, period := range []string{"202609", "2026-09", "20260901", "2026-09-01"} {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ICCID: "89860042"},
|
||||
ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: period, DataAmount: 1},
|
||||
}}}
|
||||
handler, _ := NewHandler(client, &fakeRepo{})
|
||||
if result := handler.Query(context.Background(), monthlyTask()); !result.Success {
|
||||
t.Fatalf("period %q was rejected: class=%s message=%s", period, result.ErrorClass, result.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRejectsMismatchedMonth(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ICCID: "89860042"},
|
||||
ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: "202608", DataAmount: 1},
|
||||
}}}
|
||||
handler, _ := NewHandler(client, &fakeRepo{})
|
||||
result := handler.Query(context.Background(), monthlyTask())
|
||||
if result.Success {
|
||||
t.Fatal("a mismatched period must not be accepted")
|
||||
}
|
||||
if result.ErrorClass != "protocol" || !result.Retryable {
|
||||
t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRejectsMissingMonthEcho(t *testing.T) {
|
||||
// A channel that omits DeadLine from a successful response must fail too:
|
||||
// the task period would be unverifiable, so writing the row risks
|
||||
// wrong-month data. This mirrors the admin reconcile requirement.
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ICCID: "89860042"},
|
||||
ResData: model.SimDataUsageMonthlyOutputItem{DataAmount: 1},
|
||||
}}}
|
||||
handler, _ := NewHandler(client, &fakeRepo{})
|
||||
result := handler.Query(context.Background(), monthlyTask())
|
||||
if result.Success {
|
||||
t.Fatal("a missing period echo must not be accepted")
|
||||
}
|
||||
if result.ErrorClass != "protocol" || !result.Retryable {
|
||||
t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRejectsMismatchedICCID(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ICCID: "89869999"},
|
||||
ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: "202609"},
|
||||
}}}
|
||||
handler, _ := NewHandler(client, &fakeRepo{})
|
||||
result := handler.Query(context.Background(), monthlyTask())
|
||||
if result.Success || result.ErrorClass != "protocol" || !result.Retryable {
|
||||
t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRejectsEmptyResult(t *testing.T) {
|
||||
handler, _ := NewHandler(&fakeClient{}, &fakeRepo{})
|
||||
result := handler.Query(context.Background(), monthlyTask())
|
||||
if result.Success || result.ErrorClass != "protocol" || !result.Retryable {
|
||||
t.Fatalf("class=%s retryable=%v, want protocol/retryable", result.ErrorClass, result.Retryable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTreatsTransportErrorAsRetryableNetwork(t *testing.T) {
|
||||
handler, _ := NewHandler(&fakeClient{err: errors.New("connection refused")}, &fakeRepo{})
|
||||
result := handler.Query(context.Background(), monthlyTask())
|
||||
if result.ErrorClass != "network" || !result.Retryable {
|
||||
t.Fatalf("class=%s retryable=%v, want network/retryable", result.ErrorClass, result.Retryable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRejectsTaskWithoutMonth(t *testing.T) {
|
||||
handler, _ := NewHandler(&fakeClient{}, &fakeRepo{})
|
||||
task := monthlyTask()
|
||||
task.QueryMonth = ""
|
||||
result := handler.Query(context.Background(), task)
|
||||
if result.Success || result.Retryable {
|
||||
t.Fatalf("a task without a period must fail without retrying: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWritesPackageCodeAndEndOfMonthTimestamp(t *testing.T) {
|
||||
repo := &fakeRepo{}
|
||||
handler, _ := NewHandler(&fakeClient{}, repo)
|
||||
err := handler.Apply(context.Background(), []zsetconsumer.Result{
|
||||
{Task: monthlyTask(), Success: true, UsageMB: 7.25},
|
||||
{Task: monthlyTask(), Success: false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.items) != 1 {
|
||||
t.Fatalf("wrote %d items, want only the successful one", len(repo.items))
|
||||
}
|
||||
item := repo.items[0]
|
||||
if item.Iccid != "89860042" || item.UsageMb != 7.25 || item.PackageCode != "PKG-A" {
|
||||
t.Fatalf("item = %+v, want iccid/usage/packageCode carried from the task", item)
|
||||
}
|
||||
// The handler supplies the month; repository.snapshotMonth turns it into the
|
||||
// end-of-month 23:59:59.000 row (covered by repository_test.go). Assert the
|
||||
// month here so a period mix-up cannot slip through.
|
||||
if got := item.Month.Format("2006-01"); got != "2026-09" {
|
||||
t.Fatalf("month = %s, want the run period 2026-09", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRejectsInvalidMonth(t *testing.T) {
|
||||
handler, _ := NewHandler(&fakeClient{}, &fakeRepo{})
|
||||
task := monthlyTask()
|
||||
task.QueryMonth = "2026-10-09"
|
||||
if err := handler.Apply(context.Background(), []zsetconsumer.Result{{Task: task, Success: true}}); err == nil {
|
||||
t.Fatal("expected an unparseable period to fail the write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMonthlyHistoryFailure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tempKey string
|
||||
code int
|
||||
errCode string
|
||||
wantClass string
|
||||
wantRetry bool
|
||||
wantBlocked bool
|
||||
}{
|
||||
{name: "telecom channel limited", tempKey: "sim_dx_v1", code: 500, errCode: "S00004", wantClass: "channel_limited", wantBlocked: true},
|
||||
{name: "telecom channel limited lowercase", tempKey: "sim_dx_v1", code: 500, errCode: "s00004", wantClass: "channel_limited", wantBlocked: true},
|
||||
// sim_dx_v3 is a telecom template too. It is resolved through
|
||||
// model.IsTelecomTemplate so a v3 channel keeps S00004 blocking instead
|
||||
// of falling through to generic and retrying every card to exhaustion.
|
||||
{name: "telecom v3 channel limited", tempKey: "sim_dx_v3", code: 500, errCode: "S00004", wantClass: "channel_limited", wantBlocked: true},
|
||||
{name: "telecom v3 template is not treated as mobile", tempKey: "sim_dx_v3", code: 13000, wantClass: "business"},
|
||||
{name: "mobile busy first code", tempKey: "sim_yd_v5", code: 13000, wantClass: "mobile_busy", wantRetry: true},
|
||||
{name: "mobile busy last code", tempKey: "sim_yd_v5", code: 13007, wantClass: "mobile_busy", wantRetry: true},
|
||||
{name: "mobile one past the busy range", tempKey: "sim_yd_v5", code: 13008, wantClass: "business"},
|
||||
{name: "mobile one below the busy range", tempKey: "sim_yd_v5", code: 12999, wantClass: "business"},
|
||||
{name: "busy code on a non-mobile template", tempKey: "sim_qianxun_v3_bogus", code: 13000, wantClass: "business"},
|
||||
{name: "token expired", tempKey: "sim_yd_v5", code: 12021, wantClass: "token_expired", wantRetry: true},
|
||||
{name: "gateway internal without errCode", tempKey: "sim_yd_v5", code: 9999, errCode: "", wantClass: "network", wantRetry: true},
|
||||
{name: "gateway code with a carrier errCode stays business", tempKey: "sim_yd_v5", code: 9999, errCode: "E42", wantClass: "business"},
|
||||
{name: "telecom S00004 without the telecom template is not channel limited", tempKey: "sim_yd_v5", code: 500, errCode: "S00004", wantClass: "business"},
|
||||
{name: "unknown code is not retried", tempKey: "sim_yd_v5", code: 4242, wantClass: "business"},
|
||||
{name: "unknown code on an unknown template is not retried", tempKey: "some_new_channel_v9", code: 13000, wantClass: "business"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
class, retry, blocked := classifyMonthlyHistoryFailure(test.tempKey, test.code, test.errCode)
|
||||
if class != test.wantClass || retry != test.wantRetry || blocked != test.wantBlocked {
|
||||
t.Fatalf("classify(%q, %d, %q) = (%s, %v, %v), want (%s, %v, %v)",
|
||||
test.tempKey, test.code, test.errCode, class, retry, blocked, test.wantClass, test.wantRetry, test.wantBlocked)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyIgnoresMessageText pins the 2026-09-14 discipline: classification
|
||||
// must never depend on carrier text, because text matching caused the channel
|
||||
// retry loop that the rule was introduced to prevent.
|
||||
func TestClassifyIgnoresMessageText(t *testing.T) {
|
||||
// A "busy"-looking message on an unknown code must stay non-retryable.
|
||||
if _, retry, _ := classifyMonthlyHistoryFailure("sim_yd_v5", 4242, ""); retry {
|
||||
t.Fatal("an unknown code must not become retryable because its message looked transient")
|
||||
}
|
||||
// And a busy code must stay retryable regardless of what the text says.
|
||||
if _, retry, _ := classifyMonthlyHistoryFailure("sim_yd_v5", 13000, ""); !retry {
|
||||
t.Fatal("a mobile busy code must stay retryable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRetryNextRoundFollowsClassifier(t *testing.T) {
|
||||
if ShouldRetryNextRound(zsetconsumer.Result{Success: true}) {
|
||||
t.Fatal("a successful result must not be retried")
|
||||
}
|
||||
if !ShouldRetryNextRound(zsetconsumer.Result{Retryable: true}) {
|
||||
t.Fatal("a retryable failure must be deferred to the next round")
|
||||
}
|
||||
if ShouldRetryNextRound(zsetconsumer.Result{Retryable: false}) {
|
||||
t.Fatal("a terminal failure must not be retried")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageCarrierForTemplateMatchesSharedDefinition(t *testing.T) {
|
||||
// The monthly-history classifier must agree with model.IsTelecomTemplate for every
|
||||
// telecom template the rest of the worker recognises. A drift here means a
|
||||
// channel silently loses its S00004 blocking behaviour.
|
||||
for _, tempKey := range []string{"sim_dx_v1", "sim_dx_v3", "SIM_DX_V1", " sim_dx_v3 "} {
|
||||
if got := usageCarrierForTemplate(tempKey); got != "telecom" {
|
||||
t.Errorf("usageCarrierForTemplate(%q) = %q, want telecom", tempKey, got)
|
||||
}
|
||||
}
|
||||
for _, tempKey := range []string{"sim_yd_v5", "sim_onelink_daihe_v1"} {
|
||||
if got := usageCarrierForTemplate(tempKey); got != "mobile" {
|
||||
t.Errorf("usageCarrierForTemplate(%q) = %q, want mobile", tempKey, got)
|
||||
}
|
||||
}
|
||||
for _, tempKey := range []string{"", "some_new_channel_v9", "sim_dx_v2"} {
|
||||
if got := usageCarrierForTemplate(tempKey); got != "generic" {
|
||||
t.Errorf("usageCarrierForTemplate(%q) = %q, want generic", tempKey, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBlockChannelPropagatesForTelecomS00004(t *testing.T) {
|
||||
client := &fakeClient{output: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{{
|
||||
Code: 500,
|
||||
ErrCode: "S00004",
|
||||
Message: "channel disabled",
|
||||
SimKey: &model.SimKey{ICCID: "89860042"},
|
||||
}}}
|
||||
handler, _ := NewHandler(client, &fakeRepo{})
|
||||
task := monthlyTask()
|
||||
task.TempKey = "sim_dx_v1"
|
||||
result := handler.Query(context.Background(), task)
|
||||
if result.ErrorClass != "channel_limited" || !result.BlockChannel || result.Retryable {
|
||||
t.Fatalf("telecom S00004 must block the channel without retrying: %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package packagecustomer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
mq "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
func PublishSync(ctx context.Context, message model.PackageCustomerSyncMessage) error {
|
||||
message.TenantId = strings.TrimSpace(message.TenantId)
|
||||
message.Trigger = strings.TrimSpace(message.Trigger)
|
||||
message.SourceBatchNo = strings.TrimSpace(message.SourceBatchNo)
|
||||
if message.TenantId == "" {
|
||||
return gerror.New("package customer sync tenantId is empty")
|
||||
}
|
||||
if message.DeptId <= 0 {
|
||||
return gerror.New("package customer sync deptId is empty")
|
||||
}
|
||||
if message.Trigger == "" {
|
||||
message.Trigger = "worker"
|
||||
}
|
||||
if err := mq.Publish(ctx, consts.QueuePackageCustomerSync, message); err != nil {
|
||||
return gerror.Wrapf(err, "publish package customer sync failed, queue:%s", consts.QueuePackageCustomerSync)
|
||||
}
|
||||
g.Log().Infof(ctx, "package customer sync published, tenantId:%s, deptId:%d, trigger:%s, sourceBatchNo:%s",
|
||||
message.TenantId, message.DeptId, message.Trigger, message.SourceBatchNo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func PublishSyncSafe(ctx context.Context, message model.PackageCustomerSyncMessage) {
|
||||
if err := PublishSync(ctx, message); err != nil {
|
||||
g.Log().Warningf(ctx, "package customer sync publish skipped, tenantId:%s, deptId:%d, trigger:%s, sourceBatchNo:%s, err:%+v",
|
||||
message.TenantId, message.DeptId, message.Trigger, message.SourceBatchNo, err)
|
||||
}
|
||||
}
|
||||
|
||||
func PublishSyncForDeptsSafe(ctx context.Context, tenantId string, userId int64, deptIds []int64, trigger, sourceBatchNo string) {
|
||||
seen := make(map[int64]struct{}, len(deptIds))
|
||||
for _, deptId := range deptIds {
|
||||
if deptId <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[deptId]; ok {
|
||||
continue
|
||||
}
|
||||
seen[deptId] = struct{}{}
|
||||
PublishSyncSafe(ctx, model.PackageCustomerSyncMessage{
|
||||
TenantId: tenantId,
|
||||
DeptId: deptId,
|
||||
UserId: userId,
|
||||
Trigger: trigger,
|
||||
SourceBatchNo: sourceBatchNo,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package packagecustomer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
sysDeptTable = "sys_dept"
|
||||
cardInfoTable = "t_sim_card_info"
|
||||
flowPackageTable = "t_sim_flow_package_info"
|
||||
packageCustomerTable = "t_package_customer"
|
||||
)
|
||||
|
||||
var ErrDeptNotFound = gerror.New("package customer sync dept not found")
|
||||
|
||||
type Service struct {
|
||||
db gdb.DB
|
||||
}
|
||||
|
||||
type deptRow struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
ParentId int64 `orm:"parent_id"`
|
||||
Ancestors string `orm:"ancestors"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
}
|
||||
|
||||
type packageRow struct {
|
||||
PackageCode string `orm:"package_code"`
|
||||
PackageName string `orm:"package_name"`
|
||||
}
|
||||
|
||||
func New() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) SyncDeptPackages(ctx context.Context, message model.PackageCustomerSyncMessage) error {
|
||||
message.TenantId = strings.TrimSpace(message.TenantId)
|
||||
message.Trigger = strings.TrimSpace(message.Trigger)
|
||||
if message.TenantId == "" {
|
||||
return gerror.New("package customer sync tenantId is empty")
|
||||
}
|
||||
if message.DeptId <= 0 {
|
||||
return gerror.New("package customer sync deptId is empty")
|
||||
}
|
||||
var packageCount int
|
||||
err := s.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
dept, err := s.lockDept(ctx, tx, message.TenantId, message.DeptId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
packages, err := s.loadPackages(ctx, tx, message.TenantId, dept)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
packageCount = len(packages)
|
||||
return s.rebuild(ctx, tx, message.TenantId, dept, message.UserId, packages)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.Log().Infof(ctx, "package customer sync finished, tenantId:%s, deptId:%d, packages:%d, trigger:%s, sourceBatchNo:%s",
|
||||
message.TenantId, message.DeptId, packageCount, message.Trigger, message.SourceBatchNo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) database() gdb.DB {
|
||||
if s.db != nil {
|
||||
return s.db
|
||||
}
|
||||
return g.DB()
|
||||
}
|
||||
|
||||
func (s *Service) lockDept(ctx context.Context, tx gdb.TX, tenantId string, deptId int64) (*deptRow, error) {
|
||||
var dept *deptRow
|
||||
err := tx.Ctx(ctx).Model(sysDeptTable).Safe().
|
||||
Fields("dept_id", "parent_id", "ancestors", "dept_name").
|
||||
Where("tenant_id", tenantId).
|
||||
Where("dept_id", deptId).
|
||||
WhereNull("deleted_at").
|
||||
LockUpdate().
|
||||
Limit(1).
|
||||
Scan(&dept)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "load package customer sync dept failed")
|
||||
}
|
||||
if dept == nil || dept.DeptId <= 0 {
|
||||
return nil, ErrDeptNotFound
|
||||
}
|
||||
return dept, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadPackages(ctx context.Context, tx gdb.TX, tenantId string, dept *deptRow) ([]packageRow, error) {
|
||||
if deptIsTop(dept) {
|
||||
var packages []packageRow
|
||||
err := tx.Ctx(ctx).Model(flowPackageTable).Safe().
|
||||
Fields("package_code", "package_name").
|
||||
Where("tenant_id", tenantId).
|
||||
Scan(&packages)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "load top dept packages failed")
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
var packages []packageRow
|
||||
err := tx.Ctx(ctx).Model(cardInfoTable+" c").Safe().
|
||||
LeftJoin(flowPackageTable+" p", "c.current_package_code = p.package_code").
|
||||
Fields("DISTINCT c.current_package_code AS package_code", "p.package_name AS package_name").
|
||||
Where("c.tenant_id", tenantId).
|
||||
Where("c.dept_id", dept.DeptId).
|
||||
Where("p.tenant_id", tenantId).
|
||||
Where("c.current_package_code IS NOT NULL").
|
||||
Where("c.current_package_code <> ?", "").
|
||||
Where("p.package_code IS NOT NULL").
|
||||
Scan(&packages)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "load dept packages failed")
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
func (s *Service) rebuild(ctx context.Context, tx gdb.TX, tenantId string, dept *deptRow, userId int64, packages []packageRow) error {
|
||||
if _, err := tx.Ctx(ctx).Model(packageCustomerTable).Safe().
|
||||
Where("tenant_id", tenantId).
|
||||
Where("dept_id", dept.DeptId).
|
||||
Delete(); err != nil {
|
||||
return gerror.Wrap(err, "clear dept package customer relation failed")
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
rows := make([]g.Map, 0, len(packages))
|
||||
seen := make(map[string]struct{}, len(packages))
|
||||
for _, item := range packages {
|
||||
packageCode := strings.TrimSpace(item.PackageCode)
|
||||
if packageCode == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[packageCode]; ok {
|
||||
continue
|
||||
}
|
||||
seen[packageCode] = struct{}{}
|
||||
rows = append(rows, g.Map{
|
||||
"package_code": packageCode,
|
||||
"package_name": strings.TrimSpace(item.PackageName),
|
||||
"dept_id": dept.DeptId,
|
||||
"dept_name": strings.TrimSpace(dept.DeptName),
|
||||
"tenant_id": tenantId,
|
||||
"created_dept": dept.DeptId,
|
||||
"created_by": userId,
|
||||
"created_at": now,
|
||||
"updated_by": userId,
|
||||
"updated_at": now,
|
||||
})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(packageCustomerTable).Safe().Data(rows).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "insert dept package customer relation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deptIsTop(dept *deptRow) bool {
|
||||
if dept == nil {
|
||||
return false
|
||||
}
|
||||
return dept.ParentId == 0 || strings.Trim(dept.Ancestors, ",") == "0"
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/packagecustomer"
|
||||
)
|
||||
|
||||
// ChangeExecutor executes a confirmed batch_type=102 change as one business
|
||||
// transaction. The transaction includes old subscription cleanup, the new
|
||||
// order/subscriptions, card reset, detail success, and batch success.
|
||||
type ChangeExecutor struct {
|
||||
repo *Repository
|
||||
nowFunc NowFunc
|
||||
}
|
||||
|
||||
func NewChangeExecutor(repo *Repository) *ChangeExecutor {
|
||||
if repo == nil {
|
||||
repo = NewRepository()
|
||||
}
|
||||
return &ChangeExecutor{repo: repo, nowFunc: timeNow}
|
||||
}
|
||||
|
||||
func (c *ChangeExecutor) SetNowFunc(now NowFunc) { c.nowFunc = now }
|
||||
|
||||
func (c *ChangeExecutor) Execute(ctx context.Context, batchNo string) error {
|
||||
batchNo = strings.TrimSpace(batchNo)
|
||||
if batchNo == "" {
|
||||
return gerror.New("change batch number is empty")
|
||||
}
|
||||
main, err := c.repo.LoadProcessingBatch(ctx, batchNo, consts.BatchTypeChange)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
details, err := c.repo.LoadRunningDetails(ctx, batchNo, consts.BatchTypeChange)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load change details failed")
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return c.finalizeFailure(ctx, main, "change batch has no runnable details")
|
||||
}
|
||||
contract, err := ParseExecutionContract(main)
|
||||
if err != nil {
|
||||
return c.finalizeFailure(ctx, main, err.Error())
|
||||
}
|
||||
if contract.BatchType != consts.BatchTypeChange {
|
||||
return c.finalizeFailure(ctx, main, "change execution contract has invalid batch type")
|
||||
}
|
||||
|
||||
now := pnow(c.nowFunc)
|
||||
orderSN, skipped, err := c.apply(ctx, main, contract, details, now)
|
||||
if err != nil {
|
||||
return c.finalizeFailure(ctx, main, err.Error())
|
||||
}
|
||||
if skipped {
|
||||
return nil
|
||||
}
|
||||
packagecustomer.PublishSyncForDeptsSafe(ctx, main.TenantId, main.CreatedBy, []int64{contract.CustomerAgentID}, "change", main.BatchNo)
|
||||
|
||||
rows, snapshotErr := c.repo.ResolveResultSnapshot(ctx, main.BatchNo, main.BatchType)
|
||||
if snapshotErr == nil {
|
||||
_ = c.repo.WriteResultFileSafe(ctx, main.BatchNo, main.BatchType, main.TenantId, main.CreatedDept, main.CreatedBy, rows)
|
||||
}
|
||||
g.Log().Infof(ctx, "change batch success, batchNo:%s, orderSn:%s, tenant:%s", main.BatchNo, orderSN, main.TenantId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// apply is deliberately the only success transaction. A duplicate delivery
|
||||
// that reaches this method after a completed transaction is safely ignored.
|
||||
func (c *ChangeExecutor) apply(ctx context.Context, main *rc.BatchMainRow, contract *ExecutionContract, details []*rc.BatchDetailRow, now time.Time) (orderSN string, skipped bool, err error) {
|
||||
iccids := uniqueIccidsFromDetails(details)
|
||||
if len(iccids) != len(details) {
|
||||
return "", false, gerror.New("change batch contains duplicate or empty ICCID")
|
||||
}
|
||||
if contract.Quantity != len(iccids) {
|
||||
return "", false, gerror.Newf("change contract quantity mismatch: contract=%d actual=%d", contract.Quantity, len(iccids))
|
||||
}
|
||||
|
||||
err = c.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
lockedMain, lockErr := c.repo.Tables().LockChangeBatch(ctx, tx, main.BatchNo)
|
||||
if lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
if lockedMain.BatchAsyncStatus != consts.BatchAsyncStatusProcessing {
|
||||
skipped = true
|
||||
return nil
|
||||
}
|
||||
lockedDetails, lockErr := c.repo.Tables().LockChangeDetails(ctx, tx, main.BatchNo)
|
||||
if lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
if err = validateRunningChangeDetails(lockedDetails, details); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cards, lockErr := c.repo.Tables().LockCardsByIccids(ctx, tx, iccids)
|
||||
if lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
if len(cards) != len(iccids) {
|
||||
return gerror.New("some change cards no longer exist")
|
||||
}
|
||||
bindRows, lockErr := c.repo.Tables().LockChangeBinds(ctx, tx, iccids)
|
||||
if lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
currentBinds, futureBinds, lockErr := resolveCurrentSubscriptions(bindRows, iccids)
|
||||
if lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
|
||||
oldPackageCode := readChangeOldPackageCode(main.BatchInfo)
|
||||
if oldPackageCode == "" {
|
||||
return gerror.New("change contract old package is missing")
|
||||
}
|
||||
newPackage, loadErr := c.repo.Tables().LoadChangePackage(ctx, tx, contract.TenantID, contract.PackageCode)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
oldPackage, loadErr := c.repo.Tables().LoadChangePackage(ctx, tx, contract.TenantID, oldPackageCode)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if newPackage.Enabled != 1 || oldPackage.Enabled != 1 {
|
||||
return gerror.New("change package is disabled")
|
||||
}
|
||||
if newPackage.PackageType != oldPackage.PackageType {
|
||||
return gerror.New("new package type differs from current package")
|
||||
}
|
||||
if !containsPackageCode(oldPackage.ChangeablePackageCodes, contract.PackageCode) {
|
||||
return gerror.New("new package is not in current package changeable range")
|
||||
}
|
||||
durationOptions, durationErr := changeDurationOptions(newPackage.RenewableDurationConfig)
|
||||
if durationErr != nil {
|
||||
return gerror.Wrap(durationErr, "load target package change duration options failed")
|
||||
}
|
||||
if !rc.ContainsInt(durationOptions, contract.DurationMonths) {
|
||||
return gerror.Newf("change duration %d months is not allowed for target package", contract.DurationMonths)
|
||||
}
|
||||
customer, loadErr := c.repo.Tables().LoadChangeDept(ctx, tx, contract.TenantID, contract.CustomerAgentID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if customer.DeptId != contract.SellerAgentID && customer.ParentId != contract.SellerAgentID {
|
||||
return gerror.New("change customer is outside the operator scope")
|
||||
}
|
||||
|
||||
sourceBatchNo := contract.SourceBatchNo
|
||||
for _, iccid := range iccids {
|
||||
card := cards[iccid]
|
||||
current := currentBinds[iccid]
|
||||
var expectedMSISDN, expectedIMSI string
|
||||
if detail := findDetailByICCID(lockedDetails, iccid); detail != nil && detail.Snapshot != nil {
|
||||
expectedMSISDN = strings.TrimSpace(detail.Snapshot.Get("msisdn").String())
|
||||
expectedIMSI = strings.TrimSpace(detail.Snapshot.Get("imsi").String())
|
||||
}
|
||||
if err = validateChangeCard(card, current, contract, iccid, oldPackageCode, sourceBatchNo, expectedMSISDN, expectedIMSI); err != nil {
|
||||
return gerror.Wrapf(err, "validate change card failed, iccid:%s", iccid)
|
||||
}
|
||||
for _, future := range futureBinds[iccid] {
|
||||
if future == nil || future.TenantId != contract.TenantID || future.DeptId != contract.CustomerAgentID {
|
||||
return gerror.Newf("future change subscription is inconsistent: %s", iccid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentList := make([]*rc.SimCardBindPkg, 0, len(iccids))
|
||||
currentOrderCodes := make([]string, 0, len(iccids))
|
||||
currentOrderQuantities := make(map[string]int)
|
||||
for _, iccid := range iccids {
|
||||
currentList = append(currentList, currentBinds[iccid])
|
||||
if orderCode := strings.TrimSpace(currentBinds[iccid].OrderCode); orderCode != "" {
|
||||
currentOrderCodes = append(currentOrderCodes, orderCode)
|
||||
currentOrderQuantities[orderCode]++
|
||||
}
|
||||
}
|
||||
if err = c.repo.Tables().ArchiveChangeBinds(ctx, tx, currentList, consts.EffectiveStatusTerminated, gtime.NewFromTime(now), main.BatchNo, contract.OperatorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
currentSubscriptionNos := subscriptionNos(currentList)
|
||||
if affected, deleteErr := c.repo.Tables().DeleteChangeBinds(ctx, tx, currentSubscriptionNos); deleteErr != nil {
|
||||
return deleteErr
|
||||
} else if int(affected) != len(currentSubscriptionNos) {
|
||||
return gerror.New("current change subscriptions changed concurrently")
|
||||
}
|
||||
if err = c.repo.Tables().UpdateChangeOrdersAfterRemoval(ctx, tx, currentOrderCodes, main.BatchNo, "lastUnsubscribe", currentOrderQuantities, gtime.NewFromTime(now), contract.OperatorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
futureList := flattenFutureBinds(futureBinds, iccids)
|
||||
futureOrderCodes := make([]string, 0, len(futureList))
|
||||
futureOrderQuantities := make(map[string]int)
|
||||
for _, bind := range futureList {
|
||||
if orderCode := strings.TrimSpace(bind.OrderCode); orderCode != "" {
|
||||
futureOrderCodes = append(futureOrderCodes, orderCode)
|
||||
futureOrderQuantities[orderCode]++
|
||||
}
|
||||
}
|
||||
if err = c.repo.Tables().ArchiveChangeBinds(ctx, tx, futureList, consts.EffectiveStatusVoided, gtime.NewFromTime(now), main.BatchNo, contract.OperatorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
futureSubscriptionNos := subscriptionNos(futureList)
|
||||
if affected, deleteErr := c.repo.Tables().DeleteChangeBinds(ctx, tx, futureSubscriptionNos); deleteErr != nil {
|
||||
return deleteErr
|
||||
} else if int(affected) != len(futureSubscriptionNos) {
|
||||
return gerror.New("future change subscriptions changed concurrently")
|
||||
}
|
||||
if err = c.repo.Tables().UpdateChangeOrdersAfterRemoval(ctx, tx, futureOrderCodes, main.BatchNo, "lastChange", futureOrderQuantities, gtime.NewFromTime(now), contract.OperatorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
beginAt, resolveErr := resolveChangeBeginAt(contract, now)
|
||||
if resolveErr != nil {
|
||||
return resolveErr
|
||||
}
|
||||
endAt := contract.ServiceEnd(beginAt)
|
||||
packageStatus := calculateChangePackageStatus(now, beginAt, endAt)
|
||||
effectiveStatus := calculateChangeEffectiveStatus(now, beginAt, endAt)
|
||||
orderSN, err = rc.NextOrderSN(now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
order, buildErr := BuildOrderDO(contract, orderSN, now)
|
||||
if buildErr != nil {
|
||||
return buildErr
|
||||
}
|
||||
orderID, insertErr := c.repo.Tables().InsertContractOrder(ctx, tx, order)
|
||||
if insertErr != nil {
|
||||
return gerror.Wrap(insertErr, "insert change order failed")
|
||||
}
|
||||
item, buildErr := BuildOrderItemDO(contract, OrderProductContract{ID: newPackage.Id, Code: newPackage.PackageCode, Name: newPackage.PackageName, Type: newPackage.PackageType}, orderID, orderSN, beginAt, endAt, now)
|
||||
if buildErr != nil {
|
||||
return buildErr
|
||||
}
|
||||
if err = c.repo.Tables().InsertContractOrderItem(ctx, tx, item); err != nil {
|
||||
return gerror.Wrap(err, "insert change order item failed")
|
||||
}
|
||||
|
||||
snapshots := make(map[int]*gjson.Json, len(lockedDetails))
|
||||
for _, detail := range lockedDetails {
|
||||
iccid := strings.TrimSpace(detail.ItemId)
|
||||
card := cards[iccid]
|
||||
newSubscriptionNo := rc.GenerateSubscriptionNo("SUB")
|
||||
newBind := buildChangeBind(contract, newPackage, card, main.BatchNo, oldPackageCode, orderSN, newSubscriptionNo, effectiveStatus, beginAt, endAt, now)
|
||||
if err = c.repo.Tables().InsertBind(ctx, tx, newBind); err != nil {
|
||||
return gerror.Wrap(err, "insert change subscription failed")
|
||||
}
|
||||
if err = c.repo.Tables().UpdateChangeCard(ctx, tx, iccid, newPackage.PackageCode, packageStatus, newPackage.FlowMb, gtime.NewFromTime(beginAt), gtime.NewFromTime(endAt), contract.OperatorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
snapshots[detail.RowNo] = buildChangeDetailSnapshot(detail, card, currentBinds[iccid], newSubscriptionNo, futureBinds[iccid], contract, oldPackageCode, orderSN, beginAt, endAt)
|
||||
}
|
||||
if err = c.repo.Tables().MarkChangeDetailsSuccess(ctx, tx, main.BatchNo, lockedDetails, snapshots, contract.OperatorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
info := mergeBatchInfo(main.BatchInfo, map[string]any{
|
||||
"orderSn": orderSN,
|
||||
"oldPackageCode": oldPackageCode,
|
||||
"newPackageCode": newPackage.PackageCode,
|
||||
"newPackageName": newPackage.PackageName,
|
||||
"durationMonths": contract.DurationMonths,
|
||||
"giftDurationMonths": contract.GiftDurationMonths,
|
||||
"effectiveMode": contract.EffectiveMode,
|
||||
"packageBeginTime": formatTime(gtime.NewFromTime(beginAt)),
|
||||
"packageEndTime": formatTime(gtime.NewFromTime(endAt)),
|
||||
"amount": contract.TotalAmount(),
|
||||
"changeTime": formatTime(gtime.NewFromTime(now)),
|
||||
})
|
||||
return c.repo.UpdateBatchMainInfo(ctx, tx, main.BatchNo, main.BatchType, buildChangeSuccessBatchMain(main, contract, gjson.New(info), orderSN, lockedMain.StartTime, now, len(lockedDetails)))
|
||||
})
|
||||
return orderSN, skipped, err
|
||||
}
|
||||
|
||||
func buildChangeBind(contract *ExecutionContract, pkg *rc.FlowPackageInfo, card *rc.SimCardInfo, batchNo, oldPackageCode, orderSN, subscriptionNo string, effectiveStatus int, beginAt, endAt, now time.Time) *rc.SimCardBindPkg {
|
||||
return &rc.SimCardBindPkg{
|
||||
BatchNo: batchNo,
|
||||
Iccid: card.Iccid,
|
||||
Msisdn: card.Msisdn,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageType: pkg.PackageType,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PreviousPackageCode: oldPackageCode,
|
||||
BusinessType: consts.PackageBusinessTypeChange,
|
||||
EffectiveStatus: effectiveStatus,
|
||||
HandleTime: gtime.NewFromTime(now),
|
||||
EffectiveTime: gtime.NewFromTime(beginAt),
|
||||
ExpectedExpireTime: gtime.NewFromTime(endAt),
|
||||
DurationMonths: contract.DurationMonths,
|
||||
GiftDurationMonths: contract.GiftDurationMonths,
|
||||
OrderCode: orderSN,
|
||||
SubscriptionNo: subscriptionNo,
|
||||
Remark: "卡变更套餐自动生成",
|
||||
TenantId: contract.TenantID,
|
||||
CreatedDept: contract.CreatedDeptID,
|
||||
CreatedBy: contract.OperatorUserID,
|
||||
CreatedAt: gtime.NewFromTime(now),
|
||||
UpdatedBy: contract.OperatorUserID,
|
||||
UpdatedAt: gtime.NewFromTime(now),
|
||||
}
|
||||
}
|
||||
|
||||
func buildChangeSuccessBatchMain(main *rc.BatchMainRow, contract *ExecutionContract, info *gjson.Json, orderSN string, startTime *gtime.Time, now time.Time, validCount int) *rc.BatchMainRow {
|
||||
return &rc.BatchMainRow{
|
||||
BatchType: main.BatchType,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
|
||||
RunResult: consts.BatchRunResultSuccess,
|
||||
TotalCount: main.TotalCount,
|
||||
ValidCount: validCount,
|
||||
SuccessCount: validCount,
|
||||
FailCount: 0,
|
||||
BatchInfo: info,
|
||||
OrderSn: orderSN,
|
||||
StartTime: startTime,
|
||||
EndTime: gtime.NewFromTime(now),
|
||||
TargetDeptId: contract.CustomerAgentID,
|
||||
UpdatedBy: contract.OperatorUserID,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ChangeExecutor) finalizeFailure(ctx context.Context, main *rc.BatchMainRow, reason string) error {
|
||||
now := pnow(c.nowFunc)
|
||||
err := c.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if err := c.repo.MarkAllDetailsFailed(ctx, tx, main.BatchNo, main.BatchType, reason, main.CreatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.repo.FinalizeBatchMain(ctx, tx, main.BatchNo, main.BatchType, &rc.BatchMainRow{
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
|
||||
RunResult: consts.BatchRunResultFailed,
|
||||
FailReason: reason,
|
||||
TotalCount: main.TotalCount,
|
||||
FailCount: main.TotalCount,
|
||||
EndTime: gtime.NewFromTime(now),
|
||||
UpdatedBy: main.CreatedBy,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "finalize change failure failed")
|
||||
}
|
||||
g.Log().Warningf(ctx, "change batch failed, batchNo:%s, reason:%s, tenant:%s", main.BatchNo, reason, main.TenantId)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRunningChangeDetails(locked, loaded []*rc.BatchDetailRow) error {
|
||||
if len(locked) != len(loaded) {
|
||||
return gerror.New("change detail count changed during execution")
|
||||
}
|
||||
for _, detail := range locked {
|
||||
if detail == nil || detail.DetailStatus != consts.BatchDetailStatusRunning {
|
||||
return gerror.New("change detail is no longer running")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateChangeCard(card *rc.SimCardInfo, bind *rc.SimCardBindPkg, contract *ExecutionContract, iccid, oldPackageCode, sourceBatchNo, expectedMSISDN, expectedIMSI string) error {
|
||||
if contract == nil {
|
||||
return gerror.New("change execution contract is missing")
|
||||
}
|
||||
return validateCurrentSubscription(card, bind, subscriptionValidation{
|
||||
ICCID: iccid,
|
||||
MSISDN: expectedMSISDN,
|
||||
IMSI: expectedIMSI,
|
||||
TenantID: contract.TenantID,
|
||||
CustomerDeptID: contract.CustomerAgentID,
|
||||
PackageCode: oldPackageCode,
|
||||
SourceBatchNo: sourceBatchNo,
|
||||
})
|
||||
}
|
||||
|
||||
func findDetailByICCID(details []*rc.BatchDetailRow, iccid string) *rc.BatchDetailRow {
|
||||
for _, detail := range details {
|
||||
if detail != nil && strings.TrimSpace(detail.ItemId) == strings.TrimSpace(iccid) {
|
||||
return detail
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readChangeOldPackageCode(info *gjson.Json) string {
|
||||
return firstString(info, "oldPackageCode", "changePrepareSnapshot.oldPackageCode")
|
||||
}
|
||||
|
||||
func containsPackageCode(values *gjson.Json, target string) bool {
|
||||
if values == nil || values.IsNil() {
|
||||
return false
|
||||
}
|
||||
for _, value := range values.Interfaces() {
|
||||
if strings.TrimSpace(gconvString(value)) == strings.TrimSpace(target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveChangeBeginAt(contract *ExecutionContract, now time.Time) (time.Time, error) {
|
||||
if contract == nil || contract.PackageBeginTime == nil {
|
||||
return time.Time{}, gerror.New("change package begin time is missing")
|
||||
}
|
||||
switch strings.TrimSpace(contract.EffectiveMode) {
|
||||
case "immediate":
|
||||
return now, nil
|
||||
case "next_month":
|
||||
begin := contract.PackageBeginTime.Time
|
||||
if begin.Day() != 1 || begin.Hour() != 0 || begin.Minute() != 0 || begin.Second() != 0 {
|
||||
return time.Time{}, gerror.New("change next month begin time is invalid")
|
||||
}
|
||||
return begin, nil
|
||||
default:
|
||||
return time.Time{}, gerror.New("change effective mode is unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
func calculateChangePackageStatus(now, begin, end time.Time) int {
|
||||
if begin.After(now) {
|
||||
return consts.PackageStatusPending
|
||||
}
|
||||
if !end.After(now) {
|
||||
return consts.PackageStatusExpired
|
||||
}
|
||||
return consts.PackageStatusEffective
|
||||
}
|
||||
|
||||
func calculateChangeEffectiveStatus(now, begin, end time.Time) int {
|
||||
if begin.After(now) {
|
||||
return consts.EffectiveStatusPending
|
||||
}
|
||||
if !end.After(now) {
|
||||
return consts.EffectiveStatusExpired
|
||||
}
|
||||
return consts.EffectiveStatusEffective
|
||||
}
|
||||
|
||||
func subscriptionNos(rows []*rc.SimCardBindPkg) []string {
|
||||
result := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row != nil {
|
||||
result = append(result, strings.TrimSpace(row.SubscriptionNo))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func flattenFutureBinds(future map[string][]*rc.SimCardBindPkg, iccids []string) []*rc.SimCardBindPkg {
|
||||
result := make([]*rc.SimCardBindPkg, 0)
|
||||
for _, iccid := range iccids {
|
||||
result = append(result, future[iccid]...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildChangeDetailSnapshot(detail *rc.BatchDetailRow, card *rc.SimCardInfo, oldBind *rc.SimCardBindPkg, newSubscriptionNo string, voided []*rc.SimCardBindPkg, contract *ExecutionContract, oldPackageCode, orderSN string, beginAt, endAt time.Time) *gjson.Json {
|
||||
data := map[string]any{}
|
||||
if detail != nil && detail.Snapshot != nil {
|
||||
for key, value := range detail.Snapshot.Map() {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
data["ICCID"] = card.Iccid
|
||||
data["MSISDN"] = card.Msisdn
|
||||
data["IMSI"] = card.Imsi
|
||||
data["iccid"] = card.Iccid
|
||||
data["msisdn"] = card.Msisdn
|
||||
data["imsi"] = card.Imsi
|
||||
data["oldPackageCode"] = oldPackageCode
|
||||
data["newPackageCode"] = contract.PackageCode
|
||||
data["newPackageName"] = contract.PackageName
|
||||
data["oldSubscriptionNo"] = oldBind.SubscriptionNo
|
||||
data["oldOrderSn"] = oldBind.OrderCode
|
||||
data["newSubscriptionNo"] = newSubscriptionNo
|
||||
data["newOrderSn"] = orderSN
|
||||
data["durationMonths"] = contract.DurationMonths
|
||||
data["giftDurationMonths"] = contract.GiftDurationMonths
|
||||
data["effectiveMode"] = contract.EffectiveMode
|
||||
data["changeUnitPrice"] = contract.UnitPrice
|
||||
data["amount"] = roundAmount(contract.UnitPrice * float64(contract.DurationMonths))
|
||||
data["newEffectiveTime"] = formatTime(gtime.NewFromTime(beginAt))
|
||||
data["newExpectedExpireTime"] = formatTime(gtime.NewFromTime(endAt))
|
||||
data["oldActualExpireTime"] = formatTime(gtime.Now())
|
||||
voidedNos := make([]string, 0, len(voided))
|
||||
for _, row := range voided {
|
||||
if row != nil {
|
||||
voidedNos = append(voidedNos, row.SubscriptionNo)
|
||||
}
|
||||
}
|
||||
data["voidedSubscriptionNos"] = voidedNos
|
||||
return gjson.New(data)
|
||||
}
|
||||
|
||||
func gconvString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
func TestResolveCurrentSubscriptionsSelectsEffectiveAndFuturePending(t *testing.T) {
|
||||
rows := []*rc.SimCardBindPkg{
|
||||
{Iccid: "8986001", SubscriptionNo: "SUB-E", EffectiveStatus: consts.EffectiveStatusEffective},
|
||||
{Iccid: "8986001", SubscriptionNo: "SUB-P", EffectiveStatus: consts.EffectiveStatusPending},
|
||||
}
|
||||
current, future, err := resolveCurrentSubscriptions(rows, []string{"8986001"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCurrentSubscriptions() error = %v", err)
|
||||
}
|
||||
if current["8986001"].SubscriptionNo != "SUB-E" {
|
||||
t.Fatalf("current subscription = %q, want SUB-E", current["8986001"].SubscriptionNo)
|
||||
}
|
||||
if len(future["8986001"]) != 1 || future["8986001"][0].SubscriptionNo != "SUB-P" {
|
||||
t.Fatalf("future subscriptions = %+v, want SUB-P", future["8986001"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentSubscriptionsRejectsDuplicateCurrentSubscriptions(t *testing.T) {
|
||||
rows := []*rc.SimCardBindPkg{
|
||||
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusEffective},
|
||||
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusEffective},
|
||||
}
|
||||
if _, _, err := resolveCurrentSubscriptions(rows, []string{"8986001"}); err == nil {
|
||||
t.Fatal("resolveCurrentSubscriptions() error = nil, want duplicate-current error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChangeBeginAtUsesConfirmedNextMonthContract(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local)
|
||||
confirmed := time.Date(2026, 9, 1, 0, 0, 0, 0, time.Local)
|
||||
contract := &ExecutionContract{
|
||||
EffectiveMode: "next_month",
|
||||
PackageBeginTime: gtime.NewFromTime(confirmed),
|
||||
}
|
||||
got, err := resolveChangeBeginAt(contract, now)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveChangeBeginAt() error = %v", err)
|
||||
}
|
||||
if !got.Equal(confirmed) {
|
||||
t.Fatalf("begin time = %v, want %v", got, confirmed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveChangeBeginAtUsesExecutionTimeForImmediate(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local)
|
||||
contract := &ExecutionContract{
|
||||
EffectiveMode: "immediate",
|
||||
PackageBeginTime: gtime.NewFromTime(now.Add(-time.Minute)),
|
||||
}
|
||||
got, err := resolveChangeBeginAt(contract, now)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveChangeBeginAt() error = %v", err)
|
||||
}
|
||||
if !got.Equal(now) {
|
||||
t.Fatalf("begin time = %v, want %v", got, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeAmountAndServiceEndUseConfirmedDurationAndGift(t *testing.T) {
|
||||
contract := &ExecutionContract{UnitPrice: 8.5, Quantity: 2, DurationMonths: 12, GiftDurationMonths: 2}
|
||||
if got := contract.TotalAmount(); got != 204 {
|
||||
t.Fatalf("TotalAmount() = %v, want 204", got)
|
||||
}
|
||||
begin := time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local)
|
||||
wantEnd := begin.AddDate(0, 14, 0)
|
||||
if got := contract.ServiceEnd(begin); !got.Equal(wantEnd) {
|
||||
t.Fatalf("ServiceEnd() = %v, want %v", got, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChangeBindAndSuccessBatchKeepConfirmedContractAndAuditFields(t *testing.T) {
|
||||
contract := validExecutionContract()
|
||||
contract.BatchType = consts.BatchTypeChange
|
||||
contract.UnitPrice = 8.5
|
||||
contract.Quantity = 2
|
||||
contract.DurationMonths = 12
|
||||
contract.GiftDurationMonths = 2
|
||||
now := time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local)
|
||||
begin := now.AddDate(0, 1, 0)
|
||||
end := contract.ServiceEnd(begin)
|
||||
pkg := &rc.FlowPackageInfo{Id: 300, PackageCode: contract.PackageCode, PackageName: "Changed package", PackageType: 1}
|
||||
card := &rc.SimCardInfo{Iccid: "8986001", Msisdn: "13800000000"}
|
||||
|
||||
bind := buildChangeBind(contract, pkg, card, contract.BatchNo, "PKG-OLD", "SO-NEW", "SUB-NEW", consts.EffectiveStatusPending, begin, end, now)
|
||||
if bind.CreatedAt == nil || bind.UpdatedAt == nil || !bind.CreatedAt.Time.Equal(now) || !bind.UpdatedAt.Time.Equal(now) {
|
||||
t.Fatalf("subscription audit fields = %v/%v, want %v", bind.CreatedAt, bind.UpdatedAt, now)
|
||||
}
|
||||
if bind.DurationMonths != 12 || bind.GiftDurationMonths != 2 || bind.PackageCode != contract.PackageCode {
|
||||
t.Fatalf("subscription contract = package %q, duration %d+%d", bind.PackageCode, bind.DurationMonths, bind.GiftDurationMonths)
|
||||
}
|
||||
if bind.BusinessType != consts.PackageBusinessTypeChange {
|
||||
t.Fatalf("subscription business type = %d, want %d", bind.BusinessType, consts.PackageBusinessTypeChange)
|
||||
}
|
||||
if !bind.EffectiveTime.Time.Equal(begin) || !bind.ExpectedExpireTime.Time.Equal(end) {
|
||||
t.Fatalf("subscription service period = %v/%v, want %v/%v", bind.EffectiveTime, bind.ExpectedExpireTime, begin, end)
|
||||
}
|
||||
|
||||
main := &rc.BatchMainRow{BatchType: consts.BatchTypeChange, TotalCount: 2, StartTime: gtime.NewFromTime(now.Add(-time.Minute))}
|
||||
batch := buildChangeSuccessBatchMain(main, contract, nil, "SO-NEW", main.StartTime, now, 2)
|
||||
if batch.TargetDeptId != contract.CustomerAgentID {
|
||||
t.Fatalf("target_dept_id = %d, want %d", batch.TargetDeptId, contract.CustomerAgentID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package renewchange
|
||||
|
||||
// roundAmount 四舍五入到分。
|
||||
func roundAmount(value float64) float64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(int(value*100+0.5)) / 100
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
// ExecutionContract is the confirmed renew/change contract persisted in
|
||||
// batch_main.batch_info. DurationMonths is the actual persisted service
|
||||
// duration in months; DurationDisplayValue is the value selected by the user.
|
||||
type ExecutionContract struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
SourceBatchNo string
|
||||
TenantID string
|
||||
CustomerAgentID int64
|
||||
SellerAgentID int64
|
||||
CreatedDeptID int64
|
||||
OperatorUserID int64
|
||||
PackageCode string
|
||||
PackageName string
|
||||
PriceSource string
|
||||
AgentDefaultPriceID int64
|
||||
TargetPriceID int64
|
||||
UnitPrice float64
|
||||
Quantity int
|
||||
BillingMethod int
|
||||
RenewDurationUnit string
|
||||
RenewDurationUnitText string
|
||||
DurationDisplayValue int
|
||||
DurationMonths int
|
||||
GiftDurationMonths int
|
||||
OfflineContractCompleted bool
|
||||
PaymentMode string
|
||||
PayType int
|
||||
PayStatus int
|
||||
PayAmount float64
|
||||
BalanceAmount float64
|
||||
OfflinePaidAmount float64
|
||||
BalanceDetailSerialNo string
|
||||
PaymentTime string
|
||||
PayerDeptID int64
|
||||
PayerUserID int64
|
||||
BuyerNote string
|
||||
AdminNote string
|
||||
Remark string
|
||||
EffectiveMode string
|
||||
RenewDate string
|
||||
PackageBeginTime *gtime.Time
|
||||
}
|
||||
|
||||
type OrderProductContract struct {
|
||||
ID int64
|
||||
Code string
|
||||
Name string
|
||||
Type int
|
||||
}
|
||||
|
||||
// ParseExecutionContract reads the canonical top-level contract and falls
|
||||
// back to the prepare snapshots only for batches created before the flat
|
||||
// contract migration.
|
||||
func ParseExecutionContract(main *rc.BatchMainRow) (*ExecutionContract, error) {
|
||||
if main == nil {
|
||||
return nil, gerror.New("batch main cannot be nil")
|
||||
}
|
||||
if main.BatchType != consts.BatchTypeRenew && main.BatchType != consts.BatchTypeChange {
|
||||
return nil, gerror.Newf("unsupported renew/change batch type: %d", main.BatchType)
|
||||
}
|
||||
if main.BatchInfo == nil || main.BatchInfo.IsNil() {
|
||||
return nil, gerror.New("confirmed batch contract is missing")
|
||||
}
|
||||
|
||||
var snapshotPath, packageCodeField, packageNameField, unitPriceField string
|
||||
if main.BatchType == consts.BatchTypeRenew {
|
||||
snapshotPath = "priceSnapshot"
|
||||
packageCodeField = "packageCode"
|
||||
packageNameField = "packageName"
|
||||
unitPriceField = "renewUnitPrice"
|
||||
} else {
|
||||
snapshotPath = "changePriceSnapshot"
|
||||
packageCodeField = "newPackageCode"
|
||||
packageNameField = "newPackageName"
|
||||
unitPriceField = "changeUnitPrice"
|
||||
}
|
||||
|
||||
info := main.BatchInfo
|
||||
duration := executionDurationFromInfo(info, snapshotPath)
|
||||
if main.BatchType == consts.BatchTypeRenew {
|
||||
duration = normalizeRenewExecutionDuration(info, duration)
|
||||
}
|
||||
contract := &ExecutionContract{
|
||||
BatchNo: strings.TrimSpace(main.BatchNo),
|
||||
BatchType: main.BatchType,
|
||||
SourceBatchNo: executionSourceBatchNo(main, info),
|
||||
TenantID: strings.TrimSpace(main.TenantId),
|
||||
CustomerAgentID: firstInt64(info, "customerAgentId", snapshotPath+".customerAgentId"),
|
||||
SellerAgentID: main.DeptId,
|
||||
CreatedDeptID: main.CreatedDept,
|
||||
OperatorUserID: main.CreatedBy,
|
||||
PackageCode: firstString(info, packageCodeField, snapshotPath+"."+packageCodeField),
|
||||
PackageName: firstString(info, packageNameField, snapshotPath+"."+packageNameField),
|
||||
PriceSource: firstString(info, "priceSource", snapshotPath+".priceSource"),
|
||||
AgentDefaultPriceID: firstInt64(info, "agentDefaultPriceId", snapshotPath+".agentDefaultPriceId"),
|
||||
TargetPriceID: firstInt64(info, "targetPriceId", snapshotPath+".targetPriceId"),
|
||||
Quantity: firstInt(info, "quantity"),
|
||||
BillingMethod: duration.billingMethod,
|
||||
RenewDurationUnit: duration.unit,
|
||||
RenewDurationUnitText: duration.unitText,
|
||||
DurationDisplayValue: duration.displayValue,
|
||||
DurationMonths: duration.actualMonths,
|
||||
GiftDurationMonths: duration.giftMonths,
|
||||
OfflineContractCompleted: info.Get("offlineContractCompleted").Bool(),
|
||||
PaymentMode: firstString(info, "paymentMode", "paymentSnapshot.paymentMode"),
|
||||
PayType: firstInt(info, "payType", "paymentSnapshot.payType"),
|
||||
PayStatus: firstInt(info, "payStatus", "paymentSnapshot.payStatus"),
|
||||
PayAmount: firstAmount(info, "payAmount", "paymentSnapshot.payAmount"),
|
||||
BalanceAmount: firstAmount(info, "balanceAmount", "paymentSnapshot.balanceAmount"),
|
||||
OfflinePaidAmount: firstAmount(info, "offlinePaidAmount", "paymentSnapshot.offlinePaidAmount"),
|
||||
BalanceDetailSerialNo: firstString(info, "balanceDetailSerialNo", "paymentSnapshot.balanceDetailSerialNo"),
|
||||
PaymentTime: firstString(info, "paymentTime", "paymentSnapshot.paymentTime"),
|
||||
PayerDeptID: firstInt64(info, "payerDeptId", "paymentSnapshot.payerDeptId"),
|
||||
PayerUserID: firstInt64(info, "payerUserId", "paymentSnapshot.payerUserId"),
|
||||
BuyerNote: strings.TrimSpace(info.Get("buyerNote").String()),
|
||||
AdminNote: strings.TrimSpace(info.Get("adminNote").String()),
|
||||
Remark: strings.TrimSpace(info.Get("remark").String()),
|
||||
EffectiveMode: strings.TrimSpace(info.Get("effectiveMode").String()),
|
||||
RenewDate: strings.TrimSpace(info.Get("renewDate").String()),
|
||||
}
|
||||
if value, ok := firstFloat64(info, unitPriceField, snapshotPath+"."+unitPriceField); ok {
|
||||
contract.UnitPrice = value
|
||||
} else {
|
||||
contract.UnitPrice = -1
|
||||
}
|
||||
if contract.SellerAgentID <= 0 {
|
||||
contract.SellerAgentID = main.CreatedDept
|
||||
}
|
||||
if contract.CreatedDeptID <= 0 {
|
||||
contract.CreatedDeptID = contract.SellerAgentID
|
||||
}
|
||||
contract.normalizePaymentContract()
|
||||
if raw := strings.TrimSpace(info.Get("packageBeginTime").String()); raw != "" {
|
||||
beginAt, err := parseExecutionTime(raw)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "parse package begin time failed")
|
||||
}
|
||||
contract.PackageBeginTime = beginAt
|
||||
}
|
||||
if err := contract.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contract, nil
|
||||
}
|
||||
|
||||
func executionSourceBatchNo(main *rc.BatchMainRow, info *gjson.Json) string {
|
||||
if main == nil || main.SourceType != consts.BatchSourceTypeBatchSelect {
|
||||
return ""
|
||||
}
|
||||
sourceBatchNo := firstString(info, "sourceBatchNo", "changePrepareSnapshot.sourceBatchNo")
|
||||
if sourceBatchNo == "" || sourceBatchNo == strings.TrimSpace(main.BatchNo) {
|
||||
sourceBatchNo = strings.TrimSpace(main.SourceBatchNo)
|
||||
}
|
||||
if sourceBatchNo == strings.TrimSpace(main.BatchNo) {
|
||||
return ""
|
||||
}
|
||||
return sourceBatchNo
|
||||
}
|
||||
|
||||
type executionDurationContract struct {
|
||||
billingMethod int
|
||||
unit string
|
||||
unitText string
|
||||
displayValue int
|
||||
actualMonths int
|
||||
giftMonths int
|
||||
}
|
||||
|
||||
func executionDurationFromInfo(info *gjson.Json, snapshotPath string) executionDurationContract {
|
||||
rawDurationMonths := firstInt(info, "durationMonths")
|
||||
displayValue := firstInt(info, "durationValue", "durationYears")
|
||||
hasDisplayValue := hasJSONValue(info, "durationValue") || hasJSONValue(info, "durationYears")
|
||||
if displayValue <= 0 {
|
||||
displayValue = rawDurationMonths
|
||||
}
|
||||
|
||||
billingMethod := firstInt(info, "billingMethod", snapshotPath+".billingMethod")
|
||||
unit := firstString(info, "renewDurationUnit", snapshotPath+".renewDurationUnit")
|
||||
if strings.TrimSpace(unit) == "" {
|
||||
if hasDisplayValue {
|
||||
unit = renewDurationUnitForBillingMethod(billingMethod)
|
||||
} else {
|
||||
unit = consts.RenewDurationUnitMonth
|
||||
}
|
||||
}
|
||||
unit = consts.NormalizeRenewDurationUnit(unit)
|
||||
unitText := firstString(info, "renewDurationUnitText", snapshotPath+".renewDurationUnitText")
|
||||
if strings.TrimSpace(unitText) == "" {
|
||||
unitText = consts.RenewDurationUnitText(unit)
|
||||
}
|
||||
|
||||
actualMonths := firstInt(info, "actualDurationMonths")
|
||||
if actualMonths <= 0 {
|
||||
if hasDisplayValue {
|
||||
actualMonths = consts.DurationValueToMonths(unit, displayValue)
|
||||
} else {
|
||||
actualMonths = rawDurationMonths
|
||||
}
|
||||
}
|
||||
giftMonths := firstInt(info, "actualGiftDurationMonths")
|
||||
if giftMonths <= 0 {
|
||||
giftMonths = firstInt(info, "giftDurationMonths")
|
||||
}
|
||||
if hasDisplayValue && giftMonths > 0 && firstInt(info, "actualGiftDurationMonths") <= 0 {
|
||||
giftMonths = consts.DurationValueToMonths(unit, giftMonths)
|
||||
}
|
||||
return executionDurationContract{
|
||||
billingMethod: billingMethod,
|
||||
unit: unit,
|
||||
unitText: unitText,
|
||||
displayValue: displayValue,
|
||||
actualMonths: actualMonths,
|
||||
giftMonths: giftMonths,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRenewExecutionDuration(info *gjson.Json, duration executionDurationContract) executionDurationContract {
|
||||
displayValue := firstInt(info, "durationValue", "durationYears")
|
||||
if displayValue <= 0 {
|
||||
displayValue = normalizeLegacyRenewDurationValue(firstInt(info, "durationMonths"))
|
||||
}
|
||||
if displayValue <= 0 {
|
||||
return duration
|
||||
}
|
||||
duration.unit = consts.RenewDurationUnitYear
|
||||
duration.unitText = consts.RenewDurationUnitText(duration.unit)
|
||||
duration.displayValue = displayValue
|
||||
duration.actualMonths = consts.DurationValueToMonths(duration.unit, displayValue)
|
||||
return duration
|
||||
}
|
||||
|
||||
func (c *ExecutionContract) Validate() error {
|
||||
if c == nil {
|
||||
return gerror.New("execution contract cannot be nil")
|
||||
}
|
||||
if strings.TrimSpace(c.BatchNo) == "" {
|
||||
return gerror.New("execution contract batch number is missing")
|
||||
}
|
||||
if c.BatchType != consts.BatchTypeRenew && c.BatchType != consts.BatchTypeChange {
|
||||
return gerror.New("execution contract batch type is invalid")
|
||||
}
|
||||
if strings.TrimSpace(c.TenantID) == "" {
|
||||
return gerror.New("execution contract tenant id is missing")
|
||||
}
|
||||
if c.CustomerAgentID <= 0 || c.SellerAgentID <= 0 || c.CreatedDeptID <= 0 || c.OperatorUserID <= 0 {
|
||||
return gerror.New("execution contract organization or operator is incomplete")
|
||||
}
|
||||
if strings.TrimSpace(c.PackageCode) == "" || strings.TrimSpace(c.PackageName) == "" {
|
||||
return gerror.New("execution contract package is incomplete")
|
||||
}
|
||||
if c.UnitPrice < 0 {
|
||||
return gerror.New("execution contract unit price is missing")
|
||||
}
|
||||
if c.Quantity <= 0 {
|
||||
return gerror.New("execution contract quantity must be greater than zero")
|
||||
}
|
||||
if c.DurationDisplayValue <= 0 {
|
||||
c.DurationDisplayValue = c.DurationMonths
|
||||
}
|
||||
if c.DurationMonths <= 0 {
|
||||
return gerror.New("execution contract duration months must be greater than zero")
|
||||
}
|
||||
if c.GiftDurationMonths < 0 {
|
||||
return gerror.New("execution contract gift duration months cannot be negative")
|
||||
}
|
||||
c.normalizePaymentContract()
|
||||
switch strings.TrimSpace(c.PaymentMode) {
|
||||
case "offline":
|
||||
if !c.OfflineContractCompleted {
|
||||
return gerror.New("execution contract offline confirmation is missing")
|
||||
}
|
||||
case "balance":
|
||||
if c.PayType != consts.PayTypeBalance {
|
||||
return gerror.New("execution contract balance pay type is invalid")
|
||||
}
|
||||
if c.PayAmount < 0 || c.BalanceAmount < 0 {
|
||||
return gerror.New("execution contract balance amount is invalid")
|
||||
}
|
||||
totalAmount := roundAmount(c.TotalAmount())
|
||||
if roundAmount(c.PayAmount) != totalAmount || roundAmount(c.BalanceAmount) != totalAmount {
|
||||
return gerror.New("execution contract balance payment amount does not match total amount")
|
||||
}
|
||||
if strings.TrimSpace(c.BalanceDetailSerialNo) == "" && c.PayAmount > 0 {
|
||||
return gerror.New("execution contract balance serial number is missing")
|
||||
}
|
||||
default:
|
||||
return gerror.New("execution contract payment mode is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ExecutionContract) normalizePaymentContract() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(c.PaymentMode) == "" {
|
||||
if c.PayType == consts.PayTypeBalance || c.BalanceAmount > 0 || strings.TrimSpace(c.BalanceDetailSerialNo) != "" {
|
||||
c.PaymentMode = "balance"
|
||||
} else {
|
||||
c.PaymentMode = "offline"
|
||||
}
|
||||
}
|
||||
amount := c.TotalAmount()
|
||||
if c.PayAmount <= 0 && amount > 0 {
|
||||
c.PayAmount = amount
|
||||
}
|
||||
if c.PayStatus == 0 {
|
||||
c.PayStatus = consts.PayStatusPaid
|
||||
}
|
||||
if c.PaymentMode == "offline" {
|
||||
if c.PayType == 0 {
|
||||
c.PayType = consts.PayTypeOffline
|
||||
}
|
||||
if c.OfflinePaidAmount <= 0 {
|
||||
c.OfflinePaidAmount = amount
|
||||
}
|
||||
return
|
||||
}
|
||||
if c.PaymentMode == "balance" {
|
||||
if c.PayType == 0 {
|
||||
c.PayType = consts.PayTypeBalance
|
||||
}
|
||||
if c.BalanceAmount <= 0 {
|
||||
c.BalanceAmount = amount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ExecutionContract) TotalAmount() float64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return roundAmount(c.UnitPrice * float64(c.DisplayDurationValue()) * float64(c.Quantity))
|
||||
}
|
||||
|
||||
func (c *ExecutionContract) ItemPrice() float64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return roundAmount(c.UnitPrice * float64(c.DisplayDurationValue()))
|
||||
}
|
||||
|
||||
func (c *ExecutionContract) DisplayDurationValue() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
if c.DurationDisplayValue > 0 {
|
||||
return c.DurationDisplayValue
|
||||
}
|
||||
return c.DurationMonths
|
||||
}
|
||||
|
||||
func (c *ExecutionContract) ServiceEnd(begin time.Time) time.Time {
|
||||
if c == nil {
|
||||
return begin
|
||||
}
|
||||
return addMonths(begin, c.DurationMonths+c.GiftDurationMonths)
|
||||
}
|
||||
|
||||
func BuildOrderDO(contract *ExecutionContract, orderSN string, now time.Time) (*rc.OrderDO, error) {
|
||||
if err := contract.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderSN = strings.TrimSpace(orderSN)
|
||||
if orderSN == "" {
|
||||
return nil, gerror.New("order number must be generated before building the order")
|
||||
}
|
||||
amount := contract.TotalAmount()
|
||||
balanceAmount := float64(0)
|
||||
offlinePaidAmount := amount
|
||||
payType := consts.PayTypeOffline
|
||||
payOrderSN := ""
|
||||
if contract.PaymentMode == "balance" {
|
||||
balanceAmount = contract.BalanceAmount
|
||||
offlinePaidAmount = 0
|
||||
payType = consts.PayTypeBalance
|
||||
payOrderSN = contract.BalanceDetailSerialNo
|
||||
}
|
||||
remark := contract.Remark
|
||||
if remark == "" {
|
||||
remark = "SIM card package business order"
|
||||
}
|
||||
payTime := gtime.NewFromTime(now)
|
||||
if strings.TrimSpace(contract.PaymentTime) != "" {
|
||||
if parsed, err := parseExecutionTime(strings.TrimSpace(contract.PaymentTime)); err == nil {
|
||||
payTime = parsed
|
||||
}
|
||||
}
|
||||
orderType := consts.OrderTypeCardRenew
|
||||
if contract.BatchType == consts.BatchTypeChange {
|
||||
orderType = consts.OrderTypeCardChange
|
||||
}
|
||||
return &rc.OrderDO{
|
||||
OrderSn: orderSN,
|
||||
ParentOrderId: int64(0),
|
||||
ParentOrderSn: "",
|
||||
CustomerType: consts.CustomerTypeAgent,
|
||||
CustomerAgentId: contract.CustomerAgentID,
|
||||
EndUserId: int64(0),
|
||||
AgentId: contract.SellerAgentID,
|
||||
OrderType: orderType,
|
||||
OrderStatus: consts.OrderStatusCompleted,
|
||||
ShippingStatus: 0,
|
||||
ReturnStatus: consts.OrderReturnStatusNone,
|
||||
Consignee: "",
|
||||
Mobile: "",
|
||||
Email: "",
|
||||
Address: "",
|
||||
AreaCode: "",
|
||||
BuyerNote: contract.BuyerNote,
|
||||
AdminNote: contract.AdminNote,
|
||||
Remark: remark,
|
||||
ShippingMethod: consts.ShippingMethodNone,
|
||||
LogisticsId: int64(0),
|
||||
LogisticsName: "",
|
||||
ShippingTypeId: int64(0),
|
||||
ShippingTypeName: "",
|
||||
TrackingNo: "",
|
||||
PayType: payType,
|
||||
PayStatus: consts.PayStatusPaid,
|
||||
PayOrderSn: payOrderSN,
|
||||
PayTime: payTime,
|
||||
TotalAmount: amount,
|
||||
ProductAmount: amount,
|
||||
ShippingFee: 0,
|
||||
ServiceFee: 0,
|
||||
InvoiceFee: 0,
|
||||
CouponAmount: 0,
|
||||
DiscountAmount: 0,
|
||||
Balance: balanceAmount,
|
||||
PaidAmount: amount,
|
||||
OnlinePaidAmount: 0,
|
||||
OfflinePaidAmount: offlinePaidAmount,
|
||||
UnpaidAmount: 0,
|
||||
UnrefundAmount: 0,
|
||||
IsNeedCommission: 0,
|
||||
DistributionStatus: 0,
|
||||
TenantId: contract.TenantID,
|
||||
CreatedDept: contract.CreatedDeptID,
|
||||
CreatedBy: contract.OperatorUserID,
|
||||
CreatedAt: payTime,
|
||||
UpdatedBy: contract.OperatorUserID,
|
||||
UpdatedAt: payTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildOrderItemDO(contract *ExecutionContract, product OrderProductContract, orderID int64, orderSN string, beginAt, endAt, now time.Time) (*rc.OrderItemDO, error) {
|
||||
if err := contract.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orderID <= 0 || strings.TrimSpace(orderSN) == "" {
|
||||
return nil, gerror.New("order item requires a persisted order id and order number")
|
||||
}
|
||||
if product.ID <= 0 || strings.TrimSpace(product.Code) == "" || strings.TrimSpace(product.Name) == "" {
|
||||
return nil, gerror.New("order product contract is incomplete")
|
||||
}
|
||||
if strings.TrimSpace(product.Code) != strings.TrimSpace(contract.PackageCode) {
|
||||
return nil, gerror.New("order product does not match the confirmed package")
|
||||
}
|
||||
if !endAt.After(beginAt) {
|
||||
return nil, gerror.New("order service end time must be after begin time")
|
||||
}
|
||||
|
||||
skuData, err := buildSKUData(contract, product, beginAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extraSKUData, err := buildExtraSKUData(contract, beginAt, endAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rc.OrderItemDO{
|
||||
OrderId: orderID,
|
||||
OrderSn: strings.TrimSpace(orderSN),
|
||||
CustomerType: consts.CustomerTypeAgent,
|
||||
CustomerAgentId: contract.CustomerAgentID,
|
||||
EndUserId: int64(0),
|
||||
Price: contract.ItemPrice(),
|
||||
Quantity: contract.Quantity,
|
||||
ProductId: product.ID,
|
||||
ProductName: product.Name,
|
||||
ProductSn: product.Code,
|
||||
PicThumb: "",
|
||||
SkuId: int64(0),
|
||||
SkuData: skuData,
|
||||
DeliveryQuantity: 0,
|
||||
ProductType: consts.ProductTypeCard,
|
||||
IsGift: 0,
|
||||
AgentId: contract.SellerAgentID,
|
||||
PrepayPrice: 0,
|
||||
Commission: "",
|
||||
OriginPrice: contract.ItemPrice(),
|
||||
PromotionData: "",
|
||||
ExtraSkuData: extraSKUData,
|
||||
PayMode: consts.PayModeYearMonth,
|
||||
ServiceBeginTime: gtime.NewFromTime(beginAt),
|
||||
ServiceEndTime: gtime.NewFromTime(endAt),
|
||||
TenantId: contract.TenantID,
|
||||
CreatedDept: contract.CreatedDeptID,
|
||||
CreatedBy: contract.OperatorUserID,
|
||||
CreatedAt: gtime.NewFromTime(now),
|
||||
UpdatedBy: contract.OperatorUserID,
|
||||
UpdatedAt: gtime.NewFromTime(now),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildRenewCardUpdate returns the intentionally narrow card update used by
|
||||
// renew execution. GoFrame ignores every nil field in this generated DO.
|
||||
func BuildRenewCardUpdate(contract *ExecutionContract, endAt time.Time, packageStatus int, now time.Time) (*do.TSimCardInfo, error) {
|
||||
if err := contract.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if contract.BatchType != consts.BatchTypeRenew {
|
||||
return nil, gerror.New("renew card update requires a renew contract")
|
||||
}
|
||||
if endAt.IsZero() {
|
||||
return nil, gerror.New("renew card end time cannot be empty")
|
||||
}
|
||||
if now.IsZero() {
|
||||
return nil, gerror.New("renew card update time cannot be empty")
|
||||
}
|
||||
if packageStatus < consts.PackageStatusPending || packageStatus > consts.PackageStatusExpired {
|
||||
return nil, gerror.New("renew card package status is invalid")
|
||||
}
|
||||
return &do.TSimCardInfo{
|
||||
FlowPkgEndAt: gtime.NewFromTime(endAt),
|
||||
PackageStatus: packageStatus,
|
||||
UpdatedBy: contract.OperatorUserID,
|
||||
UpdatedAt: gtime.NewFromTime(now),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildSKUData(contract *ExecutionContract, product OrderProductContract, beginAt time.Time) (string, error) {
|
||||
durationLabel := fmt.Sprintf("%d months", contract.DurationMonths)
|
||||
if contract.DurationDisplayValue > 0 && strings.TrimSpace(contract.RenewDurationUnit) != "" {
|
||||
durationLabel = renewDurationLabel(contract.DurationDisplayValue, contract.RenewDurationUnit)
|
||||
}
|
||||
data := []map[string]any{
|
||||
{"skuName": "package", "skuValue": product.Name, "packageCode": product.Code, "packageType": product.Type},
|
||||
{"skuName": "quantity", "skuValue": fmt.Sprintf("%d", contract.Quantity)},
|
||||
{"skuName": "effectiveTime", "skuValue": beginAt.Format("2006-01-02 15:04:05")},
|
||||
{"skuName": "duration", "skuValue": durationLabel},
|
||||
}
|
||||
encoded, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", gerror.Wrap(err, "marshal order SKU data failed")
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func buildExtraSKUData(contract *ExecutionContract, beginAt, endAt time.Time) (string, error) {
|
||||
data := map[string]any{
|
||||
"outboundBatchNo": contract.BatchNo,
|
||||
"businessBatchNo": contract.BatchNo,
|
||||
"offlineContractCompleted": contract.OfflineContractCompleted,
|
||||
"packageBeginTime": beginAt.Format("2006-01-02 15:04:05"),
|
||||
"packageEndTime": endAt.Format("2006-01-02 15:04:05"),
|
||||
"durationMonths": contract.DurationMonths,
|
||||
"durationValue": contract.DisplayDurationValue(),
|
||||
"actualDurationMonths": contract.DurationMonths,
|
||||
"durationUnit": strings.TrimSpace(contract.RenewDurationUnit),
|
||||
"billingMethod": contract.BillingMethod,
|
||||
"giftDurationMonths": contract.GiftDurationMonths,
|
||||
"unitPrice": contract.UnitPrice,
|
||||
"amount": contract.TotalAmount(),
|
||||
"paymentMode": contract.PaymentMode,
|
||||
"payType": contract.PayType,
|
||||
"balanceAmount": contract.BalanceAmount,
|
||||
"offlinePaidAmount": contract.OfflinePaidAmount,
|
||||
}
|
||||
if contract.BalanceDetailSerialNo != "" {
|
||||
data["balanceDetailSerialNo"] = contract.BalanceDetailSerialNo
|
||||
}
|
||||
if contract.SourceBatchNo != "" {
|
||||
data["sourceBatchNo"] = contract.SourceBatchNo
|
||||
}
|
||||
if contract.EffectiveMode != "" {
|
||||
data["effectiveMode"] = contract.EffectiveMode
|
||||
}
|
||||
encoded, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", gerror.Wrap(err, "marshal order extra SKU data failed")
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func firstString(info *gjson.Json, paths ...string) string {
|
||||
for _, path := range paths {
|
||||
value := strings.TrimSpace(info.Get(path).String())
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasJSONValue(info *gjson.Json, path string) bool {
|
||||
if info == nil || strings.TrimSpace(path) == "" {
|
||||
return false
|
||||
}
|
||||
value := info.Get(path)
|
||||
return value != nil && !value.IsNil()
|
||||
}
|
||||
|
||||
func firstInt(info *gjson.Json, paths ...string) int {
|
||||
return int(firstInt64(info, paths...))
|
||||
}
|
||||
|
||||
func firstInt64(info *gjson.Json, paths ...string) int64 {
|
||||
for _, path := range paths {
|
||||
value := info.Get(path)
|
||||
if value != nil && !value.IsNil() {
|
||||
return value.Int64()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstFloat64(info *gjson.Json, paths ...string) (float64, bool) {
|
||||
for _, path := range paths {
|
||||
value := info.Get(path)
|
||||
if value != nil && !value.IsNil() {
|
||||
return value.Float64(), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func firstAmount(info *gjson.Json, paths ...string) float64 {
|
||||
value, _ := firstFloat64(info, paths...)
|
||||
return roundAmount(value)
|
||||
}
|
||||
|
||||
func parseExecutionTime(value string) (*gtime.Time, error) {
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02"} {
|
||||
parsed, err := time.ParseInLocation(layout, value, time.Local)
|
||||
if err == nil {
|
||||
return gtime.NewFromTime(parsed), nil
|
||||
}
|
||||
}
|
||||
return nil, gerror.Newf("invalid execution time: %s", value)
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
func TestParseExecutionContractReadsConfirmedFlatContract(t *testing.T) {
|
||||
main := &rc.BatchMainRow{
|
||||
BatchNo: "REN20260803100250ABCDEFGH",
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
DeptId: 100,
|
||||
CreatedDept: 100,
|
||||
CreatedBy: 1,
|
||||
TenantId: "000000",
|
||||
BatchInfo: gjson.New(map[string]any{
|
||||
"customerAgentId": 200,
|
||||
"packageCode": "PKG001",
|
||||
"packageName": "Year package",
|
||||
"renewUnitPrice": 10.25,
|
||||
"quantity": 2,
|
||||
"durationMonths": 12,
|
||||
"giftDurationMonths": 1,
|
||||
"offlineContractCompleted": true,
|
||||
"buyerNote": "buyer",
|
||||
"adminNote": "admin",
|
||||
}),
|
||||
}
|
||||
contract, err := ParseExecutionContract(main)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExecutionContract returned error: %v", err)
|
||||
}
|
||||
if contract.DurationDisplayValue != 1 || contract.DurationMonths != 12 || contract.GiftDurationMonths != 1 {
|
||||
t.Fatalf("duration = %d/%d+%d, want display 1 and actual 12+1", contract.DurationDisplayValue, contract.DurationMonths, contract.GiftDurationMonths)
|
||||
}
|
||||
if contract.CustomerAgentID != 200 || contract.SellerAgentID != 100 {
|
||||
t.Fatalf("buyer/seller = %d/%d, want 200/100", contract.CustomerAgentID, contract.SellerAgentID)
|
||||
}
|
||||
if contract.TotalAmount() != 20.5 {
|
||||
t.Fatalf("TotalAmount = %.2f, want 20.50", contract.TotalAmount())
|
||||
}
|
||||
if contract.ItemPrice() != 10.25 {
|
||||
t.Fatalf("ItemPrice = %.2f, want 10.25", contract.ItemPrice())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecutionContractRenewDurationValueOverridesLegacyMonthSnapshot(t *testing.T) {
|
||||
main := &rc.BatchMainRow{
|
||||
BatchNo: "REN20260803100250LEGACY",
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
DeptId: 100,
|
||||
CreatedDept: 100,
|
||||
CreatedBy: 1,
|
||||
TenantId: "000000",
|
||||
BatchInfo: gjson.New(map[string]any{
|
||||
"customerAgentId": 200,
|
||||
"packageCode": "PKG001",
|
||||
"packageName": "Year package",
|
||||
"renewUnitPrice": 10.25,
|
||||
"quantity": 1,
|
||||
"durationValue": 1,
|
||||
"durationYears": 1,
|
||||
"durationMonths": 1,
|
||||
"actualDurationMonths": 1,
|
||||
"billingMethod": consts.BillingMethodMonth,
|
||||
"renewDurationUnit": consts.RenewDurationUnitMonth,
|
||||
"offlineContractCompleted": true,
|
||||
}),
|
||||
}
|
||||
|
||||
contract, err := ParseExecutionContract(main)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExecutionContract returned error: %v", err)
|
||||
}
|
||||
if contract.RenewDurationUnit != consts.RenewDurationUnitYear {
|
||||
t.Fatalf("renew duration unit = %q, want year", contract.RenewDurationUnit)
|
||||
}
|
||||
if contract.DurationDisplayValue != 1 || contract.DurationMonths != 12 {
|
||||
t.Fatalf("duration = %d/%d, want display 1 and actual 12 months", contract.DurationDisplayValue, contract.DurationMonths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecutionContractFallsBackToLegacyNestedSnapshot(t *testing.T) {
|
||||
main := &rc.BatchMainRow{
|
||||
BatchNo: "CHG20260803100250ABCDEFGH",
|
||||
BatchType: consts.BatchTypeChange,
|
||||
DeptId: 100,
|
||||
CreatedDept: 100,
|
||||
CreatedBy: 1,
|
||||
TenantId: "000000",
|
||||
BatchInfo: gjson.New(map[string]any{
|
||||
"changePriceSnapshot": map[string]any{
|
||||
"customerAgentId": 200,
|
||||
"newPackageCode": "PKG002",
|
||||
"newPackageName": "Changed package",
|
||||
"changeUnitPrice": 8.5,
|
||||
},
|
||||
"quantity": 1,
|
||||
"durationMonths": 24,
|
||||
"giftDurationMonths": 0,
|
||||
"offlineContractCompleted": true,
|
||||
}),
|
||||
}
|
||||
contract, err := ParseExecutionContract(main)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExecutionContract returned error: %v", err)
|
||||
}
|
||||
if contract.PackageCode != "PKG002" || contract.UnitPrice != 8.5 {
|
||||
t.Fatalf("legacy snapshot contract = %s/%.2f, want PKG002/8.50", contract.PackageCode, contract.UnitPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecutionContractNormalizesSourceBatchBySourceType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sourceType int
|
||||
mainSourceBatchNo string
|
||||
infoSourceBatchNo string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "input self reference is not a business source batch",
|
||||
sourceType: consts.BatchSourceTypeBatchInput,
|
||||
mainSourceBatchNo: "REN20260803161502D52JJU6B",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "file source ignores legacy source value",
|
||||
sourceType: consts.BatchSourceTypeFile,
|
||||
mainSourceBatchNo: "REN20260803161502D52JJU6B",
|
||||
infoSourceBatchNo: "OUT20260803STALE",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "batch selection keeps confirmed source batch",
|
||||
sourceType: consts.BatchSourceTypeBatchSelect,
|
||||
mainSourceBatchNo: "OUT20260803MAIN",
|
||||
infoSourceBatchNo: "OUT20260803CONFIRMED",
|
||||
want: "OUT20260803CONFIRMED",
|
||||
},
|
||||
{
|
||||
name: "batch selection falls back to main source batch",
|
||||
sourceType: consts.BatchSourceTypeBatchSelect,
|
||||
mainSourceBatchNo: "OUT20260803MAIN",
|
||||
want: "OUT20260803MAIN",
|
||||
},
|
||||
{
|
||||
name: "batch selection repairs self reference from main source batch",
|
||||
sourceType: consts.BatchSourceTypeBatchSelect,
|
||||
mainSourceBatchNo: "OUT20260803MAIN",
|
||||
infoSourceBatchNo: "REN20260803161502D52JJU6B",
|
||||
want: "OUT20260803MAIN",
|
||||
},
|
||||
{
|
||||
name: "legacy self reference is normalized to empty",
|
||||
sourceType: consts.BatchSourceTypeBatchSelect,
|
||||
mainSourceBatchNo: "REN20260803161502D52JJU6B",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
info := map[string]any{
|
||||
"customerAgentId": 200,
|
||||
"packageCode": "PKG001",
|
||||
"packageName": "Year package",
|
||||
"renewUnitPrice": 10.25,
|
||||
"quantity": 1,
|
||||
"durationMonths": 12,
|
||||
"offlineContractCompleted": true,
|
||||
}
|
||||
if tt.infoSourceBatchNo != "" {
|
||||
info["sourceBatchNo"] = tt.infoSourceBatchNo
|
||||
}
|
||||
main := &rc.BatchMainRow{
|
||||
BatchNo: "REN20260803161502D52JJU6B",
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
SourceType: tt.sourceType,
|
||||
SourceBatchNo: tt.mainSourceBatchNo,
|
||||
DeptId: 100,
|
||||
CreatedDept: 100,
|
||||
CreatedBy: 1,
|
||||
TenantId: "000000",
|
||||
BatchInfo: gjson.New(info),
|
||||
}
|
||||
|
||||
contract, err := ParseExecutionContract(main)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExecutionContract returned error: %v", err)
|
||||
}
|
||||
if contract.SourceBatchNo != tt.want {
|
||||
t.Fatalf("SourceBatchNo = %q, want %q", contract.SourceBatchNo, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecutionContractRejectsMissingConfirmedFields(t *testing.T) {
|
||||
base := validExecutionContract()
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*ExecutionContract)
|
||||
}{
|
||||
{name: "customer", mutate: func(c *ExecutionContract) { c.CustomerAgentID = 0 }},
|
||||
{name: "duration", mutate: func(c *ExecutionContract) { c.DurationMonths = 0 }},
|
||||
{name: "quantity", mutate: func(c *ExecutionContract) { c.Quantity = 0 }},
|
||||
{name: "package", mutate: func(c *ExecutionContract) { c.PackageCode = "" }},
|
||||
{name: "offline contract", mutate: func(c *ExecutionContract) { c.OfflineContractCompleted = false }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := *base
|
||||
tc.mutate(&got)
|
||||
if err := got.Validate(); err == nil {
|
||||
t.Fatalf("Validate accepted missing %s", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOrderDocumentsUseAggregatePurchasePeriodPrice(t *testing.T) {
|
||||
contract := validExecutionContract()
|
||||
contract.UnitPrice = 10.25
|
||||
contract.Quantity = 2
|
||||
contract.DurationMonths = 12
|
||||
contract.GiftDurationMonths = 1
|
||||
now := time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local)
|
||||
orderSN := "SO20260803100250ABCDEFGH"
|
||||
|
||||
order, err := BuildOrderDO(contract, orderSN, now)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOrderDO returned error: %v", err)
|
||||
}
|
||||
if gconv.String(order.OrderSn) != orderSN {
|
||||
t.Fatalf("order_sn = %q, want %q", order.OrderSn, orderSN)
|
||||
}
|
||||
if gconv.Int64(order.CustomerAgentId) != 200 || gconv.Int64(order.AgentId) != 100 {
|
||||
t.Fatalf("order buyer/seller = %v/%v, want 200/100", order.CustomerAgentId, order.AgentId)
|
||||
}
|
||||
if gconv.Int(order.OrderType) != consts.OrderTypeCardRenew {
|
||||
t.Fatalf("order_type = %v, want renew", order.OrderType)
|
||||
}
|
||||
if gconv.Float64(order.TotalAmount) != 246 || gconv.Float64(order.ProductAmount) != 246 || gconv.Float64(order.PaidAmount) != 246 {
|
||||
t.Fatalf("order amount fields = %v/%v/%v, want 246", order.TotalAmount, order.ProductAmount, order.PaidAmount)
|
||||
}
|
||||
if order.CreatedAt == nil || !order.CreatedAt.Time.Equal(now) || order.UpdatedAt == nil || !order.UpdatedAt.Time.Equal(now) {
|
||||
t.Fatalf("order audit times = %v/%v, want %v", order.CreatedAt, order.UpdatedAt, now)
|
||||
}
|
||||
|
||||
beginAt := time.Date(2026, 8, 15, 0, 0, 0, 0, time.Local)
|
||||
endAt := contract.ServiceEnd(beginAt)
|
||||
item, err := BuildOrderItemDO(contract, OrderProductContract{
|
||||
ID: 300, Code: "PKG001", Name: "Year package", Type: 1,
|
||||
}, 900, orderSN, beginAt, endAt, now)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOrderItemDO returned error: %v", err)
|
||||
}
|
||||
if gconv.Float64(item.Price) != 123 {
|
||||
t.Fatalf("item price = %v, want totalAmount/quantity = 123", item.Price)
|
||||
}
|
||||
if gconv.Int(item.Quantity) != 2 {
|
||||
t.Fatalf("item quantity = %v, want 2", item.Quantity)
|
||||
}
|
||||
if item.CreatedAt == nil || !item.CreatedAt.Time.Equal(now) || item.UpdatedAt == nil || !item.UpdatedAt.Time.Equal(now) {
|
||||
t.Fatalf("item audit times = %v/%v, want %v", item.CreatedAt, item.UpdatedAt, now)
|
||||
}
|
||||
if got := item.ServiceEndTime.Time; !got.Equal(time.Date(2027, 9, 15, 0, 0, 0, 0, time.Local)) {
|
||||
t.Fatalf("service end = %s, want 2027-09-15", got)
|
||||
}
|
||||
if item.CreatedAt == nil || item.UpdatedAt == nil || !item.CreatedAt.Time.Equal(now) || !item.UpdatedAt.Time.Equal(now) {
|
||||
t.Fatalf("order item audit fields = %v/%v, want %v", item.CreatedAt, item.UpdatedAt, now)
|
||||
}
|
||||
|
||||
var extra map[string]any
|
||||
if err = json.Unmarshal([]byte(gconv.String(item.ExtraSkuData)), &extra); err != nil {
|
||||
t.Fatalf("extra_sku_data is invalid JSON: %v", err)
|
||||
}
|
||||
if gconv.String(extra["outboundBatchNo"]) != contract.BatchNo || gconv.Int(extra["durationMonths"]) != 12 || gconv.Int(extra["giftDurationMonths"]) != 1 {
|
||||
t.Fatalf("extra duration = %v+%v, want 12+1", extra["durationMonths"], extra["giftDurationMonths"])
|
||||
}
|
||||
if gconv.Float64(extra["amount"]) != 246 {
|
||||
t.Fatalf("extra amount = %v, want 246", extra["amount"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOrderUsesChangeOrderType(t *testing.T) {
|
||||
contract := validExecutionContract()
|
||||
contract.BatchType = consts.BatchTypeChange
|
||||
|
||||
order, err := BuildOrderDO(contract, "SO20260803100250CHANGE01", time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOrderDO returned error: %v", err)
|
||||
}
|
||||
if gconv.Int(order.OrderType) != consts.OrderTypeCardChange {
|
||||
t.Fatalf("order_type = %v, want change", order.OrderType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOrderUsesBalancePaymentSnapshot(t *testing.T) {
|
||||
contract := validExecutionContract()
|
||||
contract.OfflineContractCompleted = false
|
||||
contract.PaymentMode = "balance"
|
||||
contract.PayType = consts.PayTypeBalance
|
||||
contract.PayAmount = 120
|
||||
contract.BalanceAmount = 120
|
||||
contract.BalanceDetailSerialNo = "RENPAYREN20260803100250ABCDEFGH"
|
||||
contract.UnitPrice = 10
|
||||
contract.Quantity = 1
|
||||
contract.DurationMonths = 12
|
||||
|
||||
order, err := BuildOrderDO(contract, "SO20260803100250ABCDEFGH", time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOrderDO returned error: %v", err)
|
||||
}
|
||||
if gconv.Int(order.PayType) != consts.PayTypeBalance {
|
||||
t.Fatalf("pay_type = %v, want balance", order.PayType)
|
||||
}
|
||||
if gconv.String(order.PayOrderSn) != contract.BalanceDetailSerialNo {
|
||||
t.Fatalf("pay_order_sn = %q, want %q", order.PayOrderSn, contract.BalanceDetailSerialNo)
|
||||
}
|
||||
if gconv.Float64(order.Balance) != 120 || gconv.Float64(order.OfflinePaidAmount) != 0 {
|
||||
t.Fatalf("balance/offline = %v/%v, want 120/0", order.Balance, order.OfflinePaidAmount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalancePaymentSnapshotMustMatchTotalAmount(t *testing.T) {
|
||||
contract := validExecutionContract()
|
||||
contract.OfflineContractCompleted = false
|
||||
contract.PaymentMode = "balance"
|
||||
contract.PayType = consts.PayTypeBalance
|
||||
contract.PayAmount = 1
|
||||
contract.BalanceAmount = 1
|
||||
contract.BalanceDetailSerialNo = "RENPAYREN20260803100250ABCDEFGH"
|
||||
|
||||
if err := contract.Validate(); err == nil {
|
||||
t.Fatal("Validate accepted balance amount that does not match total amount")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOrderRejectsEmptyOrderNumber(t *testing.T) {
|
||||
if _, err := BuildOrderDO(validExecutionContract(), "", time.Now()); err == nil {
|
||||
t.Fatal("BuildOrderDO accepted an empty order number")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRenewCardUpdateOnlySetsRenewFields(t *testing.T) {
|
||||
contract := validExecutionContract()
|
||||
endAt := time.Date(2027, 8, 3, 0, 0, 0, 0, time.Local)
|
||||
now := time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local)
|
||||
update, err := BuildRenewCardUpdate(contract, endAt, consts.PackageStatusEffective, now)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRenewCardUpdate returned error: %v", err)
|
||||
}
|
||||
if update.FlowPkgEndAt == nil || !update.FlowPkgEndAt.Time.Equal(endAt) {
|
||||
t.Fatalf("flow_pkg_end_at = %v, want %v", update.FlowPkgEndAt, endAt)
|
||||
}
|
||||
if gconv.Int(update.PackageStatus) != consts.PackageStatusEffective || gconv.Int64(update.UpdatedBy) != contract.OperatorUserID {
|
||||
t.Fatalf("package_status/updated_by = %v/%v", update.PackageStatus, update.UpdatedBy)
|
||||
}
|
||||
if update.UpdatedAt == nil || !update.UpdatedAt.Time.Equal(now) {
|
||||
t.Fatalf("updated_at = %v, want %v", update.UpdatedAt, now)
|
||||
}
|
||||
assertOnlyRenewCardFieldsSet(t, update)
|
||||
}
|
||||
|
||||
func validExecutionContract() *ExecutionContract {
|
||||
return &ExecutionContract{
|
||||
BatchNo: "REN20260803100250ABCDEFGH",
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
TenantID: "000000",
|
||||
CustomerAgentID: 200,
|
||||
SellerAgentID: 100,
|
||||
CreatedDeptID: 100,
|
||||
OperatorUserID: 1,
|
||||
PackageCode: "PKG001",
|
||||
PackageName: "Year package",
|
||||
UnitPrice: 10,
|
||||
Quantity: 1,
|
||||
DurationMonths: 12,
|
||||
GiftDurationMonths: 0,
|
||||
OfflineContractCompleted: true,
|
||||
}
|
||||
}
|
||||
|
||||
func assertOnlyRenewCardFieldsSet(t *testing.T, update *do.TSimCardInfo) {
|
||||
t.Helper()
|
||||
allowed := map[string]bool{"FlowPkgEndAt": true, "PackageStatus": true, "UpdatedBy": true, "UpdatedAt": true}
|
||||
value := reflect.ValueOf(update).Elem()
|
||||
typ := value.Type()
|
||||
for i := 0; i < value.NumField(); i++ {
|
||||
fieldType := typ.Field(i)
|
||||
if fieldType.Name == "Meta" || allowed[fieldType.Name] {
|
||||
continue
|
||||
}
|
||||
field := value.Field(i)
|
||||
switch field.Kind() {
|
||||
case reflect.Interface, reflect.Pointer, reflect.Map, reflect.Slice:
|
||||
if !field.IsNil() {
|
||||
t.Fatalf("unexpected renew card field set: %s", fieldType.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,495 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func TestPrepareExecutorCanReclaimStaleProcessingBatch(t *testing.T) {
|
||||
now := time.Date(2026, 8, 26, 14, 0, 0, 0, time.Local)
|
||||
executor := &PrepareExecutor{nowFunc: func() time.Time { return now }}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
main *entity.BatchMain
|
||||
canRun bool
|
||||
}{
|
||||
{
|
||||
name: "fresh processing batch",
|
||||
main: &entity.BatchMain{
|
||||
BatchType: consts.BatchTypePrepare,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
StartTime: gtime.NewFromTime(now.Add(-prepareProcessingRetryAfter + time.Second)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stale processing batch",
|
||||
main: &entity.BatchMain{
|
||||
BatchType: consts.BatchTypePrepare,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
StartTime: gtime.NewFromTime(now.Add(-prepareProcessingRetryAfter)),
|
||||
},
|
||||
canRun: true,
|
||||
},
|
||||
{
|
||||
name: "missing start time",
|
||||
main: &entity.BatchMain{
|
||||
BatchType: consts.BatchTypePrepare,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
},
|
||||
canRun: true,
|
||||
},
|
||||
{
|
||||
name: "terminal batch",
|
||||
main: &entity.BatchMain{
|
||||
BatchType: consts.BatchTypePrepare,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := executor.CanExecuteProcessing(context.Background(), test.main)
|
||||
if err != nil {
|
||||
t.Fatalf("CanExecuteProcessing() error = %v", err)
|
||||
}
|
||||
if got != test.canRun {
|
||||
t.Fatalf("CanExecuteProcessing() = %v, want %v", got, test.canRun)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPrepareOptionsDurationIsExplicitOnly(t *testing.T) {
|
||||
opts := loadPrepareOptions(gjson.New(map[string]any{
|
||||
"targetBatchType": 101,
|
||||
}))
|
||||
if opts.durationSet {
|
||||
t.Fatalf("durationSet = true, want false")
|
||||
}
|
||||
if opts.durationValue != 0 {
|
||||
t.Fatalf("durationValue = %d, want 0", opts.durationValue)
|
||||
}
|
||||
|
||||
opts = loadPrepareOptions(gjson.New(map[string]any{
|
||||
"durationMonths": 6,
|
||||
}))
|
||||
if !opts.durationSet {
|
||||
t.Fatalf("durationSet = false, want true")
|
||||
}
|
||||
if opts.durationValue != 6 {
|
||||
t.Fatalf("durationValue = %d, want 6", opts.durationValue)
|
||||
}
|
||||
|
||||
opts = loadPrepareOptions(gjson.New(map[string]any{
|
||||
"durationMonths": 12,
|
||||
}))
|
||||
if !opts.durationSet {
|
||||
t.Fatalf("durationSet = false, want true")
|
||||
}
|
||||
if opts.durationValue != 1 {
|
||||
t.Fatalf("durationValue = %d, want 1", opts.durationValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeDurationOptionsUseMonthsAtExecutionBoundary(t *testing.T) {
|
||||
got, err := changeDurationOptions(gjson.New([]int{1, 2, 3}))
|
||||
if err != nil {
|
||||
t.Fatalf("changeDurationOptions(years) error = %v", err)
|
||||
}
|
||||
if want := []int{12, 24, 36}; len(got) != len(want) {
|
||||
t.Fatalf("changeDurationOptions(years) = %#v, want %#v", got, want)
|
||||
} else {
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("changeDurationOptions(years) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
got, err = changeDurationOptions(gjson.New([]int{12, 24, 36}))
|
||||
if err != nil {
|
||||
t.Fatalf("changeDurationOptions(legacy months) error = %v", err)
|
||||
}
|
||||
if want := []int{12, 24, 36}; len(got) != len(want) {
|
||||
t.Fatalf("changeDurationOptions(legacy months) = %#v, want %#v", got, want)
|
||||
} else {
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("changeDurationOptions(legacy months) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeRowErrorsKeepsValidationReasons(t *testing.T) {
|
||||
got := mergeRowErrors(
|
||||
map[int]string{2: "parse failed"},
|
||||
map[int]string{1: "duration not allowed", 2: "validation failed", 0: "batch failed"},
|
||||
)
|
||||
|
||||
if got[1] != "duration not allowed" {
|
||||
t.Fatalf("row 1 reason = %q", got[1])
|
||||
}
|
||||
if got[2] != "parse failed" {
|
||||
t.Fatalf("row 2 reason = %q", got[2])
|
||||
}
|
||||
if got[0] != "batch failed" {
|
||||
t.Fatalf("row 0 reason = %q", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrepareIdentityRowsFromCardsCanonicalizesMSISDN(t *testing.T) {
|
||||
rows := []*RenewRow{
|
||||
{RowNo: 1, ICCID: "13800008000"},
|
||||
{RowNo: 2, ICCID: "8986002", MSISDN: "bad-msisdn"},
|
||||
}
|
||||
rowErrors := map[int]string{}
|
||||
|
||||
resolvePrepareIdentityRowsFromCards(rows, rowErrors, []*rc.SimCardInfo{
|
||||
{Id: 1, Iccid: "8986001", Msisdn: "13800008000", Imsi: "460001"},
|
||||
{Id: 2, Iccid: "8986002", Msisdn: "13800008002", Imsi: "460002"},
|
||||
})
|
||||
|
||||
if rows[0].ICCID != "8986001" || rows[0].MSISDN != "13800008000" || rows[0].IMSI != "460001" {
|
||||
t.Fatalf("row 1 was not canonicalized: %#v", rows[0])
|
||||
}
|
||||
if rowErrors[1] != "" {
|
||||
t.Fatalf("row 1 error = %q, want empty", rowErrors[1])
|
||||
}
|
||||
if rowErrors[2] == "" {
|
||||
t.Fatal("row 2 should fail when ICCID and MSISDN point to different identities")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectPrepareIccidsMarksDuplicateAfterCanonicalization(t *testing.T) {
|
||||
rows := []*RenewRow{
|
||||
{RowNo: 1, ICCID: "8986001"},
|
||||
{RowNo: 2, ICCID: "8986001"},
|
||||
}
|
||||
rowErrors := map[int]string{}
|
||||
|
||||
got := collectPrepareIccids(rows, rowErrors)
|
||||
|
||||
if len(got) != 1 || got[0] != "8986001" {
|
||||
t.Fatalf("iccids = %#v, want single canonical ICCID", got)
|
||||
}
|
||||
if rowErrors[1] == "" || rowErrors[2] == "" {
|
||||
t.Fatalf("duplicate errors not marked: %#v", rowErrors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrepareInfoWritesRenewFlatContract(t *testing.T) {
|
||||
got := buildPrepareInfo(nil, 101, &prepareOutcome{
|
||||
customer: &customerInfo{
|
||||
AgentID: 9,
|
||||
DeptID: 117,
|
||||
Name: "customer",
|
||||
},
|
||||
priceSnap: &RenewPriceSnapshot{
|
||||
CustomerAgentId: 117,
|
||||
PackageCode: "PKG-1",
|
||||
PackageName: "package 1",
|
||||
PriceSource: PriceSourceSystem,
|
||||
AgentDefaultPriceId: 11,
|
||||
TargetPriceId: 22,
|
||||
RenewUnitPrice: 3.5,
|
||||
RenewableDurationOptions: []int{1, 3, 12},
|
||||
RenewBeginDate: "2026-08-31",
|
||||
RenewBeginDateMin: "2026-08-31",
|
||||
RenewBeginDateMax: "2026-08-31",
|
||||
SameRenewBeginDate: true,
|
||||
},
|
||||
}, 2)
|
||||
|
||||
if got["customerAgentId"] != int64(117) {
|
||||
t.Fatalf("customerAgentId = %v, want 117", got["customerAgentId"])
|
||||
}
|
||||
if got["packageCode"] != "PKG-1" {
|
||||
t.Fatalf("packageCode = %v, want PKG-1", got["packageCode"])
|
||||
}
|
||||
if got["quantity"] != 2 {
|
||||
t.Fatalf("quantity = %v, want 2", got["quantity"])
|
||||
}
|
||||
if got["renewUnitPrice"] != 3.5 {
|
||||
t.Fatalf("renewUnitPrice = %v, want 3.5", got["renewUnitPrice"])
|
||||
}
|
||||
if got["priceSnapshot"] == nil {
|
||||
t.Fatalf("priceSnapshot is nil")
|
||||
}
|
||||
if got["renewBeginDate"] != "2026-08-31" {
|
||||
t.Fatalf("renewBeginDate = %v, want 2026-08-31", got["renewBeginDate"])
|
||||
}
|
||||
if got["sameRenewBeginDate"] != true {
|
||||
t.Fatalf("sameRenewBeginDate = %v, want true", got["sameRenewBeginDate"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillRenewBeginSnapshotSummarizesRenewBeginDate(t *testing.T) {
|
||||
snapshot := &RenewPriceSnapshot{}
|
||||
fillRenewBeginSnapshot(snapshot, []*RenewRow{
|
||||
{RowNo: 1, OldExpireTime: gtime.NewFromStr("2026-09-10 00:00:00")},
|
||||
{RowNo: 2, OldExpireTime: gtime.NewFromStr("2026-08-31 23:59:59")},
|
||||
}, nil)
|
||||
|
||||
if snapshot.RenewBeginDate != "" {
|
||||
t.Fatalf("renewBeginDate = %q, want empty for mixed dates", snapshot.RenewBeginDate)
|
||||
}
|
||||
if snapshot.RenewBeginDateMin != "2026-08-31" {
|
||||
t.Fatalf("renewBeginDateMin = %q, want 2026-08-31", snapshot.RenewBeginDateMin)
|
||||
}
|
||||
if snapshot.RenewBeginDateMax != "2026-09-10" {
|
||||
t.Fatalf("renewBeginDateMax = %q, want 2026-09-10", snapshot.RenewBeginDateMax)
|
||||
}
|
||||
if snapshot.SameRenewBeginDate {
|
||||
t.Fatalf("sameRenewBeginDate = true, want false")
|
||||
}
|
||||
|
||||
fillRenewBeginSnapshot(snapshot, []*RenewRow{
|
||||
{RowNo: 1, OldExpireTime: gtime.NewFromStr("2026-10-01 00:00:00")},
|
||||
{RowNo: 2, OldExpireTime: gtime.NewFromStr("2026-10-01 23:59:59")},
|
||||
}, nil)
|
||||
|
||||
if snapshot.RenewBeginDate != "2026-10-01" {
|
||||
t.Fatalf("renewBeginDate = %q, want 2026-10-01", snapshot.RenewBeginDate)
|
||||
}
|
||||
if !snapshot.SameRenewBeginDate {
|
||||
t.Fatalf("sameRenewBeginDate = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewBeginDateFromExpireUsesExpireDate(t *testing.T) {
|
||||
beginAt := renewBeginDateFromExpire(gtime.NewFromStr("2026-08-15 00:00:00"))
|
||||
if beginAt == nil {
|
||||
t.Fatal("renew begin date is nil")
|
||||
}
|
||||
if got := beginAt.Format("Y-m-d H:i:s"); got != "2026-08-15 23:59:59" {
|
||||
t.Fatalf("renew begin date = %s, want 2026-08-15 23:59:59", got)
|
||||
}
|
||||
if got := addMonths(beginAt.Time, 12).Format("2006-01-02 15:04:05"); got != "2027-08-15 23:59:59" {
|
||||
t.Fatalf("renew end date = %s, want 2027-08-15 23:59:59", got)
|
||||
}
|
||||
|
||||
nextBeginAt := renewBeginDateFromExpire(gtime.NewFromStr("2027-07-31 00:00:00"))
|
||||
if nextBeginAt == nil {
|
||||
t.Fatal("next renew begin date is nil")
|
||||
}
|
||||
if got := nextBeginAt.Format("Y-m-d H:i:s"); got != "2027-07-31 23:59:59" {
|
||||
t.Fatalf("next renew begin date = %s, want 2027-07-31 23:59:59", got)
|
||||
}
|
||||
|
||||
giftBeginAt := renewBeginDateFromExpire(gtime.NewFromStr("2026-08-31 00:00:00"))
|
||||
if giftBeginAt == nil {
|
||||
t.Fatal("gift renew begin date is nil")
|
||||
}
|
||||
if got := giftBeginAt.Format("Y-m-d H:i:s"); got != "2026-08-31 23:59:59" {
|
||||
t.Fatalf("gift renew begin date = %s, want 2026-08-31 23:59:59", got)
|
||||
}
|
||||
if got := addMonths(giftBeginAt.Time, 13).Format("2006-01-02 15:04:05"); got != "2027-09-30 23:59:59" {
|
||||
t.Fatalf("renew end date with gift month = %s, want 2027-09-30 23:59:59", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewSameCurrentPeriod(t *testing.T) {
|
||||
sameRows := []*RenewRow{
|
||||
{
|
||||
RowNo: 1,
|
||||
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
|
||||
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
|
||||
},
|
||||
{
|
||||
RowNo: 2,
|
||||
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
|
||||
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
|
||||
},
|
||||
}
|
||||
if err := validateRenewSameCurrentPeriod(sameRows, nil); err != nil {
|
||||
t.Fatalf("validateRenewSameCurrentPeriod() error = %v", err)
|
||||
}
|
||||
|
||||
diffBeginRows := []*RenewRow{
|
||||
{
|
||||
RowNo: 1,
|
||||
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
|
||||
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
|
||||
},
|
||||
{
|
||||
RowNo: 2,
|
||||
OldEffTime: gtime.NewFromStr("2026-02-01 00:00:00"),
|
||||
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
|
||||
},
|
||||
}
|
||||
if err := validateRenewSameCurrentPeriod(diffBeginRows, nil); err == nil {
|
||||
t.Fatalf("validateRenewSameCurrentPeriod() error = nil, want begin mismatch")
|
||||
}
|
||||
|
||||
diffExpireRows := []*RenewRow{
|
||||
{
|
||||
RowNo: 1,
|
||||
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
|
||||
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
|
||||
},
|
||||
{
|
||||
RowNo: 2,
|
||||
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
|
||||
OldExpireTime: gtime.NewFromStr("2027-01-31 23:59:59"),
|
||||
},
|
||||
}
|
||||
if err := validateRenewSameCurrentPeriod(diffExpireRows, nil); err == nil {
|
||||
t.Fatalf("validateRenewSameCurrentPeriod() error = nil, want expire mismatch")
|
||||
}
|
||||
|
||||
missingTimeRows := []*RenewRow{
|
||||
{
|
||||
RowNo: 1,
|
||||
OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00"),
|
||||
OldExpireTime: gtime.NewFromStr("2026-12-31 23:59:59"),
|
||||
},
|
||||
{RowNo: 2, OldEffTime: gtime.NewFromStr("2026-01-01 00:00:00")},
|
||||
}
|
||||
if err := validateRenewSameCurrentPeriod(missingTimeRows, nil); err == nil {
|
||||
t.Fatalf("validateRenewSameCurrentPeriod() error = nil, want missing time")
|
||||
}
|
||||
|
||||
rowErrors := map[int]string{2: "existing validation error"}
|
||||
if err := validateRenewSameCurrentPeriod(missingTimeRows, rowErrors); err != nil {
|
||||
t.Fatalf("validateRenewSameCurrentPeriod() with rowErrors error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewPriceOwnerUsesCurrentCustomerParent(t *testing.T) {
|
||||
if got := renewPriceOwnerDeptID(&rc.SysDeptRow{DeptId: 117, ParentId: 100}); got != 100 {
|
||||
t.Fatalf("price owner dept = %d, want 100", got)
|
||||
}
|
||||
if got := renewPriceOwnerDeptID(&rc.SysDeptRow{DeptId: 100, ParentId: 0}); got != 0 {
|
||||
t.Fatalf("top-level price owner dept = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewExpireAfterAllowedWindow(t *testing.T) {
|
||||
now := time.Date(2026, 8, 7, 16, 50, 39, 0, time.Local)
|
||||
|
||||
if renewExpireAfterAllowedWindow(nil, now) {
|
||||
t.Fatal("nil expire time should not be rejected")
|
||||
}
|
||||
if renewExpireAfterAllowedWindow(gtime.NewFromTime(now.AddDate(0, 6, 0)), now) {
|
||||
t.Fatal("exactly 6 months ahead should be allowed")
|
||||
}
|
||||
if !renewExpireAfterAllowedWindow(gtime.NewFromTime(now.AddDate(0, 6, 0).Add(time.Second)), now) {
|
||||
t.Fatal("beyond 6 months should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrepareInfoWritesChangeFlatContract(t *testing.T) {
|
||||
got := buildPrepareInfo(nil, 102, &prepareOutcome{
|
||||
customer: &customerInfo{
|
||||
DeptID: 117,
|
||||
Name: "customer",
|
||||
},
|
||||
changeSnap: &ChangePrepareSnapshot{
|
||||
SourceBatchNo: "CHG-1",
|
||||
CustomerAgentId: 117,
|
||||
CustomerName: "customer",
|
||||
OldPackageCode: "OLD",
|
||||
OldPackageName: "old package",
|
||||
OldPackageType: 101,
|
||||
OldPackageFlowMb: 1024,
|
||||
Quantity: 1,
|
||||
},
|
||||
changePrice: &ChangePriceSnapshot{
|
||||
CustomerAgentId: 117,
|
||||
NewPackageCode: "NEW",
|
||||
NewPackageName: "new package",
|
||||
PriceSource: PriceSourceAgentDefault,
|
||||
AgentDefaultPriceId: 33,
|
||||
TargetPriceId: 44,
|
||||
ChangeUnitPrice: 6.5,
|
||||
RenewableDurationOptions: []int{1, 2},
|
||||
},
|
||||
}, 1)
|
||||
|
||||
if got["customerAgentId"] != int64(117) {
|
||||
t.Fatalf("customerAgentId = %v, want 117", got["customerAgentId"])
|
||||
}
|
||||
if got["sourceBatchNo"] != "CHG-1" {
|
||||
t.Fatalf("sourceBatchNo = %v, want CHG-1", got["sourceBatchNo"])
|
||||
}
|
||||
if got["oldPackageCode"] != "OLD" {
|
||||
t.Fatalf("oldPackageCode = %v, want OLD", got["oldPackageCode"])
|
||||
}
|
||||
if got["newPackageCode"] != "NEW" {
|
||||
t.Fatalf("newPackageCode = %v, want NEW", got["newPackageCode"])
|
||||
}
|
||||
if got["changeUnitPrice"] != 6.5 {
|
||||
t.Fatalf("changeUnitPrice = %v, want 6.5", got["changeUnitPrice"])
|
||||
}
|
||||
if got["changePrepareSnapshot"] == nil || got["changePriceSnapshot"] == nil {
|
||||
t.Fatalf("change snapshots are missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrepareDetailSnapshotIncludesChangeSubscriptionContract(t *testing.T) {
|
||||
out := &prepareOutcome{
|
||||
rows: map[int]*RenewRow{
|
||||
1: {
|
||||
RowNo: 1,
|
||||
ICCID: "8986001",
|
||||
MSISDN: "13800000000",
|
||||
IMSI: "460001",
|
||||
OldPkgCode: "OLD",
|
||||
OldPkgName: "old package",
|
||||
OldBindSubNo: "SUB-OLD",
|
||||
OldBindOrdNo: "SO-OLD",
|
||||
SourceBatchNo: "OUT-1",
|
||||
},
|
||||
},
|
||||
changePrice: &ChangePriceSnapshot{
|
||||
NewPackageCode: "NEW",
|
||||
NewPackageName: "new package",
|
||||
},
|
||||
}
|
||||
snapshot := buildPrepareDetailSnapshot(&rc.BatchDetailRow{RowNo: 1}, out, 102)
|
||||
want := map[string]string{
|
||||
"oldPackageCode": "OLD",
|
||||
"oldPackageName": "old package",
|
||||
"oldSubscriptionNo": "SUB-OLD",
|
||||
"oldOrderSn": "SO-OLD",
|
||||
"sourceBatchNo": "OUT-1",
|
||||
"newPackageCode": "NEW",
|
||||
"newPackageName": "new package",
|
||||
}
|
||||
for key, expected := range want {
|
||||
if got := snapshot.Get(key).String(); got != expected {
|
||||
t.Fatalf("%s = %q, want %q", key, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceByPackageUsesAdminContractFormula(t *testing.T) {
|
||||
pkg := &rc.FlowPackageInfo{
|
||||
CostPrice: 10,
|
||||
SysDefaultRatio: 120,
|
||||
SysDefaultProfit: 1.23,
|
||||
}
|
||||
|
||||
if got := priceByPackage(pkg, nil); got != 13.23 {
|
||||
t.Fatalf("system price = %v, want 13.23", got)
|
||||
}
|
||||
|
||||
got := priceByPackage(pkg, &rc.FlowPackagePrice{
|
||||
AgentCostRatio: 80,
|
||||
AgentCostProfit: 2.5,
|
||||
})
|
||||
if got != 10.5 {
|
||||
t.Fatalf("override price = %v, want 10.5", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
type renewEffectivePriceStore interface {
|
||||
LoadDept(ctx context.Context, tenantID string, deptID int64) (*rc.SysDeptRow, error)
|
||||
LoadPriceOverride(ctx context.Context, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*rc.FlowPackagePrice, error)
|
||||
}
|
||||
|
||||
type renewEffectivePriceRule struct {
|
||||
PriceSource string
|
||||
AgentDefaultPriceId int64
|
||||
TargetPriceId int64
|
||||
AgentCostRatio float64
|
||||
AgentCostProfit float64
|
||||
}
|
||||
|
||||
type renewEffectiveSalePrice struct {
|
||||
BaseAgentCost float64
|
||||
Rule renewEffectivePriceRule
|
||||
AgentShipmentPrice float64
|
||||
}
|
||||
|
||||
type EffectivePriceStore = renewEffectivePriceStore
|
||||
type EffectivePriceRule = renewEffectivePriceRule
|
||||
type EffectiveSalePrice = renewEffectiveSalePrice
|
||||
|
||||
type renewTxEffectivePriceStore struct {
|
||||
tx gdb.TX
|
||||
}
|
||||
|
||||
func (s renewTxEffectivePriceStore) LoadDept(ctx context.Context, tenantID string, deptID int64) (*rc.SysDeptRow, error) {
|
||||
return loadRenewDeptTx(ctx, s.tx, tenantID, deptID)
|
||||
}
|
||||
|
||||
func (s renewTxEffectivePriceStore) LoadPriceOverride(ctx context.Context, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*rc.FlowPackagePrice, error) {
|
||||
return loadRenewPriceOverrideTx(ctx, s.tx, tenantID, deptID, targetAgentID, scopeType, packageCode)
|
||||
}
|
||||
|
||||
func ResolveEffectiveSalePrice(ctx context.Context, store EffectivePriceStore, tenantID string, ownerDeptID int64, targetDeptID int64, pkg *rc.FlowPackageInfo) (EffectiveSalePrice, error) {
|
||||
return resolveRenewEffectiveSalePrice(ctx, store, tenantID, ownerDeptID, targetDeptID, pkg)
|
||||
}
|
||||
|
||||
func resolveRenewEffectiveSalePrice(ctx context.Context, store renewEffectivePriceStore, tenantID string, ownerDeptID int64, targetDeptID int64, pkg *rc.FlowPackageInfo) (renewEffectiveSalePrice, error) {
|
||||
baseCost, err := resolveRenewEffectiveBaseCost(ctx, store, tenantID, ownerDeptID, pkg, make(map[int64]struct{}))
|
||||
if err != nil {
|
||||
return renewEffectiveSalePrice{}, err
|
||||
}
|
||||
rule, err := resolveRenewEffectivePriceRule(ctx, store, tenantID, ownerDeptID, targetDeptID, pkg)
|
||||
if err != nil {
|
||||
return renewEffectiveSalePrice{}, err
|
||||
}
|
||||
return renewEffectiveSalePrice{
|
||||
BaseAgentCost: baseCost,
|
||||
Rule: rule,
|
||||
AgentShipmentPrice: rc.RoundAmount(baseCost*rule.AgentCostRatio + rule.AgentCostProfit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveRenewEffectiveBaseCost(ctx context.Context, store renewEffectivePriceStore, tenantID string, deptID int64, pkg *rc.FlowPackageInfo, visited map[int64]struct{}) (float64, error) {
|
||||
if pkg == nil || strings.TrimSpace(pkg.PackageCode) == "" {
|
||||
return 0, gerror.New("套餐编码不能为空")
|
||||
}
|
||||
if deptID <= 0 {
|
||||
return rc.RoundAmount(pkg.CostPrice), nil
|
||||
}
|
||||
if _, ok := visited[deptID]; ok {
|
||||
return 0, gerror.New("组织定价层级存在循环,无法计算生效成本")
|
||||
}
|
||||
visited[deptID] = struct{}{}
|
||||
|
||||
dept, err := store.LoadDept(ctx, tenantID, deptID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if dept == nil || dept.ParentId <= 0 {
|
||||
return rc.RoundAmount(pkg.CostPrice), nil
|
||||
}
|
||||
|
||||
parentBaseCost, err := resolveRenewEffectiveBaseCost(ctx, store, tenantID, dept.ParentId, pkg, visited)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
parentRule, err := resolveRenewEffectivePriceRule(ctx, store, tenantID, dept.ParentId, deptID, pkg)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rc.RoundAmount(parentBaseCost*parentRule.AgentCostRatio + parentRule.AgentCostProfit), nil
|
||||
}
|
||||
|
||||
func resolveRenewEffectivePriceRule(ctx context.Context, store renewEffectivePriceStore, tenantID string, ownerDeptID int64, targetDeptID int64, pkg *rc.FlowPackageInfo) (renewEffectivePriceRule, error) {
|
||||
rule := renewSystemPriceRule(pkg)
|
||||
if ownerDeptID <= 0 {
|
||||
return rule, nil
|
||||
}
|
||||
if targetDeptID > 0 {
|
||||
targetPrice, err := store.LoadPriceOverride(ctx, tenantID, ownerDeptID, targetDeptID, PriceScopeTargetAgent, pkg.PackageCode)
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
if targetPrice != nil {
|
||||
return renewRuleFromOverride(PriceSourceTarget, targetPrice), nil
|
||||
}
|
||||
}
|
||||
defaultPrice, err := store.LoadPriceOverride(ctx, tenantID, ownerDeptID, 0, PriceScopeAgentDefault, pkg.PackageCode)
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
if defaultPrice != nil {
|
||||
return renewRuleFromOverride(PriceSourceAgentDefault, defaultPrice), nil
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func renewSystemPriceRule(pkg *rc.FlowPackageInfo) renewEffectivePriceRule {
|
||||
if pkg == nil {
|
||||
return renewEffectivePriceRule{PriceSource: PriceSourceSystem}
|
||||
}
|
||||
return renewEffectivePriceRule{
|
||||
PriceSource: PriceSourceSystem,
|
||||
AgentCostRatio: normalizePriceRatio(pkg.SysDefaultRatio),
|
||||
AgentCostProfit: pkg.SysDefaultProfit,
|
||||
}
|
||||
}
|
||||
|
||||
func renewRuleFromOverride(source string, price *rc.FlowPackagePrice) renewEffectivePriceRule {
|
||||
rule := renewEffectivePriceRule{
|
||||
PriceSource: source,
|
||||
AgentCostRatio: normalizePriceRatio(price.AgentCostRatio),
|
||||
AgentCostProfit: price.AgentCostProfit,
|
||||
}
|
||||
if source == PriceSourceTarget {
|
||||
rule.TargetPriceId = price.Id
|
||||
} else {
|
||||
rule.AgentDefaultPriceId = price.Id
|
||||
}
|
||||
return rule
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
type fakeRenewEffectivePriceStore struct {
|
||||
depts map[int64]*rc.SysDeptRow
|
||||
prices map[renewEffectivePriceKey]*rc.FlowPackagePrice
|
||||
}
|
||||
|
||||
type renewEffectivePriceKey struct {
|
||||
deptID int64
|
||||
targetAgentID int64
|
||||
scopeType int
|
||||
packageCode string
|
||||
}
|
||||
|
||||
func (s fakeRenewEffectivePriceStore) LoadDept(ctx context.Context, tenantID string, deptID int64) (*rc.SysDeptRow, error) {
|
||||
return s.depts[deptID], nil
|
||||
}
|
||||
|
||||
func (s fakeRenewEffectivePriceStore) LoadPriceOverride(ctx context.Context, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*rc.FlowPackagePrice, error) {
|
||||
return s.prices[renewEffectivePriceKey{
|
||||
deptID: deptID,
|
||||
targetAgentID: targetAgentID,
|
||||
scopeType: scopeType,
|
||||
packageCode: packageCode,
|
||||
}], nil
|
||||
}
|
||||
|
||||
func TestResolveRenewEffectiveSalePriceUsesRecursiveParentCost(t *testing.T) {
|
||||
store := fakeRenewEffectivePriceStore{
|
||||
depts: map[int64]*rc.SysDeptRow{
|
||||
100: {DeptId: 100, ParentId: 0},
|
||||
117: {DeptId: 117, ParentId: 100},
|
||||
131: {DeptId: 131, ParentId: 117},
|
||||
},
|
||||
prices: map[renewEffectivePriceKey]*rc.FlowPackagePrice{
|
||||
{deptID: 100, targetAgentID: 117, scopeType: PriceScopeTargetAgent, packageCode: "yd-100m-3y"}: {
|
||||
Id: 86,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 4,
|
||||
},
|
||||
{deptID: 117, targetAgentID: 0, scopeType: PriceScopeAgentDefault, packageCode: "yd-100m-3y"}: {
|
||||
Id: 91,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 1,
|
||||
},
|
||||
{deptID: 117, targetAgentID: 131, scopeType: PriceScopeTargetAgent, packageCode: "yd-100m-3y"}: {
|
||||
Id: 92,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := resolveRenewEffectiveSalePrice(
|
||||
context.Background(),
|
||||
store,
|
||||
"000000",
|
||||
117,
|
||||
131,
|
||||
&rc.FlowPackageInfo{
|
||||
PackageCode: "yd-100m-3y",
|
||||
CostPrice: 10,
|
||||
SysDefaultRatio: 1,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRenewEffectiveSalePrice() error = %v", err)
|
||||
}
|
||||
if got.BaseAgentCost != 14 {
|
||||
t.Fatalf("base agent cost = %.2f, want 14.00", got.BaseAgentCost)
|
||||
}
|
||||
if got.AgentShipmentPrice != 19 {
|
||||
t.Fatalf("agent shipment price = %.2f, want 19.00", got.AgentShipmentPrice)
|
||||
}
|
||||
if got.Rule.PriceSource != PriceSourceTarget || got.Rule.TargetPriceId != 92 {
|
||||
t.Fatalf("rule = %+v, want target price 92", got.Rule)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
var errRenewAlreadyHandled = errors.New("renew batch was already handled")
|
||||
|
||||
// RenewExecutor executes a confirmed type=101 batch. All business writes and
|
||||
// the success state are committed by one database transaction.
|
||||
type RenewExecutor struct {
|
||||
repo *Repository
|
||||
nowFunc NowFunc
|
||||
}
|
||||
|
||||
func NewRenewExecutor(repo *Repository) *RenewExecutor {
|
||||
if repo == nil {
|
||||
repo = NewRepository()
|
||||
}
|
||||
return &RenewExecutor{repo: repo, nowFunc: timeNow}
|
||||
}
|
||||
|
||||
func (r *RenewExecutor) SetNowFunc(now NowFunc) { r.nowFunc = now }
|
||||
|
||||
func (r *RenewExecutor) Execute(ctx context.Context, batchNo string) error {
|
||||
batchNo = strings.TrimSpace(batchNo)
|
||||
if batchNo == "" {
|
||||
return gerror.New("renew batch number is empty")
|
||||
}
|
||||
|
||||
main, err := r.repo.LoadProcessingBatch(ctx, batchNo, consts.BatchTypeRenew)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(main.OrderSn) != "" {
|
||||
g.Log().Warningf(ctx, "renew batch already has order, skip duplicate: batchNo=%s orderSn=%s", batchNo, main.OrderSn)
|
||||
return nil
|
||||
}
|
||||
details, err := r.repo.LoadRunningDetails(ctx, batchNo, consts.BatchTypeRenew)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load renew details failed")
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return r.finalizeFailure(ctx, main, "renew batch has no runnable details")
|
||||
}
|
||||
now := pnow(r.nowFunc)
|
||||
orderSN, err := r.apply(ctx, main, details, now)
|
||||
if errors.Is(err, errRenewAlreadyHandled) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return r.finalizeFailure(ctx, main, err.Error())
|
||||
}
|
||||
|
||||
snap, _ := r.repo.ResolveResultSnapshot(ctx, main.BatchNo, main.BatchType)
|
||||
_ = r.repo.WriteResultFileSafe(ctx, main.BatchNo, main.BatchType, main.TenantId, main.CreatedDept, main.CreatedBy, snap)
|
||||
g.Log().Infof(ctx, "renew batch success, batchNo=%s, orderSn=%s, tenant=%s", main.BatchNo, orderSN, main.TenantId)
|
||||
return nil
|
||||
}
|
||||
|
||||
type renewExecution struct {
|
||||
detail *rc.BatchDetailRow
|
||||
card *rc.SimCardInfo
|
||||
bind *rc.SimCardBindPkg
|
||||
begin time.Time
|
||||
end time.Time
|
||||
}
|
||||
|
||||
func (r *RenewExecutor) apply(ctx context.Context, main *rc.BatchMainRow, details []*rc.BatchDetailRow, now time.Time) (string, error) {
|
||||
iccids, err := strictIccidsFromDetails(details)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var orderSN string
|
||||
err = r.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
lockedMain, err := r.repo.Tables().LockRenewBatchMain(ctx, tx, main.BatchNo, consts.BatchTypeRenew)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(lockedMain.OrderSn) != "" || lockedMain.BatchAsyncStatus != consts.BatchAsyncStatusProcessing {
|
||||
return errRenewAlreadyHandled
|
||||
}
|
||||
lockedDetails, err := r.repo.Tables().LockRenewDetails(ctx, tx, main.BatchNo, consts.BatchTypeRenew)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = validateRunningRenewDetails(lockedDetails, details); err != nil {
|
||||
return err
|
||||
}
|
||||
details = lockedDetails
|
||||
|
||||
cards, err := r.repo.Tables().LockCardsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bindRows, err := r.repo.Tables().LockRenewBindRowsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
binds, futureBinds, err := resolveCurrentSubscriptions(bindRows, iccids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contract, err := r.ensureRenewExecutionContract(ctx, tx, lockedMain, details, cards, binds, futureBinds, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
executions, err := validateRenewExecutions(contract, details, cards, binds, futureBinds, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkg, err := r.repo.Tables().LoadRenewPackage(ctx, tx, contract.TenantID, contract.PackageCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
orderSN, err = rc.NextOrderSN(now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
order, err := BuildOrderDO(contract, orderSN, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
orderID, err := r.repo.Tables().InsertContractOrder(ctx, tx, order)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "insert renew order failed")
|
||||
}
|
||||
minBegin, maxEnd := renewServiceRange(executions)
|
||||
item, err := BuildOrderItemDO(contract, OrderProductContract{
|
||||
ID: pkg.Id,
|
||||
Code: pkg.PackageCode,
|
||||
Name: pkg.PackageName,
|
||||
Type: pkg.PackageType,
|
||||
}, orderID, orderSN, minBegin, maxEnd, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = r.repo.Tables().InsertContractOrderItem(ctx, tx, item); err != nil {
|
||||
return gerror.Wrap(err, "insert renew order item failed")
|
||||
}
|
||||
|
||||
for _, execution := range executions {
|
||||
subscriptionNo, err := generateRenewSubscriptionNo(now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newBind := buildRenewBind(contract, pkg, execution, orderSN, subscriptionNo, now)
|
||||
if err = r.repo.Tables().InsertBind(ctx, tx, newBind); err != nil {
|
||||
return err
|
||||
}
|
||||
packageStatus := renewPackageStatus(now, execution.card.FlowPkgBeginAt, newBind.ExpectedExpireTime)
|
||||
cardUpdate, err := BuildRenewCardUpdate(contract, execution.end, packageStatus, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = r.repo.Tables().UpdateRenewCard(ctx, tx, execution.card.Iccid, cardUpdate); err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot := buildRenewDetailSnapshot(execution, contract, pkg, orderSN, subscriptionNo)
|
||||
if err = r.repo.MarkDetailSuccess(ctx, tx, main.BatchNo, consts.BatchTypeRenew, execution.detail.RowNo, snapshot, contract.OperatorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
info := mergeBatchInfo(lockedMain.BatchInfo, map[string]any{
|
||||
"orderSn": orderSN,
|
||||
"packageCode": contract.PackageCode,
|
||||
"packageName": pkg.PackageName,
|
||||
"packageType": pkg.PackageType,
|
||||
"billingMethod": contract.BillingMethod,
|
||||
"renewDurationUnit": contract.RenewDurationUnit,
|
||||
"renewDurationUnitText": contract.RenewDurationUnitText,
|
||||
"durationValue": contract.DisplayDurationValue(),
|
||||
"actualDurationMonths": contract.DurationMonths,
|
||||
"durationMonths": contract.DurationMonths,
|
||||
"giftDurationMonths": contract.GiftDurationMonths,
|
||||
"amount": contract.TotalAmount(),
|
||||
"quantity": contract.Quantity,
|
||||
"renewTime": now.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
if err = r.repo.UpdateBatchMainInfo(ctx, tx, main.BatchNo, consts.BatchTypeRenew, &rc.BatchMainRow{
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
|
||||
RunResult: consts.BatchRunResultSuccess,
|
||||
TotalCount: lockedMain.TotalCount,
|
||||
ValidCount: len(executions),
|
||||
SuccessCount: len(executions),
|
||||
FailCount: 0,
|
||||
BatchInfo: gjson.New(info),
|
||||
OrderSn: orderSN,
|
||||
StartTime: lockedMain.StartTime,
|
||||
EndTime: gtime.NewFromTime(now),
|
||||
TargetDeptId: contract.CustomerAgentID,
|
||||
UpdatedBy: contract.OperatorUserID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return orderSN, err
|
||||
}
|
||||
|
||||
func validateRenewExecutions(contract *ExecutionContract, details []*rc.BatchDetailRow, cards map[string]*rc.SimCardInfo, binds map[string]*rc.SimCardBindPkg, futureBinds map[string][]*rc.SimCardBindPkg, now time.Time) ([]renewExecution, error) {
|
||||
if contract == nil {
|
||||
return nil, gerror.New("renew execution contract is missing")
|
||||
}
|
||||
if contract.Quantity != len(details) {
|
||||
return nil, gerror.New("renew detail quantity does not match confirmed contract")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(details))
|
||||
result := make([]renewExecution, 0, len(details))
|
||||
for _, detail := range details {
|
||||
if detail == nil {
|
||||
return nil, gerror.New("renew detail is nil")
|
||||
}
|
||||
iccid := strings.TrimSpace(detail.ItemId)
|
||||
if iccid == "" {
|
||||
return nil, gerror.New("renew detail ICCID is empty")
|
||||
}
|
||||
if _, exists := seen[iccid]; exists {
|
||||
return nil, gerror.Newf("renew detail ICCID is duplicated: %s", iccid)
|
||||
}
|
||||
seen[iccid] = struct{}{}
|
||||
card := cards[iccid]
|
||||
if card == nil {
|
||||
return nil, gerror.Newf("renew card not found: %s", iccid)
|
||||
}
|
||||
bind := binds[iccid]
|
||||
if bind == nil {
|
||||
return nil, gerror.Newf("renew latest subscription not found: %s", iccid)
|
||||
}
|
||||
var expectedMSISDN, expectedIMSI string
|
||||
if detail.Snapshot != nil {
|
||||
expectedMSISDN = strings.TrimSpace(detail.Snapshot.Get("msisdn").String())
|
||||
expectedIMSI = strings.TrimSpace(detail.Snapshot.Get("imsi").String())
|
||||
}
|
||||
if err := validateCurrentSubscription(card, bind, subscriptionValidation{
|
||||
ICCID: iccid,
|
||||
MSISDN: expectedMSISDN,
|
||||
IMSI: expectedIMSI,
|
||||
TenantID: contract.TenantID,
|
||||
CustomerDeptID: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
SourceBatchNo: contract.SourceBatchNo,
|
||||
}); err != nil {
|
||||
return nil, gerror.Wrapf(err, "validate renew current subscription failed, iccid:%s", iccid)
|
||||
}
|
||||
// The latest subscription is the source of truth for the next renewal
|
||||
// start. The legacy Admin transaction did not require the denormalized
|
||||
// card expiration to equal that subscription; future subscriptions can
|
||||
// legitimately make the two values differ.
|
||||
renewBase := renewBaseSubscriptionForCard(card, bind, futureBinds[iccid])
|
||||
if err := validateRenewBaseWindow(renewBase, now, iccid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
begin, ok := renewBeginTimeFromExpire(renewBase.ExpectedExpireTime)
|
||||
if !ok {
|
||||
return nil, gerror.Newf("ICCID没有预计到期时间:%s", strings.TrimSpace(iccid))
|
||||
}
|
||||
if contract.PackageBeginTime != nil {
|
||||
begin = contract.PackageBeginTime.Time
|
||||
}
|
||||
result = append(result, renewExecution{
|
||||
detail: detail,
|
||||
card: card,
|
||||
bind: renewBase,
|
||||
begin: begin,
|
||||
end: contract.ServiceEnd(begin),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func renewBaseSubscription(current *rc.SimCardBindPkg, future []*rc.SimCardBindPkg) *rc.SimCardBindPkg {
|
||||
base := current
|
||||
for _, bind := range future {
|
||||
if bind == nil {
|
||||
continue
|
||||
}
|
||||
if base == nil || subscriptionAfter(bind, base) {
|
||||
base = bind
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func renewBaseSubscriptionForCard(card *rc.SimCardInfo, current *rc.SimCardBindPkg, future []*rc.SimCardBindPkg) *rc.SimCardBindPkg {
|
||||
base := renewBaseSubscription(current, future)
|
||||
cardExpireAt := latestCardRenewExpireTime(card)
|
||||
if cardExpireAt == nil {
|
||||
return base
|
||||
}
|
||||
if base == nil {
|
||||
return &rc.SimCardBindPkg{
|
||||
Iccid: strings.TrimSpace(card.Iccid),
|
||||
PackageCode: strings.TrimSpace(card.CurrentPackageCode),
|
||||
ExpectedExpireTime: cardExpireAt,
|
||||
}
|
||||
}
|
||||
if base.ExpectedExpireTime != nil && !cardExpireAt.After(base.ExpectedExpireTime) {
|
||||
return base
|
||||
}
|
||||
clone := *base
|
||||
clone.ExpectedExpireTime = cardExpireAt
|
||||
return &clone
|
||||
}
|
||||
|
||||
func latestCardRenewExpireTime(card *rc.SimCardInfo) *gtime.Time {
|
||||
if card == nil {
|
||||
return nil
|
||||
}
|
||||
var expireAt *gtime.Time
|
||||
for _, candidate := range []*gtime.Time{
|
||||
card.FlowPkgEndAt,
|
||||
card.FlowPkgPresentAt,
|
||||
card.FlowPkgRealPresent,
|
||||
} {
|
||||
if candidate == nil || candidate.IsZero() {
|
||||
continue
|
||||
}
|
||||
if expireAt == nil || candidate.After(expireAt) {
|
||||
expireAt = candidate
|
||||
}
|
||||
}
|
||||
return expireAt
|
||||
}
|
||||
|
||||
func validateRenewBaseWindow(base *rc.SimCardBindPkg, now time.Time, iccid string) error {
|
||||
if base == nil || base.ExpectedExpireTime == nil {
|
||||
return gerror.Newf("ICCID没有预计到期时间:%s", strings.TrimSpace(iccid))
|
||||
}
|
||||
if renewExpireAfterAllowedWindow(base.ExpectedExpireTime, now) {
|
||||
return gerror.Newf("%s:%s", renewExpireFailureReason, strings.TrimSpace(iccid))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRunningRenewDetails(locked, loaded []*rc.BatchDetailRow) error {
|
||||
if len(locked) != len(loaded) {
|
||||
return gerror.New("renew detail count changed during execution")
|
||||
}
|
||||
for i, detail := range locked {
|
||||
if detail == nil || detail.DetailStatus != consts.BatchDetailStatusRunning {
|
||||
return gerror.New("renew detail is no longer running")
|
||||
}
|
||||
loadedDetail := loaded[i]
|
||||
if loadedDetail == nil || detail.Id != loadedDetail.Id || detail.RowNo != loadedDetail.RowNo ||
|
||||
strings.TrimSpace(detail.ItemId) != strings.TrimSpace(loadedDetail.ItemId) {
|
||||
return gerror.New("renew detail changed during execution")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renewServiceRange(executions []renewExecution) (time.Time, time.Time) {
|
||||
minBegin := executions[0].begin
|
||||
maxEnd := executions[0].end
|
||||
for _, execution := range executions[1:] {
|
||||
if execution.begin.Before(minBegin) {
|
||||
minBegin = execution.begin
|
||||
}
|
||||
if execution.end.After(maxEnd) {
|
||||
maxEnd = execution.end
|
||||
}
|
||||
}
|
||||
return minBegin, maxEnd
|
||||
}
|
||||
|
||||
func generateRenewSubscriptionNo(now time.Time) (string, error) {
|
||||
orderSN, err := rc.NextOrderSN(now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "SUB" + strings.TrimPrefix(orderSN, "SO"), nil
|
||||
}
|
||||
|
||||
func buildRenewBind(contract *ExecutionContract, pkg *rc.FlowPackageInfo, execution renewExecution, orderSN, subscriptionNo string, now time.Time) *rc.SimCardBindPkg {
|
||||
remark := contract.Remark
|
||||
if remark == "" {
|
||||
remark = "card renew subscription"
|
||||
}
|
||||
return &rc.SimCardBindPkg{
|
||||
SubscriptionNo: subscriptionNo,
|
||||
BatchNo: contract.BatchNo,
|
||||
Iccid: execution.card.Iccid,
|
||||
Msisdn: execution.card.Msisdn,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageType: pkg.PackageType,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PreviousPackageCode: pkg.PackageCode,
|
||||
BusinessType: consts.PackageBusinessTypeRenew,
|
||||
EffectiveStatus: consts.EffectiveStatusPending,
|
||||
HandleTime: gtime.NewFromTime(now),
|
||||
EffectiveTime: gtime.NewFromTime(execution.begin),
|
||||
ExpectedExpireTime: gtime.NewFromTime(execution.end),
|
||||
DurationMonths: contract.DurationMonths,
|
||||
GiftDurationMonths: contract.GiftDurationMonths,
|
||||
OrderCode: orderSN,
|
||||
Remark: remark,
|
||||
TenantId: contract.TenantID,
|
||||
CreatedDept: contract.CreatedDeptID,
|
||||
CreatedBy: contract.OperatorUserID,
|
||||
CreatedAt: gtime.NewFromTime(now),
|
||||
UpdatedBy: contract.OperatorUserID,
|
||||
UpdatedAt: gtime.NewFromTime(now),
|
||||
}
|
||||
}
|
||||
|
||||
func buildRenewDetailSnapshot(execution renewExecution, contract *ExecutionContract, pkg *rc.FlowPackageInfo, orderSN, subscriptionNo string) *gjson.Json {
|
||||
data := map[string]any{}
|
||||
if execution.detail.Snapshot != nil {
|
||||
for key, value := range execution.detail.Snapshot.Map() {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
data["iccid"] = execution.card.Iccid
|
||||
data["msisdn"] = execution.card.Msisdn
|
||||
data["imsi"] = execution.card.Imsi
|
||||
data["renewBatchNo"] = contract.BatchNo
|
||||
data["packageCode"] = pkg.PackageCode
|
||||
data["packageName"] = pkg.PackageName
|
||||
data["oldSubscriptionNo"] = execution.bind.SubscriptionNo
|
||||
data["newSubscriptionNo"] = subscriptionNo
|
||||
data["orderSn"] = orderSN
|
||||
data["oldExpectedExpireTime"] = formatRenewTime(execution.bind.ExpectedExpireTime)
|
||||
data["renewEffectiveTime"] = formatRenewTime(gtime.NewFromTime(execution.begin))
|
||||
data["renewExpectedExpireTime"] = formatRenewTime(gtime.NewFromTime(execution.end))
|
||||
data["cardFlowPkgEndAt"] = formatRenewTime(gtime.NewFromTime(execution.end))
|
||||
data["durationValue"] = contract.DisplayDurationValue()
|
||||
data["actualDurationMonths"] = contract.DurationMonths
|
||||
data["durationMonths"] = contract.DurationMonths
|
||||
data["giftDurationMonths"] = contract.GiftDurationMonths
|
||||
data["billingMethod"] = contract.BillingMethod
|
||||
data["renewDurationUnit"] = contract.RenewDurationUnit
|
||||
data["renewDurationUnitText"] = contract.RenewDurationUnitText
|
||||
data["renewDate"] = contract.RenewDate
|
||||
data["amount"] = contract.TotalAmount()
|
||||
data["renewUnitPrice"] = contract.UnitPrice
|
||||
data["priceSource"] = contract.PriceSource
|
||||
data["rowNo"] = execution.detail.RowNo
|
||||
if contract.SourceBatchNo != "" {
|
||||
data["sourceBatchNo"] = contract.SourceBatchNo
|
||||
}
|
||||
return gjson.New(data)
|
||||
}
|
||||
|
||||
func formatRenewTime(value *gtime.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return value.Format("Y-m-d H:i:s")
|
||||
}
|
||||
|
||||
func renewPackageStatus(now time.Time, begin, end *gtime.Time) int {
|
||||
if begin != nil && begin.After(gtime.NewFromTime(now)) {
|
||||
return consts.PackageStatusPending
|
||||
}
|
||||
if end != nil && !end.After(gtime.NewFromTime(now)) {
|
||||
return consts.PackageStatusExpired
|
||||
}
|
||||
return consts.PackageStatusEffective
|
||||
}
|
||||
|
||||
func strictIccidsFromDetails(details []*rc.BatchDetailRow) ([]string, error) {
|
||||
seen := make(map[string]struct{}, len(details))
|
||||
result := make([]string, 0, len(details))
|
||||
for _, detail := range details {
|
||||
if detail == nil {
|
||||
return nil, gerror.New("renew detail is nil")
|
||||
}
|
||||
iccid := strings.TrimSpace(detail.ItemId)
|
||||
if iccid == "" {
|
||||
return nil, gerror.New("renew detail ICCID is empty")
|
||||
}
|
||||
if _, exists := seen[iccid]; exists {
|
||||
return nil, gerror.Newf("renew detail ICCID is duplicated: %s", iccid)
|
||||
}
|
||||
seen[iccid] = struct{}{}
|
||||
result = append(result, iccid)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// uniqueIccidsFromDetails remains available to the change executor while the
|
||||
// renew path uses strictIccidsFromDetails to reject malformed input.
|
||||
func uniqueIccidsFromDetails(details []*rc.BatchDetailRow) []string {
|
||||
seen := make(map[string]struct{}, len(details))
|
||||
result := make([]string, 0, len(details))
|
||||
for _, detail := range details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(detail.ItemId)
|
||||
if iccid == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[iccid]; exists {
|
||||
continue
|
||||
}
|
||||
seen[iccid] = struct{}{}
|
||||
result = append(result, iccid)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *RenewExecutor) finalizeFailure(ctx context.Context, main *rc.BatchMainRow, reason string) error {
|
||||
now := pnow(r.nowFunc)
|
||||
contract, _ := ParseExecutionContract(main)
|
||||
if contract == nil {
|
||||
contract = renewRefundContractFromBatch(main)
|
||||
}
|
||||
nowTime := gtime.NewFromTime(now)
|
||||
err := r.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
lockedMain, err := r.repo.Tables().LockRenewBatchMain(ctx, tx, main.BatchNo, main.BatchType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if contract != nil {
|
||||
if err := refundRenewBalanceIfNeeded(ctx, tx, lockedMain, contract, reason, nowTime); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := r.repo.MarkAllDetailsFailed(ctx, tx, main.BatchNo, main.BatchType, reason, main.CreatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.repo.FinalizeBatchMain(ctx, tx, main.BatchNo, main.BatchType, &rc.BatchMainRow{
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
|
||||
RunResult: consts.BatchRunResultFailed,
|
||||
TotalCount: main.TotalCount,
|
||||
FailCount: main.TotalCount,
|
||||
FailReason: reason,
|
||||
EndTime: nowTime,
|
||||
UpdatedBy: main.CreatedBy,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "finalize renew failure failed")
|
||||
}
|
||||
snap, _ := r.repo.ResolveResultSnapshot(ctx, main.BatchNo, main.BatchType)
|
||||
_ = r.repo.WriteResultFileSafe(ctx, main.BatchNo, main.BatchType, main.TenantId, main.CreatedDept, main.CreatedBy, snap)
|
||||
g.Log().Warningf(ctx, "renew batch failed, batchNo=%s, reason=%s, tenant=%s", main.BatchNo, reason, main.TenantId)
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeBatchInfo(prev *gjson.Json, addition map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
if prev != nil {
|
||||
for key, value := range prev.Map() {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
for key, value := range addition {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pnow(fn NowFunc) time.Time {
|
||||
if fn == nil {
|
||||
return timeNow()
|
||||
}
|
||||
return fn()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
func TestDeductRuntimeRenewBalanceRejectsInsufficientBalanceAtomically(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := gdb.SetConfig(gdb.Config{
|
||||
"default": gdb.ConfigGroup{{Type: "sqlite", Name: filepath.Join(t.TempDir(), "renew-balance.db")}},
|
||||
}); err != nil {
|
||||
t.Fatalf("configure sqlite: %v", err)
|
||||
}
|
||||
defer g.DB().Close(ctx)
|
||||
|
||||
createRenewBalanceTables(t, ctx)
|
||||
if _, err := g.DB().Model(rc.TableAgentBalance).Ctx(ctx).Data(g.Map{
|
||||
"dept_id": 117,
|
||||
"tenant_id": "000000",
|
||||
"balance": 100,
|
||||
"status": renewFinanceAccountStatusNormal,
|
||||
}).Insert(); err != nil {
|
||||
t.Fatalf("seed balance: %v", err)
|
||||
}
|
||||
|
||||
main := &rc.BatchMainRow{
|
||||
BatchNo: "REN20260806110000TEST",
|
||||
TenantId: "000000",
|
||||
CreatedBy: 60,
|
||||
}
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
_, _, _, err := deductRuntimeRenewBalance(ctx, tx, main, 117, 576, time.Date(2026, 8, 6, 11, 0, 0, 0, time.Local))
|
||||
return err
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "账户余额不足") {
|
||||
t.Fatalf("deduct error = %v, want insufficient balance", err)
|
||||
}
|
||||
|
||||
balance, err := g.DB().Model(rc.TableAgentBalance).Ctx(ctx).
|
||||
Where("dept_id", 117).
|
||||
Where("tenant_id", "000000").
|
||||
Value("balance")
|
||||
if err != nil {
|
||||
t.Fatalf("query balance: %v", err)
|
||||
}
|
||||
if got := balance.Float64(); got != 100 {
|
||||
t.Fatalf("balance = %.2f, want unchanged 100.00", got)
|
||||
}
|
||||
|
||||
count, err := g.DB().Model(rc.TableBalanceDetail).Ctx(ctx).
|
||||
Where("serial_no", "RENPAY"+main.BatchNo).
|
||||
Count()
|
||||
if err != nil {
|
||||
t.Fatalf("count balance detail: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("balance detail count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func createRenewBalanceTables(t *testing.T, ctx context.Context) {
|
||||
t.Helper()
|
||||
statements := []string{
|
||||
`CREATE TABLE t_agent_balance (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dept_id INTEGER NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
balance REAL NOT NULL,
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
updated_by INTEGER,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE t_balance_detail (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dept_id INTEGER NOT NULL,
|
||||
serial_no TEXT NOT NULL,
|
||||
transaction_time DATETIME,
|
||||
income_expense_type INTEGER,
|
||||
transaction_type INTEGER,
|
||||
transaction_channel INTEGER,
|
||||
transaction_status INTEGER,
|
||||
amount REAL,
|
||||
before_balance REAL,
|
||||
balance_after REAL,
|
||||
order_sn TEXT,
|
||||
remark TEXT,
|
||||
tenant_id TEXT NOT NULL,
|
||||
created_dept INTEGER,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME
|
||||
)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatalf("create renew balance table: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
const (
|
||||
renewFinanceAccountStatusNormal = 0
|
||||
renewFinanceIncomeExpenseExpense = 1
|
||||
)
|
||||
|
||||
type renewPaymentAccountRow struct {
|
||||
ID int64 `orm:"id"`
|
||||
Balance float64 `orm:"balance"`
|
||||
Status int `orm:"status"`
|
||||
}
|
||||
|
||||
func (r *RenewExecutor) ensureRenewExecutionContract(
|
||||
ctx context.Context,
|
||||
tx gdb.TX,
|
||||
main *rc.BatchMainRow,
|
||||
details []*rc.BatchDetailRow,
|
||||
cards map[string]*rc.SimCardInfo,
|
||||
binds map[string]*rc.SimCardBindPkg,
|
||||
futureBinds map[string][]*rc.SimCardBindPkg,
|
||||
now time.Time,
|
||||
) (*ExecutionContract, error) {
|
||||
if contract, err := ParseExecutionContract(main); err == nil {
|
||||
if err = r.validateRenewRuntimePermissions(ctx, tx, main, contract); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contract, nil
|
||||
}
|
||||
|
||||
info := main.BatchInfo
|
||||
if info == nil || info.IsNil() {
|
||||
return nil, gerror.New("续费批次缺少导入参数")
|
||||
}
|
||||
durationValue := firstInt(info, "durationValue", "durationYears")
|
||||
if durationValue <= 0 {
|
||||
durationValue = normalizeLegacyRenewDurationValue(firstInt(info, "durationMonths"))
|
||||
}
|
||||
if durationValue <= 0 {
|
||||
return nil, gerror.New("续费时长不能为空")
|
||||
}
|
||||
giftDurationMonths := firstInt(info, "giftDurationMonths")
|
||||
if giftDurationMonths < 0 {
|
||||
return nil, gerror.New("赠送时长不能小于0")
|
||||
}
|
||||
|
||||
iccids, err := strictIccidsFromDetails(details)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
customerID, packageCode, beginDates, err := runtimeRenewFacts(main, details, cards, binds, futureBinds, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkg, err := r.repo.Tables().LoadRenewPackage(ctx, tx, main.TenantId, packageCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pkg.Enabled != 1 {
|
||||
return nil, gerror.New("当前套餐未启用")
|
||||
}
|
||||
durationUnit := renewDurationUnitForPackage(pkg)
|
||||
actualDurationMonths := consts.DurationValueToMonths(durationUnit, durationValue)
|
||||
actualGiftDurationMonths := consts.DurationValueToMonths(durationUnit, giftDurationMonths)
|
||||
options, err := validateRuntimeRenewDuration(pkg, durationValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
customerDept, err := loadRenewDeptTx(ctx, tx, main.TenantId, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priceSnap, err := loadRuntimeRenewPriceSnapshot(ctx, tx, main.TenantId, pkg, customerDept, options, beginDates)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
amount := roundAmount(priceSnap.RenewUnitPrice * float64(durationValue) * float64(len(iccids)))
|
||||
|
||||
mode := strings.TrimSpace(firstString(info, "paymentMode", "paymentSnapshot.paymentMode"))
|
||||
payment, err := r.buildRuntimeRenewPayment(ctx, tx, main, mode, customerID, amount, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addition := map[string]any{
|
||||
"customerAgentId": customerID,
|
||||
"customerName": strings.TrimSpace(customerDept.DeptName),
|
||||
"packageCode": priceSnap.PackageCode,
|
||||
"packageName": priceSnap.PackageName,
|
||||
"priceSource": priceSnap.PriceSource,
|
||||
"priceOwnerDeptId": priceSnap.PriceOwnerDeptId,
|
||||
"agentDefaultPriceId": priceSnap.AgentDefaultPriceId,
|
||||
"targetPriceId": priceSnap.TargetPriceId,
|
||||
"renewUnitPrice": priceSnap.RenewUnitPrice,
|
||||
"renewableDurationOptions": priceSnap.RenewableDurationOptions,
|
||||
"renewablePackageCodes": priceSnap.RenewablePackageCodes,
|
||||
"renewablePackages": priceSnap.RenewablePackages,
|
||||
"billingMethod": priceSnap.BillingMethod,
|
||||
"renewDurationUnit": priceSnap.RenewDurationUnit,
|
||||
"renewDurationUnitText": priceSnap.RenewDurationUnitText,
|
||||
"renewBeginDate": priceSnap.RenewBeginDate,
|
||||
"renewBeginDateMin": priceSnap.RenewBeginDateMin,
|
||||
"renewBeginDateMax": priceSnap.RenewBeginDateMax,
|
||||
"sameRenewBeginDate": priceSnap.SameRenewBeginDate,
|
||||
"priceSnapshot": priceSnap,
|
||||
"quantity": len(iccids),
|
||||
"durationValue": durationValue,
|
||||
"durationMonths": actualDurationMonths,
|
||||
"actualDurationMonths": actualDurationMonths,
|
||||
"giftDurationMonths": giftDurationMonths,
|
||||
"actualGiftDurationMonths": actualGiftDurationMonths,
|
||||
"amount": amount,
|
||||
}
|
||||
for key, value := range payment {
|
||||
addition[key] = value
|
||||
}
|
||||
merged := mergeBatchInfo(main.BatchInfo, addition)
|
||||
main.BatchInfo = gjson.New(merged)
|
||||
|
||||
contract, err := ParseExecutionContract(main)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = r.validateRenewRuntimePermissions(ctx, tx, main, contract); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contract, nil
|
||||
}
|
||||
|
||||
func runtimeRenewFacts(
|
||||
main *rc.BatchMainRow,
|
||||
details []*rc.BatchDetailRow,
|
||||
cards map[string]*rc.SimCardInfo,
|
||||
binds map[string]*rc.SimCardBindPkg,
|
||||
futureBinds map[string][]*rc.SimCardBindPkg,
|
||||
now time.Time,
|
||||
) (int64, string, []string, error) {
|
||||
customerIDs := map[int64]struct{}{}
|
||||
packageCodes := map[string]struct{}{}
|
||||
beginDates := make([]string, 0, len(details))
|
||||
periodRows := make([]*RenewRow, 0, len(details))
|
||||
sourceBatchNo := executionSourceBatchNo(main, main.BatchInfo)
|
||||
|
||||
for _, detail := range details {
|
||||
if detail == nil {
|
||||
return 0, "", nil, gerror.New("续费明细为空")
|
||||
}
|
||||
iccid := strings.TrimSpace(detail.ItemId)
|
||||
card := cards[iccid]
|
||||
bind := binds[iccid]
|
||||
if card == nil {
|
||||
return 0, "", nil, gerror.Newf("ICCID不存在:%s", iccid)
|
||||
}
|
||||
if bind == nil {
|
||||
return 0, "", nil, gerror.Newf("ICCID没有订购记录:%s", iccid)
|
||||
}
|
||||
expectedMSISDN, expectedIMSI := snapshotIdentity(detail.Snapshot)
|
||||
if err := validateCurrentSubscription(card, bind, subscriptionValidation{
|
||||
ICCID: iccid,
|
||||
MSISDN: expectedMSISDN,
|
||||
IMSI: expectedIMSI,
|
||||
TenantID: main.TenantId,
|
||||
CustomerDeptID: card.DeptId,
|
||||
PackageCode: card.CurrentPackageCode,
|
||||
SourceBatchNo: sourceBatchNo,
|
||||
}); err != nil {
|
||||
return 0, "", nil, gerror.Wrapf(err, "校验续费卡失败,ICCID:%s", iccid)
|
||||
}
|
||||
base := renewBaseSubscriptionForCard(card, bind, futureBinds[iccid])
|
||||
if err := validateRenewBaseWindow(base, now, iccid); err != nil {
|
||||
return 0, "", nil, err
|
||||
}
|
||||
customerIDs[card.DeptId] = struct{}{}
|
||||
packageCodes[strings.TrimSpace(card.CurrentPackageCode)] = struct{}{}
|
||||
periodRows = append(periodRows, &RenewRow{
|
||||
RowNo: detail.RowNo,
|
||||
ICCID: iccid,
|
||||
OldEffTime: base.EffectiveTime,
|
||||
OldExpireTime: base.ExpectedExpireTime,
|
||||
})
|
||||
beginAt := renewBeginDateFromExpire(base.ExpectedExpireTime)
|
||||
if beginAt != nil {
|
||||
beginDates = append(beginDates, beginAt.Format("Y-m-d"))
|
||||
}
|
||||
}
|
||||
if err := validateRenewSameCurrentPeriod(periodRows, nil); err != nil {
|
||||
return 0, "", nil, err
|
||||
}
|
||||
if len(customerIDs) != 1 {
|
||||
return 0, "", nil, gerror.New("当前批次下存在多个客户,不能统一续费")
|
||||
}
|
||||
if len(packageCodes) != 1 {
|
||||
return 0, "", nil, gerror.New("当前批次下存在多个套餐,不能统一续费")
|
||||
}
|
||||
var customerID int64
|
||||
for id := range customerIDs {
|
||||
customerID = id
|
||||
}
|
||||
var packageCode string
|
||||
for code := range packageCodes {
|
||||
packageCode = code
|
||||
}
|
||||
return customerID, packageCode, beginDates, nil
|
||||
}
|
||||
|
||||
func normalizeLegacyRenewDurationValue(raw int) int {
|
||||
if raw <= 0 {
|
||||
return 0
|
||||
}
|
||||
if raw >= 12 && raw%12 == 0 {
|
||||
return raw / 12
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func validateRuntimeRenewDuration(pkg *rc.FlowPackageInfo, durationValue int) ([]int, error) {
|
||||
if pkg == nil {
|
||||
return nil, gerror.New("续费套餐不存在")
|
||||
}
|
||||
options, err := renewableYearOptions(pkg.RenewableDurationConfig)
|
||||
if err != nil || len(options) == 0 {
|
||||
return nil, gerror.Newf("当前套餐%s未配置可续费时长", runtimeRenewPackageLabel(pkg))
|
||||
}
|
||||
if !rc.ContainsInt(options, durationValue) {
|
||||
return nil, gerror.Newf(
|
||||
"续费时长%d%s不在当前套餐%s可续费范围内,可选:%s",
|
||||
durationValue,
|
||||
consts.RenewDurationUnitText(renewDurationUnitForPackage(pkg)),
|
||||
runtimeRenewPackageLabel(pkg),
|
||||
formatRuntimeRenewDurationOptions(options, renewDurationUnitForPackage(pkg)),
|
||||
)
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func runtimeRenewPackageLabel(pkg *rc.FlowPackageInfo) string {
|
||||
if pkg == nil {
|
||||
return ""
|
||||
}
|
||||
name := strings.TrimSpace(pkg.PackageName)
|
||||
code := strings.TrimSpace(pkg.PackageCode)
|
||||
switch {
|
||||
case name != "" && code != "":
|
||||
return name + "(" + code + ")"
|
||||
case name != "":
|
||||
return name
|
||||
default:
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
func formatRuntimeRenewDurationOptions(options []int, unit string) string {
|
||||
if len(options) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(options))
|
||||
for _, option := range options {
|
||||
parts = append(parts, fmt.Sprintf("%d%s", option, consts.RenewDurationUnitText(unit)))
|
||||
}
|
||||
return strings.Join(parts, "、")
|
||||
}
|
||||
|
||||
func snapshotIdentity(snapshot *gjson.Json) (string, string) {
|
||||
if snapshot == nil {
|
||||
return "", ""
|
||||
}
|
||||
msisdn := strings.TrimSpace(snapshot.Get("msisdn").String())
|
||||
if msisdn == "" {
|
||||
msisdn = strings.TrimSpace(snapshot.Get("MSISDN").String())
|
||||
}
|
||||
imsi := strings.TrimSpace(snapshot.Get("imsi").String())
|
||||
if imsi == "" {
|
||||
imsi = strings.TrimSpace(snapshot.Get("IMSI").String())
|
||||
}
|
||||
return msisdn, imsi
|
||||
}
|
||||
|
||||
func loadRuntimeRenewPriceSnapshot(
|
||||
ctx context.Context,
|
||||
tx gdb.TX,
|
||||
tenantID string,
|
||||
pkg *rc.FlowPackageInfo,
|
||||
customerDept *rc.SysDeptRow,
|
||||
options []int,
|
||||
beginDates []string,
|
||||
) (*RenewPriceSnapshot, error) {
|
||||
if pkg == nil || customerDept == nil {
|
||||
return nil, gerror.New("续费套餐或客户信息缺失")
|
||||
}
|
||||
priceOwnerDeptID := renewPriceOwnerDeptID(customerDept)
|
||||
salePrice, err := resolveRenewEffectiveSalePrice(ctx, renewTxEffectivePriceStore{tx: tx}, tenantID, priceOwnerDeptID, customerDept.DeptId, pkg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sameDate, minDate, maxDate, same := summarizeRenewBeginDates(beginDates)
|
||||
durationUnit := renewDurationUnitForPackage(pkg)
|
||||
renewableCodes, renewablePackages, err := runtimeRenewablePackageOptions(ctx, tx, tenantID, customerDept.DeptId, priceOwnerDeptID, pkg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RenewPriceSnapshot{
|
||||
CustomerAgentId: customerDept.DeptId,
|
||||
PriceOwnerDeptId: priceOwnerDeptID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
BillingMethod: pkg.BillingMethod,
|
||||
RenewDurationUnit: durationUnit,
|
||||
RenewDurationUnitText: consts.RenewDurationUnitText(durationUnit),
|
||||
PriceSource: salePrice.Rule.PriceSource,
|
||||
AgentDefaultPriceId: salePrice.Rule.AgentDefaultPriceId,
|
||||
TargetPriceId: salePrice.Rule.TargetPriceId,
|
||||
RenewUnitPrice: salePrice.AgentShipmentPrice,
|
||||
RenewableDurationOptions: options,
|
||||
RenewablePackageCodes: renewableCodes,
|
||||
RenewablePackages: renewablePackages,
|
||||
RenewBeginDate: sameDate,
|
||||
RenewBeginDateMin: minDate,
|
||||
RenewBeginDateMax: maxDate,
|
||||
SameRenewBeginDate: same,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func runtimeRenewablePackageOptions(ctx context.Context, tx gdb.TX, tenantID string, customerDeptID, priceOwnerDeptID int64, currentPkg *rc.FlowPackageInfo) ([]string, []RenewPackageOption, error) {
|
||||
if currentPkg == nil {
|
||||
return nil, nil, gerror.New("当前套餐不存在或未启用")
|
||||
}
|
||||
codes := renewPackageCodesFromJSON(currentPkg.RenewablePackageCodes)
|
||||
if len(codes) == 0 {
|
||||
return nil, nil, gerror.New("当前套餐未配置可续费套餐")
|
||||
}
|
||||
validCodes := make([]string, 0, len(codes))
|
||||
options := make([]RenewPackageOption, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
pkg, err := loadRenewPackageOptionTx(ctx, tx, tenantID, code)
|
||||
if err != nil || pkg == nil || pkg.Enabled != 1 {
|
||||
continue
|
||||
}
|
||||
unitPrice, err := runtimeRenewPackageUnitPrice(ctx, tx, tenantID, customerDeptID, priceOwnerDeptID, pkg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
durations, err := renewableYearOptions(pkg.RenewableDurationConfig)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
validCodes = append(validCodes, pkg.PackageCode)
|
||||
options = append(options, RenewPackageOption{
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
RenewUnitPrice: unitPrice,
|
||||
RenewableDurationOptions: durations,
|
||||
})
|
||||
}
|
||||
if len(options) == 0 {
|
||||
return nil, nil, gerror.New("当前套餐未配置可用的可续费套餐")
|
||||
}
|
||||
return validCodes, options, nil
|
||||
}
|
||||
|
||||
func loadRenewPackageOptionTx(ctx context.Context, tx gdb.TX, tenantID, packageCode string) (*rc.FlowPackageInfo, error) {
|
||||
var pkg rc.FlowPackageInfo
|
||||
err := tx.Ctx(ctx).Model(rc.TableSimFlowPackageInfo).
|
||||
Where("tenant_id", strings.TrimSpace(tenantID)).
|
||||
Where("package_code", strings.TrimSpace(packageCode)).
|
||||
LockShared().
|
||||
Scan(&pkg)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, gerror.Wrap(err, "查询可续费套餐失败")
|
||||
}
|
||||
if pkg.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &pkg, nil
|
||||
}
|
||||
|
||||
func runtimeRenewPackageUnitPrice(ctx context.Context, tx gdb.TX, tenantID string, customerDeptID, priceOwnerDeptID int64, pkg *rc.FlowPackageInfo) (float64, error) {
|
||||
if pkg == nil {
|
||||
return 0, gerror.New("续费套餐不存在")
|
||||
}
|
||||
salePrice, err := resolveRenewEffectiveSalePrice(ctx, renewTxEffectivePriceStore{tx: tx}, tenantID, priceOwnerDeptID, customerDeptID, pkg)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return salePrice.AgentShipmentPrice, nil
|
||||
}
|
||||
|
||||
func (r *RenewExecutor) buildRuntimeRenewPayment(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow, requestedMode string, payerDeptID int64, amount float64, now time.Time) (map[string]any, error) {
|
||||
isTop, operatorDeptID, err := isRenewTopDeptTx(ctx, tx, main)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payerDeptID <= 0 {
|
||||
payerDeptID = operatorDeptID
|
||||
}
|
||||
mode := strings.TrimSpace(requestedMode)
|
||||
if mode == "" {
|
||||
if isTop {
|
||||
mode = "offline"
|
||||
} else {
|
||||
mode = "balance"
|
||||
}
|
||||
}
|
||||
switch mode {
|
||||
case "offline":
|
||||
if !isTop {
|
||||
return nil, gerror.New("当前组织续费必须使用余额支付")
|
||||
}
|
||||
if main.BatchInfo != nil && !main.BatchInfo.Get("offlineContractCompleted").Bool() {
|
||||
return nil, gerror.New("请确认线下业务已完成")
|
||||
}
|
||||
return runtimePaymentSnapshot(mode, consts.PayTypeOffline, amount, 0, amount, 0, 0, "", payerDeptID, main.CreatedBy, isTop, now), nil
|
||||
case "balance":
|
||||
before, after, serialNo, err := deductRuntimeRenewBalance(ctx, tx, main, payerDeptID, amount, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return runtimePaymentSnapshot(mode, consts.PayTypeBalance, amount, amount, 0, before, after, serialNo, payerDeptID, main.CreatedBy, isTop, now), nil
|
||||
default:
|
||||
return nil, gerror.New("续费付款方式不正确")
|
||||
}
|
||||
}
|
||||
|
||||
func runtimePaymentSnapshot(mode string, payType int, payAmount, balanceAmount, offlineAmount, before, after float64, serialNo string, payerDeptID, payerUserID int64, isTop bool, now time.Time) map[string]any {
|
||||
paymentTime := now.Format("2006-01-02 15:04:05")
|
||||
snapshot := map[string]any{
|
||||
"paymentMode": mode,
|
||||
"payType": payType,
|
||||
"payStatus": consts.PayStatusPaid,
|
||||
"payAmount": payAmount,
|
||||
"balanceAmount": balanceAmount,
|
||||
"offlinePaidAmount": offlineAmount,
|
||||
"balanceBefore": before,
|
||||
"balanceAfter": after,
|
||||
"balanceDetailSerialNo": strings.TrimSpace(serialNo),
|
||||
"paymentTime": paymentTime,
|
||||
"payerDeptId": payerDeptID,
|
||||
"payerUserId": payerUserID,
|
||||
"isTopDept": isTop,
|
||||
}
|
||||
return map[string]any{
|
||||
"paymentMode": mode,
|
||||
"payType": payType,
|
||||
"payStatus": consts.PayStatusPaid,
|
||||
"payAmount": payAmount,
|
||||
"balanceAmount": balanceAmount,
|
||||
"offlinePaidAmount": offlineAmount,
|
||||
"balanceBefore": before,
|
||||
"balanceAfter": after,
|
||||
"balanceDetailSerialNo": strings.TrimSpace(serialNo),
|
||||
"paymentTime": paymentTime,
|
||||
"payerDeptId": payerDeptID,
|
||||
"payerUserId": payerUserID,
|
||||
"isTopDept": isTop,
|
||||
"paymentSnapshot": snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
func deductRuntimeRenewBalance(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow, payerDeptID int64, amount float64, now time.Time) (float64, float64, string, error) {
|
||||
amount = roundAmount(amount)
|
||||
if amount <= 0 {
|
||||
return 0, 0, "", nil
|
||||
}
|
||||
serialNo := "RENPAY" + strings.TrimSpace(main.BatchNo)
|
||||
if serialNo == "RENPAY" {
|
||||
return 0, 0, "", gerror.New("续费余额流水号生成失败")
|
||||
}
|
||||
existingModel := tx.Ctx(ctx).Model(rc.TableBalanceDetail).
|
||||
Where("tenant_id", main.TenantId).
|
||||
Where("serial_no", serialNo)
|
||||
existing, err := runtimeRenewLockForUpdate(existingModel).One()
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, 0, "", gerror.Wrap(err, "查询续费余额流水失败")
|
||||
}
|
||||
if existing != nil && !existing.IsEmpty() {
|
||||
return 0, 0, "", gerror.New("当前续费批次已扣款,请勿重复提交")
|
||||
}
|
||||
|
||||
var account renewPaymentAccountRow
|
||||
recordModel := tx.Ctx(ctx).Model(rc.TableAgentBalance).
|
||||
Where("dept_id", payerDeptID).
|
||||
Where("tenant_id", main.TenantId)
|
||||
record, err := runtimeRenewLockForUpdate(recordModel).One()
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, 0, "", gerror.Wrap(err, "查询账户余额失败")
|
||||
}
|
||||
if record != nil && !record.IsEmpty() {
|
||||
if err := record.Struct(&account); err != nil {
|
||||
return 0, 0, "", gerror.Wrap(err, "解析账户余额失败")
|
||||
}
|
||||
}
|
||||
if account.ID == 0 {
|
||||
return 0, 0, "", gerror.New("账户余额不存在,请先充值")
|
||||
}
|
||||
if account.Status != renewFinanceAccountStatusNormal {
|
||||
return 0, 0, "", gerror.New("账户余额状态异常,不能续费")
|
||||
}
|
||||
before := roundAmount(account.Balance)
|
||||
if before+0.0001 < amount {
|
||||
return 0, 0, "", gerror.Newf("账户余额不足,需支付%.2f元,当前余额%.2f元", amount, before)
|
||||
}
|
||||
after := roundAmount(before - amount)
|
||||
nowTime := gtime.NewFromTime(now)
|
||||
if _, err := tx.Ctx(ctx).Model(rc.TableAgentBalance).Where("id", account.ID).Data(g.Map{
|
||||
"balance": after,
|
||||
"updated_by": main.CreatedBy,
|
||||
"updated_at": nowTime,
|
||||
}).Update(); err != nil {
|
||||
return 0, 0, "", gerror.Wrap(err, "扣减账户余额失败")
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(rc.TableBalanceDetail).Data(g.Map{
|
||||
"dept_id": payerDeptID,
|
||||
"serial_no": serialNo,
|
||||
"transaction_time": nowTime,
|
||||
"income_expense_type": renewFinanceIncomeExpenseExpense,
|
||||
"transaction_type": renewFinanceTransactionTypeConsume,
|
||||
"transaction_channel": renewFinanceTransactionChannelBalance,
|
||||
"transaction_status": renewFinanceTransactionStatusPaid,
|
||||
"amount": amount,
|
||||
"before_balance": before,
|
||||
"balance_after": after,
|
||||
"order_sn": main.BatchNo,
|
||||
"remark": fmt.Sprintf("SIM卡续费余额支付,批次号:%s", main.BatchNo),
|
||||
"tenant_id": main.TenantId,
|
||||
"created_dept": payerDeptID,
|
||||
"created_by": main.CreatedBy,
|
||||
"created_at": nowTime,
|
||||
}).Insert(); err != nil {
|
||||
return 0, 0, "", gerror.Wrap(err, "写入续费余额流水失败")
|
||||
}
|
||||
return before, after, serialNo, nil
|
||||
}
|
||||
|
||||
func runtimeRenewLockForUpdate(model *gdb.Model) *gdb.Model {
|
||||
if g.DB().GetConfig().Type == "sqlite" {
|
||||
return model
|
||||
}
|
||||
return model.LockUpdate()
|
||||
}
|
||||
|
||||
func (r *RenewExecutor) validateRenewRuntimePermissions(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow, contract *ExecutionContract) error {
|
||||
if contract == nil {
|
||||
return gerror.New("续费执行合同不能为空")
|
||||
}
|
||||
isTop, _, err := isRenewTopDeptTx(ctx, tx, main)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if contract.PaymentMode == "offline" && !isTop {
|
||||
return gerror.New("当前组织续费必须使用余额支付")
|
||||
}
|
||||
if contract.PackageBeginTime != nil && !isTop {
|
||||
return gerror.New("当前组织不能修改续费日期")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isRenewTopDeptTx(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow) (bool, int64, error) {
|
||||
deptID := main.CreatedDept
|
||||
if deptID <= 0 {
|
||||
deptID = main.DeptId
|
||||
}
|
||||
if deptID <= 0 {
|
||||
return false, 0, gerror.New("当前批次缺少操作组织")
|
||||
}
|
||||
dept, err := loadRenewDeptTx(ctx, tx, main.TenantId, deptID)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
return dept.ParentId == 0, deptID, nil
|
||||
}
|
||||
|
||||
func loadRenewDeptTx(ctx context.Context, tx gdb.TX, tenantID string, deptID int64) (*rc.SysDeptRow, error) {
|
||||
var row rc.SysDeptRow
|
||||
err := tx.Ctx(ctx).Model(rc.TableSysDept).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("dept_id", deptID).
|
||||
WhereNull("deleted_at").
|
||||
LockShared().
|
||||
Scan(&row)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询客户部门失败")
|
||||
}
|
||||
if row.DeptId == 0 {
|
||||
return nil, gerror.New("客户部门不存在")
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func loadRenewPriceOverrideTx(ctx context.Context, tx gdb.TX, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*rc.FlowPackagePrice, error) {
|
||||
var row rc.FlowPackagePrice
|
||||
err := tx.Ctx(ctx).Model(rc.TableSimFlowPackagePrice).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("dept_id", deptID).
|
||||
Where("agent_scope_type", scopeType).
|
||||
Where("target_agent_id", targetAgentID).
|
||||
Where("package_code", strings.TrimSpace(packageCode)).
|
||||
Where("enabled", 1).
|
||||
LockShared().
|
||||
Scan(&row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, gerror.Wrap(err, "查询代理续费定价失败")
|
||||
}
|
||||
if row.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
const (
|
||||
renewFinanceIncomeExpenseIncome = 0
|
||||
renewFinanceTransactionTypeConsume = 1
|
||||
renewFinanceTransactionChannelBalance = 3
|
||||
renewFinanceTransactionStatusPaid = 1
|
||||
)
|
||||
|
||||
type renewBalanceAccountRow struct {
|
||||
ID int64 `orm:"id"`
|
||||
Balance float64 `orm:"balance"`
|
||||
}
|
||||
|
||||
type renewBalanceDetailRow struct {
|
||||
ID int64 `orm:"id"`
|
||||
DeptID int64 `orm:"dept_id"`
|
||||
SerialNo string `orm:"serial_no"`
|
||||
Amount float64 `orm:"amount"`
|
||||
TransactionStatus int `orm:"transaction_status"`
|
||||
TenantID string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
}
|
||||
|
||||
func refundRenewBalanceIfNeeded(ctx context.Context, tx gdb.TX, main *rc.BatchMainRow, contract *ExecutionContract, reason string, now *gtime.Time) error {
|
||||
if main == nil || contract == nil {
|
||||
return nil
|
||||
}
|
||||
if contract.PaymentMode != "balance" || strings.TrimSpace(contract.BalanceDetailSerialNo) == "" || contract.BalanceAmount <= 0 {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(main.OrderSn) != "" {
|
||||
return nil
|
||||
}
|
||||
refundSerialNo := "REFUND" + strings.TrimSpace(contract.BalanceDetailSerialNo)
|
||||
var existing renewBalanceDetailRow
|
||||
existingRecord, err := tx.Ctx(ctx).Model(rc.TableBalanceDetail).
|
||||
Where("tenant_id", contract.TenantID).
|
||||
Where("serial_no", refundSerialNo).
|
||||
LockUpdate().
|
||||
One()
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return gerror.Wrap(err, "query renew refund detail failed")
|
||||
}
|
||||
if existingRecord != nil && !existingRecord.IsEmpty() {
|
||||
if err := existingRecord.Struct(&existing); err != nil {
|
||||
return gerror.Wrap(err, "decode renew refund detail failed")
|
||||
}
|
||||
}
|
||||
if existing.ID != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var original renewBalanceDetailRow
|
||||
originalRecord, err := tx.Ctx(ctx).Model(rc.TableBalanceDetail).
|
||||
Where("tenant_id", contract.TenantID).
|
||||
Where("serial_no", strings.TrimSpace(contract.BalanceDetailSerialNo)).
|
||||
LockUpdate().
|
||||
One()
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return gerror.Wrap(err, "query renew payment detail failed")
|
||||
}
|
||||
if originalRecord != nil && !originalRecord.IsEmpty() {
|
||||
if err := originalRecord.Struct(&original); err != nil {
|
||||
return gerror.Wrap(err, "decode renew payment detail failed")
|
||||
}
|
||||
}
|
||||
if original.ID == 0 {
|
||||
g.Log().Warningf(ctx, "renew balance refund skipped, original payment detail missing, batchNo=%s serialNo=%s",
|
||||
main.BatchNo, contract.BalanceDetailSerialNo)
|
||||
return nil
|
||||
}
|
||||
|
||||
payerDeptID := contract.PayerDeptID
|
||||
if payerDeptID <= 0 {
|
||||
payerDeptID = original.DeptID
|
||||
}
|
||||
var account renewBalanceAccountRow
|
||||
accountRecord, err := tx.Ctx(ctx).Model(rc.TableAgentBalance).
|
||||
Where("dept_id", payerDeptID).
|
||||
Where("tenant_id", contract.TenantID).
|
||||
LockUpdate().
|
||||
One()
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return gerror.Wrap(err, "lock renew refund account failed")
|
||||
}
|
||||
if accountRecord != nil && !accountRecord.IsEmpty() {
|
||||
if err := accountRecord.Struct(&account); err != nil {
|
||||
return gerror.Wrap(err, "decode renew refund account failed")
|
||||
}
|
||||
}
|
||||
if account.ID == 0 {
|
||||
return gerror.New("renew refund account missing")
|
||||
}
|
||||
if now == nil {
|
||||
now = gtime.Now()
|
||||
}
|
||||
amount := roundAmount(original.Amount)
|
||||
before := roundAmount(account.Balance)
|
||||
after := roundAmount(before + amount)
|
||||
if _, err := tx.Ctx(ctx).Model(rc.TableAgentBalance).Where("id", account.ID).Data(g.Map{
|
||||
"balance": after,
|
||||
"updated_by": contract.OperatorUserID,
|
||||
"updated_at": now,
|
||||
}).Update(); err != nil {
|
||||
return gerror.Wrap(err, "refund renew balance failed")
|
||||
}
|
||||
remark := fmt.Sprintf("SIM卡续费批次失败退款,批次号:%s,原因:%s", main.BatchNo, strings.TrimSpace(reason))
|
||||
if _, err := tx.Ctx(ctx).Model(rc.TableBalanceDetail).Data(g.Map{
|
||||
"dept_id": payerDeptID,
|
||||
"serial_no": refundSerialNo,
|
||||
"transaction_time": now,
|
||||
"income_expense_type": renewFinanceIncomeExpenseIncome,
|
||||
"transaction_type": renewFinanceTransactionTypeConsume,
|
||||
"transaction_channel": renewFinanceTransactionChannelBalance,
|
||||
"transaction_status": renewFinanceTransactionStatusPaid,
|
||||
"amount": amount,
|
||||
"before_balance": before,
|
||||
"balance_after": after,
|
||||
"order_sn": main.BatchNo,
|
||||
"remark": remark,
|
||||
"tenant_id": contract.TenantID,
|
||||
"created_dept": original.CreatedDept,
|
||||
"created_by": original.CreatedBy,
|
||||
"created_at": now,
|
||||
}).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "insert renew refund detail failed")
|
||||
}
|
||||
g.Log().Warningf(ctx, "renew balance refunded, batchNo=%s serialNo=%s refundSerialNo=%s amount=%.2f reason=%s",
|
||||
main.BatchNo, contract.BalanceDetailSerialNo, refundSerialNo, amount, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
func renewRefundContractFromBatch(main *rc.BatchMainRow) *ExecutionContract {
|
||||
if main == nil || main.BatchInfo == nil || main.BatchInfo.IsNil() {
|
||||
return nil
|
||||
}
|
||||
info := main.BatchInfo
|
||||
contract := &ExecutionContract{
|
||||
BatchNo: strings.TrimSpace(main.BatchNo),
|
||||
BatchType: main.BatchType,
|
||||
TenantID: strings.TrimSpace(main.TenantId),
|
||||
CustomerAgentID: firstInt64(info, "customerAgentId", "priceSnapshot.customerAgentId"),
|
||||
SellerAgentID: main.DeptId,
|
||||
CreatedDeptID: main.CreatedDept,
|
||||
OperatorUserID: main.CreatedBy,
|
||||
PaymentMode: firstString(info, "paymentMode", "paymentSnapshot.paymentMode"),
|
||||
PayType: firstInt(info, "payType", "paymentSnapshot.payType"),
|
||||
PayAmount: firstAmount(info, "payAmount", "paymentSnapshot.payAmount"),
|
||||
BalanceAmount: firstAmount(info, "balanceAmount", "paymentSnapshot.balanceAmount"),
|
||||
BalanceDetailSerialNo: firstString(info, "balanceDetailSerialNo", "paymentSnapshot.balanceDetailSerialNo"),
|
||||
PayerDeptID: firstInt64(info, "payerDeptId", "paymentSnapshot.payerDeptId"),
|
||||
PayerUserID: firstInt64(info, "payerUserId", "paymentSnapshot.payerUserId"),
|
||||
}
|
||||
contract.normalizePaymentContract()
|
||||
if contract.PaymentMode != "balance" || strings.TrimSpace(contract.BalanceDetailSerialNo) == "" || contract.BalanceAmount <= 0 {
|
||||
return nil
|
||||
}
|
||||
return contract
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
func TestValidateRuntimeRenewDurationRejectsUnsupportedOption(t *testing.T) {
|
||||
pkg := &rc.FlowPackageInfo{
|
||||
PackageCode: "PKG001",
|
||||
BillingMethod: consts.BillingMethodMonth,
|
||||
PackageName: "测试套餐",
|
||||
RenewableDurationConfig: gjson.New([]any{1, 2, 3}),
|
||||
}
|
||||
|
||||
options, err := validateRuntimeRenewDuration(pkg, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRuntimeRenewDuration returned error: %v", err)
|
||||
}
|
||||
if len(options) != 3 {
|
||||
t.Fatalf("options = %v, want 3 entries", options)
|
||||
}
|
||||
|
||||
_, err = validateRuntimeRenewDuration(pkg, 4)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported renew duration to be rejected")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "4年") || !strings.Contains(got, "测试套餐(PKG001)") || !strings.Contains(got, "3年") {
|
||||
t.Fatalf("error message %q does not include duration/package/options", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRuntimeRenewDurationNormalizesLegacyMonthOptions(t *testing.T) {
|
||||
pkg := &rc.FlowPackageInfo{
|
||||
PackageCode: "PKG001",
|
||||
PackageName: "测试套餐",
|
||||
RenewableDurationConfig: gjson.New([]any{12, 24, 36}),
|
||||
}
|
||||
|
||||
options, err := validateRuntimeRenewDuration(pkg, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRuntimeRenewDuration returned error: %v", err)
|
||||
}
|
||||
if got := options; len(got) != 3 || got[0] != 1 || got[1] != 2 || got[2] != 3 {
|
||||
t.Fatalf("options = %v, want [1 2 3]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddMonthsClampsToTargetMonthEnd(t *testing.T) {
|
||||
begin := time.Date(2027, time.January, 31, 0, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2027, time.February, 28, 0, 0, 0, 0, time.UTC)
|
||||
if got := addMonths(begin, 1); !got.Equal(want) {
|
||||
t.Fatalf("addMonths() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewBaseSubscriptionForCardPrefersLaterCardExpire(t *testing.T) {
|
||||
card := &rc.SimCardInfo{
|
||||
Iccid: "8986001",
|
||||
CurrentPackageCode: "PKG001",
|
||||
FlowPkgEndAt: gtime.NewFromStr("2026-08-31 00:00:00"),
|
||||
FlowPkgPresentAt: gtime.NewFromStr("2026-09-30 00:00:00"),
|
||||
FlowPkgRealPresent: gtime.NewFromStr("2026-10-31 00:00:00"),
|
||||
}
|
||||
bind := &rc.SimCardBindPkg{
|
||||
Iccid: "8986001",
|
||||
PackageCode: "PKG001",
|
||||
ExpectedExpireTime: gtime.NewFromStr("2026-08-31 00:00:00"),
|
||||
}
|
||||
|
||||
if got := renewBaseSubscriptionForCard(card, bind, nil); got == nil || got.ExpectedExpireTime.Format("Y-m-d") != "2026-10-31" {
|
||||
t.Fatalf("renew base expire = %v, want 2026-10-31", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecutionContractConvertsYearDurationToActualMonths(t *testing.T) {
|
||||
main := &rc.BatchMainRow{
|
||||
BatchNo: "REN20260806000000TEST",
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
TenantId: "000000",
|
||||
DeptId: 1000,
|
||||
CreatedDept: 1000,
|
||||
CreatedBy: 1,
|
||||
BatchInfo: gjson.New(map[string]any{
|
||||
"customerAgentId": 1001,
|
||||
"packageCode": "PKG001",
|
||||
"packageName": "Test package",
|
||||
"renewUnitPrice": 120,
|
||||
"quantity": 2,
|
||||
"billingMethod": consts.BillingMethodYear,
|
||||
"renewDurationUnit": consts.RenewDurationUnitYear,
|
||||
"durationValue": 1,
|
||||
"durationMonths": 1,
|
||||
"offlineContractCompleted": true,
|
||||
"paymentMode": "offline",
|
||||
}),
|
||||
}
|
||||
|
||||
contract, err := ParseExecutionContract(main)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExecutionContract returned error: %v", err)
|
||||
}
|
||||
if contract.DurationDisplayValue != 1 {
|
||||
t.Fatalf("display duration = %d, want 1", contract.DurationDisplayValue)
|
||||
}
|
||||
if contract.DurationMonths != 12 {
|
||||
t.Fatalf("actual duration months = %d, want 12", contract.DurationMonths)
|
||||
}
|
||||
if got := contract.TotalAmount(); got != 240 {
|
||||
t.Fatalf("total amount = %v, want 240", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRenewFactsRequiresSameCurrentPeriod(t *testing.T) {
|
||||
main := &rc.BatchMainRow{
|
||||
BatchNo: "REN20260806000000TEST",
|
||||
TenantId: "000000",
|
||||
}
|
||||
begin := gtime.NewFromStr("2026-01-01 00:00:00")
|
||||
expire := gtime.NewFromStr("2026-12-31 23:59:59")
|
||||
details := []*rc.BatchDetailRow{
|
||||
{RowNo: 1, ItemId: "898600000000000001"},
|
||||
{RowNo: 2, ItemId: "898600000000000002"},
|
||||
}
|
||||
cards := map[string]*rc.SimCardInfo{
|
||||
details[0].ItemId: {
|
||||
Iccid: details[0].ItemId,
|
||||
DeptId: 117,
|
||||
CurrentPackageCode: "PKG001",
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
TenantId: main.TenantId,
|
||||
},
|
||||
details[1].ItemId: {
|
||||
Iccid: details[1].ItemId,
|
||||
DeptId: 117,
|
||||
CurrentPackageCode: "PKG001",
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
TenantId: main.TenantId,
|
||||
},
|
||||
}
|
||||
binds := map[string]*rc.SimCardBindPkg{
|
||||
details[0].ItemId: {
|
||||
Iccid: details[0].ItemId,
|
||||
DeptId: 117,
|
||||
PackageCode: "PKG001",
|
||||
EffectiveTime: begin,
|
||||
ExpectedExpireTime: expire,
|
||||
TenantId: main.TenantId,
|
||||
},
|
||||
details[1].ItemId: {
|
||||
Iccid: details[1].ItemId,
|
||||
DeptId: 117,
|
||||
PackageCode: "PKG001",
|
||||
EffectiveTime: begin,
|
||||
ExpectedExpireTime: expire,
|
||||
TenantId: main.TenantId,
|
||||
},
|
||||
}
|
||||
|
||||
now := time.Date(2026, 8, 1, 0, 0, 0, 0, time.Local)
|
||||
if _, _, _, err := runtimeRenewFacts(main, details, cards, binds, nil, now); err != nil {
|
||||
t.Fatalf("runtimeRenewFacts() error = %v", err)
|
||||
}
|
||||
|
||||
binds[details[1].ItemId].ExpectedExpireTime = gtime.NewFromStr("2027-01-31 23:59:59")
|
||||
if _, _, _, err := runtimeRenewFacts(main, details, cards, binds, nil, now); err == nil {
|
||||
t.Fatalf("runtimeRenewFacts() error = nil, want same period failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewExecutionsUsesConfirmedDuration(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
base := now.AddDate(0, 1, 0)
|
||||
contract := testRenewContract(12, 2)
|
||||
details := []*rc.BatchDetailRow{{RowNo: 1, ItemId: "8986000000000000001"}}
|
||||
cards := map[string]*rc.SimCardInfo{
|
||||
details[0].ItemId: {
|
||||
Iccid: details[0].ItemId,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
CurrentPackageCode: contract.PackageCode,
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
FlowPkgBeginAt: gtime.NewFromTime(now.AddDate(0, -1, 0)),
|
||||
FlowPkgEndAt: gtime.NewFromTime(base),
|
||||
OutboundBatchNo: contract.SourceBatchNo,
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
binds := map[string]*rc.SimCardBindPkg{
|
||||
details[0].ItemId: {
|
||||
Iccid: details[0].ItemId,
|
||||
BatchNo: contract.SourceBatchNo,
|
||||
Msisdn: cards[details[0].ItemId].Msisdn,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
ExpectedExpireTime: gtime.NewFromTime(base),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := validateRenewExecutions(contract, details, cards, binds, nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRenewExecutions returned error: %v", err)
|
||||
}
|
||||
renewBegin := renewBeginDateFromExpire(gtime.NewFromTime(base))
|
||||
wantEnd := addMonths(renewBegin.Time, 14)
|
||||
if len(got) != 1 || !got[0].end.Equal(wantEnd) {
|
||||
t.Fatalf("renew end = %v, want %v", got[0].end, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewExecutionsUsesLatestBindExpiration(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
contract := testRenewContract(12, 0)
|
||||
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000002"}
|
||||
cardEnd := gtime.NewFromTime(time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
cards := map[string]*rc.SimCardInfo{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
CurrentPackageCode: contract.PackageCode,
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
FlowPkgEndAt: cardEnd,
|
||||
OutboundBatchNo: contract.SourceBatchNo,
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
binds := map[string]*rc.SimCardBindPkg{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
BatchNo: contract.SourceBatchNo,
|
||||
Msisdn: cards[detail.ItemId].Msisdn,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
ExpectedExpireTime: gtime.NewFromTime(cardEnd.Time.AddDate(0, 0, 1)),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRenewExecutions returned error: %v", err)
|
||||
}
|
||||
renewBegin := renewBeginDateFromExpire(gtime.NewFromTime(cardEnd.Time.AddDate(0, 0, 1)))
|
||||
wantEnd := addMonths(renewBegin.Time, contract.DurationMonths)
|
||||
if len(got) != 1 || !got[0].end.Equal(wantEnd) {
|
||||
t.Fatalf("renew end = %v, want %v", got[0].end, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewExecutionsAllowsRenewalFromExpiredSubscription(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
contract := testRenewContract(12, 0)
|
||||
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000006"}
|
||||
expiredAt := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
cards := map[string]*rc.SimCardInfo{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
CurrentPackageCode: contract.PackageCode,
|
||||
PackageStatus: consts.PackageStatusExpired,
|
||||
OutboundBatchNo: contract.SourceBatchNo,
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
binds := map[string]*rc.SimCardBindPkg{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
BatchNo: contract.SourceBatchNo,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
EffectiveStatus: consts.EffectiveStatusExpired,
|
||||
ExpectedExpireTime: gtime.NewFromTime(expiredAt),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRenewExecutions returned error: %v", err)
|
||||
}
|
||||
renewBegin := renewBeginDateFromExpire(gtime.NewFromTime(expiredAt))
|
||||
wantEnd := addMonths(renewBegin.Time, contract.DurationMonths)
|
||||
if len(got) != 1 || !got[0].begin.Equal(renewBegin.Time) || !got[0].end.Equal(wantEnd) {
|
||||
t.Fatalf("renew range = %v - %v, want %v - %v", got[0].begin, got[0].end, renewBegin.Time, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewExecutionsAppendsAfterFuturePendingExpiration(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
contract := testRenewContract(12, 0)
|
||||
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000005"}
|
||||
currentExpire := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
futureExpire := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
cards := map[string]*rc.SimCardInfo{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
CurrentPackageCode: contract.PackageCode,
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
OutboundBatchNo: contract.SourceBatchNo,
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
binds := map[string]*rc.SimCardBindPkg{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
BatchNo: contract.SourceBatchNo,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
ExpectedExpireTime: gtime.NewFromTime(currentExpire),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
futureBinds := map[string][]*rc.SimCardBindPkg{
|
||||
detail.ItemId: {
|
||||
{
|
||||
Iccid: detail.ItemId,
|
||||
BatchNo: "REN-FUTURE",
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
ExpectedExpireTime: gtime.NewFromTime(futureExpire),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, futureBinds, now)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRenewExecutions returned error: %v", err)
|
||||
}
|
||||
renewBegin := renewBeginDateFromExpire(gtime.NewFromTime(futureExpire))
|
||||
wantEnd := addMonths(renewBegin.Time, contract.DurationMonths)
|
||||
if len(got) != 1 || !got[0].begin.Equal(renewBegin.Time) || !got[0].end.Equal(wantEnd) {
|
||||
t.Fatalf("renew range = %v - %v, want %v - %v", got[0].begin, got[0].end, renewBegin.Time, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewExecutionsRejectsPendingExpirationBeyondWindow(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
contract := testRenewContract(12, 0)
|
||||
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000007"}
|
||||
currentExpire := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
futureExpire := time.Date(2027, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
cards := map[string]*rc.SimCardInfo{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
CurrentPackageCode: contract.PackageCode,
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
OutboundBatchNo: contract.SourceBatchNo,
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
binds := map[string]*rc.SimCardBindPkg{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
BatchNo: contract.SourceBatchNo,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
ExpectedExpireTime: gtime.NewFromTime(currentExpire),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
futureBinds := map[string][]*rc.SimCardBindPkg{
|
||||
detail.ItemId: {
|
||||
{
|
||||
Iccid: detail.ItemId,
|
||||
BatchNo: "REN-FUTURE",
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
ExpectedExpireTime: gtime.NewFromTime(futureExpire),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, futureBinds, now)
|
||||
if err == nil || !strings.Contains(err.Error(), renewExpireFailureReason) {
|
||||
t.Fatalf("validateRenewExecutions error = %v, want renew window failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunningRenewDetailsRejectsChangedSnapshot(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
locked []*rc.BatchDetailRow
|
||||
loaded []*rc.BatchDetailRow
|
||||
}{
|
||||
{
|
||||
name: "count",
|
||||
locked: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
loaded: nil,
|
||||
},
|
||||
{
|
||||
name: "id",
|
||||
locked: []*rc.BatchDetailRow{{Id: 2, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
loaded: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
},
|
||||
{
|
||||
name: "row number",
|
||||
locked: []*rc.BatchDetailRow{{Id: 1, RowNo: 2, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
loaded: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
},
|
||||
{
|
||||
name: "iccid",
|
||||
locked: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986002", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
loaded: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
locked: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusSuccess}},
|
||||
loaded: []*rc.BatchDetailRow{{Id: 1, RowNo: 1, ItemId: "8986001", DetailStatus: consts.BatchDetailStatusRunning}},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := validateRunningRenewDetails(tc.locked, tc.loaded); err == nil {
|
||||
t.Fatal("expected changed renew details to be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewExecutionsUsesConfirmedPackageBeginTime(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.Local)
|
||||
contract := testRenewContract(12, 0)
|
||||
confirmedBegin := time.Date(2026, 10, 1, 0, 0, 0, 0, time.Local)
|
||||
contract.PackageBeginTime = gtime.NewFromTime(confirmedBegin)
|
||||
detail := &rc.BatchDetailRow{RowNo: 1, ItemId: "8986000000000000004"}
|
||||
latestExpire := time.Date(2026, 9, 1, 0, 0, 0, 0, time.Local)
|
||||
cards := map[string]*rc.SimCardInfo{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
CurrentPackageCode: contract.PackageCode,
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
OutboundBatchNo: contract.SourceBatchNo,
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
binds := map[string]*rc.SimCardBindPkg{
|
||||
detail.ItemId: {
|
||||
Iccid: detail.ItemId,
|
||||
BatchNo: contract.SourceBatchNo,
|
||||
DeptId: contract.CustomerAgentID,
|
||||
PackageCode: contract.PackageCode,
|
||||
ExpectedExpireTime: gtime.NewFromTime(latestExpire),
|
||||
TenantId: contract.TenantID,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := validateRenewExecutions(contract, []*rc.BatchDetailRow{detail}, cards, binds, nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRenewExecutions returned error: %v", err)
|
||||
}
|
||||
wantEnd := addMonths(confirmedBegin, contract.DurationMonths)
|
||||
if len(got) != 1 || !got[0].begin.Equal(confirmedBegin) || !got[0].end.Equal(wantEnd) {
|
||||
t.Fatalf("renew range = %v - %v, want %v - %v", got[0].begin, got[0].end, confirmedBegin, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRenewBindUsesConfirmedContract(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
begin := now.AddDate(0, 1, 0)
|
||||
contract := testRenewContract(12, 3)
|
||||
pkg := &rc.FlowPackageInfo{PackageCode: contract.PackageCode, PackageType: 7}
|
||||
execution := renewExecution{
|
||||
card: &rc.SimCardInfo{Iccid: "8986000000000000003", Msisdn: "13800000000"},
|
||||
bind: &rc.SimCardBindPkg{ExpectedExpireTime: gtime.NewFromTime(begin)},
|
||||
begin: begin,
|
||||
end: contract.ServiceEnd(begin),
|
||||
}
|
||||
|
||||
bind := buildRenewBind(contract, pkg, execution, "SO20260803100000ABC", "SUB20260803100000ABC", now)
|
||||
if bind.DurationMonths != 12 || bind.GiftDurationMonths != 3 {
|
||||
t.Fatalf("duration = %d+%d, want 12+3", bind.DurationMonths, bind.GiftDurationMonths)
|
||||
}
|
||||
if bind.PackageType != 7 || bind.EffectiveTime.Time != begin {
|
||||
t.Fatalf("bind contract fields were not preserved: %+v", bind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRenewDetailSnapshotUsesConfirmedBatchAmount(t *testing.T) {
|
||||
contract := testRenewContract(12, 0)
|
||||
contract.Quantity = 2
|
||||
execution := renewExecution{
|
||||
detail: &rc.BatchDetailRow{RowNo: 1},
|
||||
card: &rc.SimCardInfo{Iccid: "8986000000000000003"},
|
||||
bind: &rc.SimCardBindPkg{},
|
||||
begin: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC),
|
||||
end: time.Date(2027, 8, 3, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
pkg := &rc.FlowPackageInfo{PackageCode: contract.PackageCode, PackageName: contract.PackageName}
|
||||
|
||||
snapshot := buildRenewDetailSnapshot(execution, contract, pkg, "SO-1", "SUB-1")
|
||||
if got := snapshot.Get("amount").Float64(); got != 240 {
|
||||
t.Fatalf("detail amount = %v, want confirmed batch amount 240", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewPackageStatus(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
if got := renewPackageStatus(now, gtime.NewFromTime(now.AddDate(0, 1, 0)), gtime.NewFromTime(now.AddDate(0, 13, 0))); got != consts.PackageStatusPending {
|
||||
t.Fatalf("future package status = %d, want pending", got)
|
||||
}
|
||||
if got := renewPackageStatus(now, gtime.NewFromTime(now.AddDate(0, -1, 0)), gtime.NewFromTime(now.AddDate(0, 1, 0))); got != consts.PackageStatusEffective {
|
||||
t.Fatalf("active package status = %d, want effective", got)
|
||||
}
|
||||
if got := renewPackageStatus(now, gtime.NewFromTime(now.AddDate(0, -2, 0)), gtime.NewFromTime(now.AddDate(0, -1, 0))); got != consts.PackageStatusExpired {
|
||||
t.Fatalf("expired package status = %d, want expired", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testRenewContract(duration, gift int) *ExecutionContract {
|
||||
return &ExecutionContract{
|
||||
BatchNo: "REN20260803100000TEST",
|
||||
BatchType: consts.BatchTypeRenew,
|
||||
SourceBatchNo: "OUT20260801TEST",
|
||||
TenantID: "000000",
|
||||
CustomerAgentID: 1001,
|
||||
SellerAgentID: 1000,
|
||||
CreatedDeptID: 1000,
|
||||
OperatorUserID: 1,
|
||||
PackageCode: "PKG001",
|
||||
PackageName: "Test package",
|
||||
PriceSource: PriceSourceSystem,
|
||||
UnitPrice: 10,
|
||||
Quantity: 1,
|
||||
DurationMonths: duration,
|
||||
GiftDurationMonths: gift,
|
||||
OfflineContractCompleted: true,
|
||||
BuyerNote: "buyer",
|
||||
AdminNote: "admin",
|
||||
Remark: "renew test",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"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/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
batchrepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/batch"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
// Repository orchestrates table access + batch status writes for renew/change
|
||||
// service. It embeds the typed table repository and adds batch lifecycle
|
||||
// helpers that align with the Admin side batch_main/batch_detail contract.
|
||||
type Repository struct {
|
||||
tables *rc.Repository
|
||||
batchRepo *batchrepo.Repository
|
||||
results batchresult.Generator
|
||||
}
|
||||
|
||||
// NewRepository builds a service-level repository backed by the typed table
|
||||
// repository (g.DB() default) and the shared batch repository.
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{
|
||||
tables: rc.New(),
|
||||
batchRepo: batchrepo.New(),
|
||||
results: batchresult.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// newRepositoryWithDeps allows tests to inject custom dependencies.
|
||||
func newRepositoryWithDeps(tables *rc.Repository, batchRepo *batchrepo.Repository, results batchresult.Generator) *Repository {
|
||||
return &Repository{tables: tables, batchRepo: batchRepo, results: results}
|
||||
}
|
||||
|
||||
// Tables exposes the low-level repository for tests.
|
||||
func (r *Repository) Tables() *rc.Repository { return r.tables }
|
||||
|
||||
// Batch exposes the batch repository.
|
||||
func (r *Repository) Batch() *batchrepo.Repository { return r.batchRepo }
|
||||
|
||||
// Results exposes the result file generator.
|
||||
func (r *Repository) Results() batchresult.Generator { return r.results }
|
||||
|
||||
// DB exposes the underlying gdb.DB handle.
|
||||
func (r *Repository) DB() gdb.DB { return r.tables.DB() }
|
||||
|
||||
// LoadProcessingBatch loads a processing renew/change/prepare batch by
|
||||
// (batchNo, batchType). It returns an error if the batch is not in
|
||||
// processing state so the caller can log and skip safely.
|
||||
func (r *Repository) LoadProcessingBatch(ctx context.Context, batchNo string, batchType int) (*rc.BatchMainRow, error) {
|
||||
main, err := r.tables.LoadBatchMain(ctx, batchNo, batchType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if main == nil {
|
||||
return nil, gerror.Newf("batch %s (type %d) does not exist", batchNo, batchType)
|
||||
}
|
||||
if main.BatchAsyncStatus != batch.AsyncStatusProcessing {
|
||||
return nil, gerror.Newf("batch %s (type %d) is not in processing state: %d", batchNo, batchType, main.BatchAsyncStatus)
|
||||
}
|
||||
return main, nil
|
||||
}
|
||||
|
||||
// LoadRunningDetails returns the running batch details ordered by row_no.
|
||||
func (r *Repository) LoadRunningDetails(ctx context.Context, batchNo string, batchType int) ([]*rc.BatchDetailRow, error) {
|
||||
return r.tables.LoadBatchDetails(ctx, batchNo, batchType, []int{consts.BatchDetailStatusRunning})
|
||||
}
|
||||
|
||||
// LoadAllDetails returns every detail regardless of status, used for the
|
||||
// confirm step (renew/change) to inspect previously committed results.
|
||||
func (r *Repository) LoadAllDetails(ctx context.Context, batchNo string, batchType int) ([]*rc.BatchDetailRow, error) {
|
||||
return r.tables.LoadBatchDetails(ctx, batchNo, batchType, nil)
|
||||
}
|
||||
|
||||
type ConvertPrepareResult struct {
|
||||
MainAffected int64
|
||||
DetailAffected int64
|
||||
}
|
||||
|
||||
// ConvertPrepareToRenewChange switches the prepare batch (type=4) to the
|
||||
// matching business type (101/102). The transaction must be the one used for
|
||||
// the detail update.
|
||||
func (r *Repository) ConvertPrepareToRenewChange(ctx context.Context, tx gdb.TX, batchNo string, targetType int) (*ConvertPrepareResult, error) {
|
||||
detailResult, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare).
|
||||
Data(do.BatchDetail{BatchType: targetType, UpdatedAt: gtime.Now()}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "convert prepare details to target type failed")
|
||||
}
|
||||
detailAffected, err := detailResult.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mainResult, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypePrepare).
|
||||
Data(do.BatchMain{BatchType: targetType, UpdatedAt: gtime.Now()}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "convert prepare batch type failed")
|
||||
}
|
||||
mainAffected, err := mainResult.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mainAffected == 0 {
|
||||
return nil, gerror.New("prepare batch main already converted or missing")
|
||||
}
|
||||
return &ConvertPrepareResult{
|
||||
MainAffected: mainAffected,
|
||||
DetailAffected: detailAffected,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MarkDetailFailed writes a single detail as failed with the given reason.
|
||||
func (r *Repository) MarkDetailFailed(ctx context.Context, tx gdb.TX, batchNo string, batchType, rowNo int, reason string, userID int64) error {
|
||||
return r.tables.UpdateBatchDetailSnapshot(ctx, tx, batchNo, batchType, rowNo, consts.BatchDetailStatusFailed, nil, reason, userID)
|
||||
}
|
||||
|
||||
// MarkDetailSuccess writes a single detail as success with the given snapshot.
|
||||
func (r *Repository) MarkDetailSuccess(ctx context.Context, tx gdb.TX, batchNo string, batchType, rowNo int, snapshot *gjson.Json, userID int64) error {
|
||||
return r.tables.UpdateBatchDetailSnapshot(ctx, tx, batchNo, batchType, rowNo, consts.BatchDetailStatusSuccess, snapshot, "", userID)
|
||||
}
|
||||
|
||||
func (r *Repository) MarkPrepareDetailSuccess(ctx context.Context, tx gdb.TX, batchNo string, rowNo int, itemID string, snapshot *gjson.Json, userID int64) error {
|
||||
data := map[string]any{
|
||||
dao.BatchDetail.Columns().DetailStatus: consts.BatchDetailStatusSuccess,
|
||||
dao.BatchDetail.Columns().Snapshot: snapshot,
|
||||
dao.BatchDetail.Columns().UpdatedBy: userID,
|
||||
dao.BatchDetail.Columns().UpdatedAt: gtime.Now(),
|
||||
}
|
||||
if itemID = strings.TrimSpace(itemID); itemID != "" {
|
||||
data[dao.BatchDetail.Columns().ItemId] = itemID
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare).
|
||||
Where(dao.BatchDetail.Columns().RowNo, rowNo).
|
||||
Data(data).
|
||||
Update()
|
||||
return gerror.Wrap(err, "mark prepare detail success failed")
|
||||
}
|
||||
|
||||
// MarkAllDetailsFailed marks every unfinished detail as failed in a single update.
|
||||
func (r *Repository) MarkAllDetailsFailed(ctx context.Context, tx gdb.TX, batchNo string, batchType int, reason string, userID int64) error {
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, batchType).
|
||||
WhereIn(dao.BatchDetail.Columns().DetailStatus, []int{consts.BatchDetailStatusPending, consts.BatchDetailStatusRunning}).
|
||||
Data(do.BatchDetail{
|
||||
DetailStatus: consts.BatchDetailStatusFailed,
|
||||
ErrorReason: reason,
|
||||
UpdatedBy: userID,
|
||||
UpdatedAt: gtime.Now(),
|
||||
}).
|
||||
Update()
|
||||
return gerror.Wrap(err, "mark all running details failed")
|
||||
}
|
||||
|
||||
// MarkAllDetailsSuccess marks every running detail as success.
|
||||
func (r *Repository) MarkAllDetailsSuccess(ctx context.Context, tx gdb.TX, batchNo string, batchType int, userID int64, extra map[string]any) error {
|
||||
data := map[string]any{
|
||||
"detail_status": consts.BatchDetailStatusSuccess,
|
||||
"updated_by": userID,
|
||||
"updated_at": gtime.Now(),
|
||||
}
|
||||
for k, v := range extra {
|
||||
data[k] = v
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, batchType).
|
||||
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
|
||||
Data(data).
|
||||
Update()
|
||||
return gerror.Wrap(err, "mark all running details success")
|
||||
}
|
||||
|
||||
// UpdateBatchMainInfo merges new entries into batch_main.batch_info and
|
||||
// updates the lifecycle counters in a single statement.
|
||||
func (r *Repository) UpdateBatchMainInfo(ctx context.Context, tx gdb.TX, batchNo string, batchType int, data *rc.BatchMainRow) error {
|
||||
return r.tables.UpdateBatchMainStatus(ctx, tx, batchNo, batchType, data)
|
||||
}
|
||||
|
||||
// FinalizeBatchMain updates only the lifecycle fields (status, counts,
|
||||
// fail_reason, end_time) while keeping batch_info untouched.
|
||||
func (r *Repository) FinalizeBatchMain(ctx context.Context, tx gdb.TX, batchNo string, batchType int, data *rc.BatchMainRow) error {
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchMain.Columns().BatchType, batchType).
|
||||
Data(do.BatchMain{
|
||||
BatchAsyncStatus: data.BatchAsyncStatus,
|
||||
RunResult: data.RunResult,
|
||||
SuccessCount: data.SuccessCount,
|
||||
FailCount: data.FailCount,
|
||||
ValidCount: data.ValidCount,
|
||||
TotalCount: data.TotalCount,
|
||||
FailReason: data.FailReason,
|
||||
EndTime: data.EndTime,
|
||||
UpdatedBy: data.UpdatedBy,
|
||||
}).
|
||||
Update()
|
||||
return gerror.Wrap(err, "finalize batch main failed")
|
||||
}
|
||||
|
||||
// ResolveResultSnapshot returns the per-row data needed to render the result
|
||||
// file. It always includes every detail (success and failure) for the batch.
|
||||
func (r *Repository) ResolveResultSnapshot(ctx context.Context, batchNo string, batchType int) ([]ResultRow, error) {
|
||||
details, err := r.tables.LoadBatchDetails(ctx, batchNo, batchType, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]ResultRow, 0, len(details))
|
||||
for _, d := range details {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
msisdn, imsi := "", ""
|
||||
targetCode, packageCode, effectiveMode, orderNum := "", "", "", ""
|
||||
if d.Snapshot != nil {
|
||||
msisdn = strings.TrimSpace(d.Snapshot.Get("msisdn").String())
|
||||
imsi = strings.TrimSpace(d.Snapshot.Get("imsi").String())
|
||||
targetCode = strings.TrimSpace(d.Snapshot.Get("targetCode").String())
|
||||
if targetCode == "" {
|
||||
targetCode = strings.TrimSpace(d.ItemId)
|
||||
}
|
||||
packageCode = strings.TrimSpace(d.Snapshot.Get("packageCode").String())
|
||||
effectiveMode = strings.TrimSpace(d.Snapshot.Get("effectiveMode").String())
|
||||
orderNum = strings.TrimSpace(d.Snapshot.Get("orderSn").String())
|
||||
if orderNum == "" {
|
||||
orderNum = strings.TrimSpace(d.Snapshot.Get("orderNo").String())
|
||||
}
|
||||
}
|
||||
rows = append(rows, ResultRow{
|
||||
RowNo: d.RowNo,
|
||||
ICCID: strings.TrimSpace(d.ItemId),
|
||||
TargetCode: targetCode,
|
||||
PackageCode: packageCode,
|
||||
EffectiveMode: effectiveMode,
|
||||
MSISDN: msisdn,
|
||||
IMSI: imsi,
|
||||
Success: d.DetailStatus == consts.BatchDetailStatusSuccess,
|
||||
Reason: strings.TrimSpace(d.ErrorReason),
|
||||
OrderNum: orderNum,
|
||||
Snap: d.Snapshot,
|
||||
DetailID: d.Id,
|
||||
DetailCode: d.ErrorCode,
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// WriteResultFileSafe persists a result Excel and OSS/download records. It
|
||||
// never panics on a bad batch type; missing configuration is logged and the
|
||||
// business outcome is preserved. The caller must not abort the batch when
|
||||
// this returns an error.
|
||||
func (r *Repository) WriteResultFileSafe(ctx context.Context, batchNo string, batchType int, tenantID string, deptID, userID int64, rows []ResultRow) error {
|
||||
if r.results == nil {
|
||||
return nil
|
||||
}
|
||||
brRows := make([]batchresult.Row, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
brRows = append(brRows, batchresult.Row{
|
||||
RowNo: row.RowNo,
|
||||
ObjectCode: row.TargetCode,
|
||||
TargetCode: row.TargetCode,
|
||||
PackageCode: row.PackageCode,
|
||||
EffectiveMode: row.EffectiveMode,
|
||||
ICCID: row.ICCID,
|
||||
MSISDN: row.MSISDN,
|
||||
IMSI: row.IMSI,
|
||||
Success: row.Success,
|
||||
Reason: row.Reason,
|
||||
OrderNum: row.OrderNum,
|
||||
})
|
||||
}
|
||||
request := batchresult.Request{
|
||||
BatchNo: batchNo,
|
||||
BatchType: batchType,
|
||||
TenantID: strings.TrimSpace(tenantID),
|
||||
Rows: brRows,
|
||||
}
|
||||
artifact, err := r.results.Create(ctx, request)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "renew/change result file generation failed, batchNo:%s, batchType:%d, err:%+v", batchNo, batchType, err)
|
||||
return err
|
||||
}
|
||||
if err = r.persistArtifact(ctx, artifact, batchNo, batchType, tenantID, deptID, userID, len(rows)); err != nil {
|
||||
g.Log().Warningf(ctx, "renew/change result file bookkeeping failed, batchNo:%s, batchType:%d, err:%+v", batchNo, batchType, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) persistArtifact(ctx context.Context, artifact batchresult.Artifact, batchNo string, batchType int, tenantID string, deptID, userID int64, total int) error {
|
||||
return r.tables.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
ossID, err := r.tables.InsertSysOss(ctx, tx, &rc.SysOssRow{
|
||||
TenantId: tenantID,
|
||||
FileName: artifact.FileName,
|
||||
OriginalName: artifact.OriginalName,
|
||||
FileSuffix: artifact.FileSuffix,
|
||||
Path: artifact.Path,
|
||||
Url: artifact.URL,
|
||||
Md5: artifact.MD5,
|
||||
FileSize: artifact.Size,
|
||||
Service: "local",
|
||||
CreatedDept: deptID,
|
||||
CreatedBy: userID,
|
||||
UpdatedBy: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
successCount, failCount := 0, 0
|
||||
main, mErr := r.tables.LoadBatchMain(ctx, batchNo, batchType)
|
||||
if mErr == nil && main != nil {
|
||||
successCount = main.SuccessCount
|
||||
failCount = main.FailCount
|
||||
}
|
||||
_, err = r.tables.InsertBatchDownload(ctx, tx, &rc.ResourceBatchDownloadRow{
|
||||
BatchNo: batchNo,
|
||||
TaskName: batchresult.TaskName(batchType),
|
||||
ResourceType: consts.ResourceTypeExcel,
|
||||
ExportScene: batchType,
|
||||
TotalCount: total,
|
||||
SuccessCount: successCount,
|
||||
FailCount: failCount,
|
||||
DownloadStatus: consts.DownloadStatusGenerated,
|
||||
OssId: ossID,
|
||||
FileName: artifact.FileName,
|
||||
FileExt: artifact.FileSuffix,
|
||||
FileUrl: artifact.URL,
|
||||
FilePath: artifact.Path,
|
||||
FileSize: artifact.Size,
|
||||
FileMd5: artifact.MD5,
|
||||
DownloadCount: 0,
|
||||
DeptId: deptID,
|
||||
StartTime: gtime.Now(),
|
||||
FinishTime: gtime.Now(),
|
||||
TenantId: tenantID,
|
||||
CreatedDept: deptID,
|
||||
CreatedBy: userID,
|
||||
UpdatedBy: userID,
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// LoadSystemContext returns tenant id and a default user id (CreatedBy) for
|
||||
// the given batch. Centralized so individual executors stay slim.
|
||||
func (r *Repository) LoadSystemContext(ctx context.Context, batchNo string, batchType int) (string, int64, int64, error) {
|
||||
main, err := r.tables.LoadBatchMain(ctx, batchNo, batchType)
|
||||
if err != nil {
|
||||
return "", 0, 0, err
|
||||
}
|
||||
if main == nil {
|
||||
return "", 0, 0, gerror.Newf("batch %s (type %d) does not exist", batchNo, batchType)
|
||||
}
|
||||
return main.TenantId, main.CreatedBy, main.CreatedDept, nil
|
||||
}
|
||||
|
||||
// ResultRow is the per-row information needed by the result file.
|
||||
type ResultRow struct {
|
||||
RowNo int
|
||||
ICCID string
|
||||
TargetCode string
|
||||
PackageCode string
|
||||
EffectiveMode string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
Success bool
|
||||
Reason string
|
||||
OrderNum string
|
||||
Upstream int
|
||||
Snap *gjson.Json
|
||||
DetailID int64
|
||||
DetailCode string
|
||||
}
|
||||
|
||||
func tenantIDtoString(tenantID int64) string {
|
||||
return strconv.FormatInt(tenantID, 10)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
type subscriptionValidation struct {
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
TenantID string
|
||||
CustomerDeptID int64
|
||||
PackageCode string
|
||||
SourceBatchNo string
|
||||
}
|
||||
|
||||
// resolveCurrentSubscriptions selects the current package fact per ICCID.
|
||||
// It keeps future pending subscriptions for change-package cleanup, while
|
||||
// allowing renew to append after the farthest pending subscription.
|
||||
func resolveCurrentSubscriptions(rows []*rc.SimCardBindPkg, iccids []string) (map[string]*rc.SimCardBindPkg, map[string][]*rc.SimCardBindPkg, error) {
|
||||
grouped := make(map[string][]*rc.SimCardBindPkg, len(iccids))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(row.Iccid)
|
||||
if iccid == "" {
|
||||
continue
|
||||
}
|
||||
grouped[iccid] = append(grouped[iccid], row)
|
||||
}
|
||||
|
||||
current := make(map[string]*rc.SimCardBindPkg, len(iccids))
|
||||
future := make(map[string][]*rc.SimCardBindPkg, len(iccids))
|
||||
for _, rawICCID := range iccids {
|
||||
iccid := strings.TrimSpace(rawICCID)
|
||||
selected, pending, err := selectCurrentSubscription(grouped[iccid], iccid)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
current[iccid] = selected
|
||||
future[iccid] = pending
|
||||
}
|
||||
return current, future, nil
|
||||
}
|
||||
|
||||
func selectCurrentSubscription(rows []*rc.SimCardBindPkg, iccid string) (*rc.SimCardBindPkg, []*rc.SimCardBindPkg, error) {
|
||||
var (
|
||||
effective *rc.SimCardBindPkg
|
||||
pending []*rc.SimCardBindPkg
|
||||
expired []*rc.SimCardBindPkg
|
||||
)
|
||||
for _, row := range rows {
|
||||
if row == nil || strings.TrimSpace(row.Iccid) != strings.TrimSpace(iccid) {
|
||||
continue
|
||||
}
|
||||
if consts.IsAddPackageType(row.PackageType) {
|
||||
continue
|
||||
}
|
||||
switch row.EffectiveStatus {
|
||||
case consts.EffectiveStatusEffective:
|
||||
if effective != nil {
|
||||
return nil, nil, gerror.Newf("同一张卡存在多个生效中的套餐订购记录:%s", iccid)
|
||||
}
|
||||
effective = row
|
||||
case consts.EffectiveStatusPending:
|
||||
pending = append(pending, row)
|
||||
case consts.EffectiveStatusExpired:
|
||||
expired = append(expired, row)
|
||||
}
|
||||
}
|
||||
sortPendingSubscriptions(pending)
|
||||
if effective != nil {
|
||||
return effective, pending, nil
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
return pending[0], pending[1:], nil
|
||||
}
|
||||
if latest := latestExpiredSubscription(expired); latest != nil {
|
||||
return latest, nil, nil
|
||||
}
|
||||
return nil, nil, gerror.Newf("ICCID没有当前套餐订购记录:%s", iccid)
|
||||
}
|
||||
|
||||
func latestExpiredSubscription(rows []*rc.SimCardBindPkg) *rc.SimCardBindPkg {
|
||||
var latest *rc.SimCardBindPkg
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
if latest == nil || subscriptionAfter(row, latest) {
|
||||
latest = row
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func sortPendingSubscriptions(rows []*rc.SimCardBindPkg) {
|
||||
if len(rows) <= 1 {
|
||||
return
|
||||
}
|
||||
for i := 1; i < len(rows); i++ {
|
||||
current := rows[i]
|
||||
j := i - 1
|
||||
for j >= 0 && subscriptionAfter(rows[j], current) {
|
||||
rows[j+1] = rows[j]
|
||||
j--
|
||||
}
|
||||
rows[j+1] = current
|
||||
}
|
||||
}
|
||||
|
||||
func subscriptionAfter(left, right *rc.SimCardBindPkg) bool {
|
||||
if compareTime(expectedExpireTime(left), expectedExpireTime(right)) != 0 {
|
||||
return compareTime(expectedExpireTime(left), expectedExpireTime(right)) > 0
|
||||
}
|
||||
if compareTime(effectiveTime(left), effectiveTime(right)) != 0 {
|
||||
return compareTime(effectiveTime(left), effectiveTime(right)) > 0
|
||||
}
|
||||
return strings.TrimSpace(subscriptionNo(left)) > strings.TrimSpace(subscriptionNo(right))
|
||||
}
|
||||
|
||||
func expectedExpireTime(row *rc.SimCardBindPkg) *gtime.Time {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
return row.ExpectedExpireTime
|
||||
}
|
||||
|
||||
func effectiveTime(row *rc.SimCardBindPkg) *gtime.Time {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
return row.EffectiveTime
|
||||
}
|
||||
|
||||
func subscriptionNo(row *rc.SimCardBindPkg) string {
|
||||
if row == nil {
|
||||
return ""
|
||||
}
|
||||
return row.SubscriptionNo
|
||||
}
|
||||
|
||||
func compareTime(left, right *gtime.Time) int {
|
||||
switch {
|
||||
case left == nil && right == nil:
|
||||
return 0
|
||||
case left == nil:
|
||||
return -1
|
||||
case right == nil:
|
||||
return 1
|
||||
case left.Before(right):
|
||||
return -1
|
||||
case left.After(right):
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func validateCurrentSubscription(card *rc.SimCardInfo, bind *rc.SimCardBindPkg, expected subscriptionValidation) error {
|
||||
if card == nil || bind == nil {
|
||||
return gerror.New("卡信息或当前订购记录缺失")
|
||||
}
|
||||
iccid := strings.TrimSpace(expected.ICCID)
|
||||
if iccid == "" || strings.TrimSpace(card.Iccid) != iccid || strings.TrimSpace(bind.Iccid) != iccid {
|
||||
return gerror.New("ICCID不一致")
|
||||
}
|
||||
if strings.TrimSpace(expected.TenantID) != "" &&
|
||||
(strings.TrimSpace(card.TenantId) != expected.TenantID || strings.TrimSpace(bind.TenantId) != expected.TenantID) {
|
||||
return gerror.New("租户不一致")
|
||||
}
|
||||
// t_sim_card_info.dept_id is the current card owner. A card allocation
|
||||
// changes that field, while the historical bind row keeps the department
|
||||
// that originally purchased the subscription.
|
||||
if expected.CustomerDeptID <= 0 || card.DeptId != expected.CustomerDeptID {
|
||||
return gerror.New("客户部门不一致")
|
||||
}
|
||||
packageCode := strings.TrimSpace(expected.PackageCode)
|
||||
if packageCode == "" || strings.TrimSpace(card.CurrentPackageCode) != packageCode || strings.TrimSpace(bind.PackageCode) != packageCode {
|
||||
return gerror.New("当前套餐不一致")
|
||||
}
|
||||
if card.PackageStatus == consts.PackageStatusNone {
|
||||
return gerror.New("卡没有当前套餐")
|
||||
}
|
||||
if msisdn := strings.TrimSpace(expected.MSISDN); msisdn != "" && strings.TrimSpace(card.Msisdn) != msisdn {
|
||||
return gerror.New("MSISDN不一致")
|
||||
}
|
||||
if bindMSISDN := strings.TrimSpace(bind.Msisdn); bindMSISDN != "" && strings.TrimSpace(card.Msisdn) != bindMSISDN {
|
||||
return gerror.New("订购记录MSISDN不一致")
|
||||
}
|
||||
if imsi := strings.TrimSpace(expected.IMSI); imsi != "" && strings.TrimSpace(card.Imsi) != imsi {
|
||||
return gerror.New("IMSI不一致")
|
||||
}
|
||||
if sourceBatchNo := strings.TrimSpace(expected.SourceBatchNo); sourceBatchNo != "" {
|
||||
bindBatchNo := strings.TrimSpace(bind.BatchNo)
|
||||
outboundBatchNo := strings.TrimSpace(card.OutboundBatchNo)
|
||||
if bindBatchNo != sourceBatchNo && outboundBatchNo != sourceBatchNo {
|
||||
return gerror.New("当前订购来源批次不一致")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
func TestResolveCurrentSubscriptionsRejectsMissingAndDuplicatePriorities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rows []*rc.SimCardBindPkg
|
||||
}{
|
||||
{name: "missing"},
|
||||
{
|
||||
name: "duplicate effective",
|
||||
rows: []*rc.SimCardBindPkg{
|
||||
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusEffective},
|
||||
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusEffective},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, _, err := resolveCurrentSubscriptions(tt.rows, []string{"8986001"}); err == nil {
|
||||
t.Fatal("resolveCurrentSubscriptions() error = nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectCurrentSubscriptionIgnoresCardAddPackage(t *testing.T) {
|
||||
iccid := "8986000000000000103"
|
||||
formal := &rc.SimCardBindPkg{
|
||||
Iccid: iccid,
|
||||
PackageType: consts.PackageTypeCard,
|
||||
PackageCode: "CARD-FORMAL",
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
}
|
||||
addPackage := &rc.SimCardBindPkg{
|
||||
Iccid: iccid,
|
||||
PackageType: consts.PackageTypeCardAddPackage,
|
||||
PackageCode: "CARD-ADD",
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
}
|
||||
|
||||
current, future, err := selectCurrentSubscription([]*rc.SimCardBindPkg{formal, addPackage}, iccid)
|
||||
if err != nil {
|
||||
t.Fatalf("selectCurrentSubscription() error = %v", err)
|
||||
}
|
||||
if current != formal {
|
||||
t.Fatalf("current package = %+v, want formal package", current)
|
||||
}
|
||||
if len(future) != 0 {
|
||||
t.Fatalf("future package count = %d, want 0", len(future))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectCurrentSubscriptionIgnoresPoolAddPackage(t *testing.T) {
|
||||
iccid := "8986000000000000104"
|
||||
formal := &rc.SimCardBindPkg{
|
||||
Iccid: iccid,
|
||||
PackageType: consts.PackageTypePool,
|
||||
PackageCode: "POOL-FORMAL",
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
}
|
||||
addPackage := &rc.SimCardBindPkg{
|
||||
Iccid: iccid,
|
||||
PackageType: consts.PackageTypePoolAddPackage,
|
||||
PackageCode: "POOL-ADD",
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
}
|
||||
|
||||
current, _, err := selectCurrentSubscription([]*rc.SimCardBindPkg{addPackage, formal}, iccid)
|
||||
if err != nil {
|
||||
t.Fatalf("selectCurrentSubscription() error = %v", err)
|
||||
}
|
||||
if current != formal {
|
||||
t.Fatalf("current package = %+v, want formal package", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectCurrentSubscriptionRejectsTwoEffectiveFormalPackages(t *testing.T) {
|
||||
iccid := "8986000000000000102"
|
||||
rows := []*rc.SimCardBindPkg{
|
||||
{
|
||||
Iccid: iccid,
|
||||
PackageType: consts.PackageTypeCard,
|
||||
PackageCode: "FORMAL-1",
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
},
|
||||
{
|
||||
Iccid: iccid,
|
||||
PackageType: consts.PackageTypePool,
|
||||
PackageCode: "FORMAL-2",
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := selectCurrentSubscription(rows, iccid)
|
||||
if err == nil {
|
||||
t.Fatal("selectCurrentSubscription() error = nil, want duplicate formal package error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "多个生效中的套餐订购记录") {
|
||||
t.Fatalf("error = %q, want duplicate formal package message", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentSubscriptionsAllowsChainedPendingRenewals(t *testing.T) {
|
||||
rows := []*rc.SimCardBindPkg{
|
||||
{Iccid: "8986001", SubscriptionNo: "SUB-E", EffectiveStatus: consts.EffectiveStatusEffective},
|
||||
{Iccid: "8986001", SubscriptionNo: "SUB-P2", EffectiveStatus: consts.EffectiveStatusPending, ExpectedExpireTime: gtime.NewFromStr("2028-01-01 00:00:00")},
|
||||
{Iccid: "8986001", SubscriptionNo: "SUB-P1", EffectiveStatus: consts.EffectiveStatusPending, ExpectedExpireTime: gtime.NewFromStr("2027-01-01 00:00:00")},
|
||||
}
|
||||
current, future, err := resolveCurrentSubscriptions(rows, []string{"8986001"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCurrentSubscriptions() error = %v", err)
|
||||
}
|
||||
if current["8986001"].SubscriptionNo != "SUB-E" {
|
||||
t.Fatalf("current subscription = %q, want SUB-E", current["8986001"].SubscriptionNo)
|
||||
}
|
||||
if len(future["8986001"]) != 2 {
|
||||
t.Fatalf("future subscriptions len = %d, want 2", len(future["8986001"]))
|
||||
}
|
||||
if future["8986001"][0].SubscriptionNo != "SUB-P1" || future["8986001"][1].SubscriptionNo != "SUB-P2" {
|
||||
t.Fatalf("future subscriptions order = %+v, want SUB-P1,SUB-P2", future["8986001"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentSubscriptionsFallsBackToLatestExpiredSubscription(t *testing.T) {
|
||||
rows := []*rc.SimCardBindPkg{
|
||||
{
|
||||
Iccid: "8986001",
|
||||
SubscriptionNo: "SUB-X1",
|
||||
EffectiveStatus: consts.EffectiveStatusExpired,
|
||||
ExpectedExpireTime: gtime.NewFromStr("2026-01-01 00:00:00"),
|
||||
},
|
||||
{
|
||||
Iccid: "8986001",
|
||||
SubscriptionNo: "SUB-X2",
|
||||
EffectiveStatus: consts.EffectiveStatusExpired,
|
||||
ExpectedExpireTime: gtime.NewFromStr("2026-07-01 00:00:00"),
|
||||
},
|
||||
{
|
||||
Iccid: "8986001",
|
||||
SubscriptionNo: "SUB-T",
|
||||
EffectiveStatus: consts.EffectiveStatusTerminated,
|
||||
},
|
||||
}
|
||||
current, future, err := resolveCurrentSubscriptions(rows, []string{"8986001"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCurrentSubscriptions() error = %v", err)
|
||||
}
|
||||
if current["8986001"] == nil || current["8986001"].SubscriptionNo != "SUB-X2" {
|
||||
t.Fatalf("current subscription = %+v, want SUB-X2", current["8986001"])
|
||||
}
|
||||
if len(future["8986001"]) != 0 {
|
||||
t.Fatalf("future subscriptions = %+v, want empty", future["8986001"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentSubscriptionsPrefersPendingOverExpiredHistory(t *testing.T) {
|
||||
rows := []*rc.SimCardBindPkg{
|
||||
{
|
||||
Iccid: "8986001",
|
||||
SubscriptionNo: "SUB-X",
|
||||
EffectiveStatus: consts.EffectiveStatusExpired,
|
||||
ExpectedExpireTime: gtime.NewFromStr("2026-07-01 00:00:00"),
|
||||
},
|
||||
{
|
||||
Iccid: "8986001",
|
||||
SubscriptionNo: "SUB-P",
|
||||
EffectiveStatus: consts.EffectiveStatusPending,
|
||||
ExpectedExpireTime: gtime.NewFromStr("2027-07-01 00:00:00"),
|
||||
},
|
||||
}
|
||||
current, future, err := resolveCurrentSubscriptions(rows, []string{"8986001"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCurrentSubscriptions() error = %v", err)
|
||||
}
|
||||
if current["8986001"] == nil || current["8986001"].SubscriptionNo != "SUB-P" {
|
||||
t.Fatalf("current subscription = %+v, want SUB-P", current["8986001"])
|
||||
}
|
||||
if len(future["8986001"]) != 0 {
|
||||
t.Fatalf("future subscriptions = %+v, want empty", future["8986001"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentSubscriptionsStillRejectsOnlyTerminatedOrVoidedHistory(t *testing.T) {
|
||||
rows := []*rc.SimCardBindPkg{
|
||||
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusTerminated},
|
||||
{Iccid: "8986001", EffectiveStatus: consts.EffectiveStatusVoided},
|
||||
}
|
||||
if _, _, err := resolveCurrentSubscriptions(rows, []string{"8986001"}); err == nil {
|
||||
t.Fatal("terminated/voided history was accepted as current subscription")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCurrentSubscriptionChecksPackageAndBusinessSource(t *testing.T) {
|
||||
card, bind, expected := validSubscriptionValidationFixture()
|
||||
if err := validateCurrentSubscription(card, bind, expected); err != nil {
|
||||
t.Fatalf("validateCurrentSubscription() error = %v", err)
|
||||
}
|
||||
|
||||
bind.PackageCode = "OTHER"
|
||||
if err := validateCurrentSubscription(card, bind, expected); err == nil {
|
||||
t.Fatal("package mismatch was accepted")
|
||||
}
|
||||
bind.PackageCode = expected.PackageCode
|
||||
bind.BatchNo = "OUT-OTHER"
|
||||
card.OutboundBatchNo = "OUT-OTHER"
|
||||
if err := validateCurrentSubscription(card, bind, expected); err == nil {
|
||||
t.Fatal("source batch mismatch was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCurrentSubscriptionIgnoresSourceForInputSelfReference(t *testing.T) {
|
||||
card, bind, expected := validSubscriptionValidationFixture()
|
||||
bind.BatchNo = "OUT-ACTUAL"
|
||||
card.OutboundBatchNo = ""
|
||||
expected.SourceBatchNo = ""
|
||||
if err := validateCurrentSubscription(card, bind, expected); err != nil {
|
||||
t.Fatalf("input source should be ignored, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCurrentSubscriptionRejectsIdentityChanges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*rc.SimCardInfo, *rc.SimCardBindPkg, *subscriptionValidation)
|
||||
}{
|
||||
{name: "ICCID", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) { card.Iccid = "8986002" }},
|
||||
{name: "MSISDN", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) {
|
||||
card.Msisdn = "13900000000"
|
||||
}},
|
||||
{name: "IMSI", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) { card.Imsi = "460002" }},
|
||||
{name: "tenant", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) { card.TenantId = "OTHER" }},
|
||||
{name: "customer", mutate: func(card *rc.SimCardInfo, _ *rc.SimCardBindPkg, _ *subscriptionValidation) { card.DeptId = 118 }},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
card, bind, expected := validSubscriptionValidationFixture()
|
||||
tt.mutate(card, bind, &expected)
|
||||
if err := validateCurrentSubscription(card, bind, expected); err == nil {
|
||||
t.Fatalf("%s change was accepted", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCurrentSubscriptionAllowsAllocatedCardWithHistoricalBindDept(t *testing.T) {
|
||||
card, bind, expected := validSubscriptionValidationFixture()
|
||||
bind.DeptId = 100
|
||||
if err := validateCurrentSubscription(card, bind, expected); err != nil {
|
||||
t.Fatalf("allocated card with historical bind department was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validSubscriptionValidationFixture() (*rc.SimCardInfo, *rc.SimCardBindPkg, subscriptionValidation) {
|
||||
expected := subscriptionValidation{
|
||||
ICCID: "8986001",
|
||||
MSISDN: "13800000000",
|
||||
IMSI: "460001",
|
||||
TenantID: "000000",
|
||||
CustomerDeptID: 117,
|
||||
PackageCode: "PKG-OLD",
|
||||
SourceBatchNo: "OUT-1",
|
||||
}
|
||||
card := &rc.SimCardInfo{
|
||||
Iccid: expected.ICCID,
|
||||
Msisdn: expected.MSISDN,
|
||||
Imsi: expected.IMSI,
|
||||
DeptId: expected.CustomerDeptID,
|
||||
CurrentPackageCode: expected.PackageCode,
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
OutboundBatchNo: expected.SourceBatchNo,
|
||||
TenantId: expected.TenantID,
|
||||
}
|
||||
bind := &rc.SimCardBindPkg{
|
||||
Iccid: expected.ICCID,
|
||||
Msisdn: expected.MSISDN,
|
||||
DeptId: expected.CustomerDeptID,
|
||||
PackageCode: expected.PackageCode,
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
BatchNo: expected.SourceBatchNo,
|
||||
TenantId: expected.TenantID,
|
||||
}
|
||||
return card, bind, expected
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
|
||||
)
|
||||
|
||||
const (
|
||||
SourceTypeExcel = 3
|
||||
SourceTypeInput = 2
|
||||
|
||||
// 价格来源
|
||||
PriceSourceSystem = "system"
|
||||
PriceSourceAgentDefault = "agentDefault"
|
||||
PriceSourceTarget = "target"
|
||||
|
||||
// Agent 价格 scope_type
|
||||
PriceScopeAgentDefault = 0
|
||||
PriceScopeTargetAgent = 1
|
||||
)
|
||||
|
||||
// RenewRow 续费/变更行(按业务语义复用同一行结构)。
|
||||
type RenewRow struct {
|
||||
RowNo int
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
NewPkgCode string
|
||||
Reason string
|
||||
PackageCode string
|
||||
CustomerID int64
|
||||
// 续费使用
|
||||
OutboundBatchNo string
|
||||
// 变更使用
|
||||
OldPkgCode string
|
||||
OldPkgName string
|
||||
OldBindSubNo string
|
||||
OldBindOrdNo string
|
||||
OldEffTime *gtime.Time
|
||||
OldExpireTime *gtime.Time
|
||||
// 准备结果快照
|
||||
SourceBatchNo string
|
||||
}
|
||||
|
||||
// RenewPriceSnapshot 续费价格快照(与 Admin 对齐)。
|
||||
type RenewPriceSnapshot struct {
|
||||
CustomerAgentId int64 `json:"customerAgentId"`
|
||||
PriceOwnerDeptId int64 `json:"priceOwnerDeptId"`
|
||||
PackageCode string `json:"packageCode"`
|
||||
PackageName string `json:"packageName"`
|
||||
BillingMethod int `json:"billingMethod"`
|
||||
RenewDurationUnit string `json:"renewDurationUnit"`
|
||||
RenewDurationUnitText string `json:"renewDurationUnitText"`
|
||||
PriceSource string `json:"priceSource"`
|
||||
AgentDefaultPriceId int64 `json:"agentDefaultPriceId"`
|
||||
TargetPriceId int64 `json:"targetPriceId"`
|
||||
RenewUnitPrice float64 `json:"renewUnitPrice"`
|
||||
RenewableDurationOptions []int `json:"renewableDurationOptions"`
|
||||
RenewablePackageCodes []string `json:"renewablePackageCodes"`
|
||||
RenewablePackages []RenewPackageOption `json:"renewablePackages"`
|
||||
RenewBeginDate string `json:"renewBeginDate"`
|
||||
RenewBeginDateMin string `json:"renewBeginDateMin"`
|
||||
RenewBeginDateMax string `json:"renewBeginDateMax"`
|
||||
SameRenewBeginDate bool `json:"sameRenewBeginDate"`
|
||||
}
|
||||
|
||||
type RenewPackageOption struct {
|
||||
PackageCode string `json:"packageCode"`
|
||||
PackageName string `json:"packageName"`
|
||||
RenewUnitPrice float64 `json:"renewUnitPrice"`
|
||||
RenewableDurationOptions []int `json:"renewableDurationOptions"`
|
||||
}
|
||||
|
||||
// ChangePriceSnapshot 变更价格快照。
|
||||
type ChangePriceSnapshot struct {
|
||||
CustomerAgentId int64 `json:"customerAgentId"`
|
||||
NewPackageCode string `json:"newPackageCode"`
|
||||
NewPackageName string `json:"newPackageName"`
|
||||
PriceSource string `json:"priceSource"`
|
||||
AgentDefaultPriceId int64 `json:"agentDefaultPriceId"`
|
||||
TargetPriceId int64 `json:"targetPriceId"`
|
||||
ChangeUnitPrice float64 `json:"changeUnitPrice"`
|
||||
RenewableDurationOptions []int `json:"renewableDurationOptions"`
|
||||
}
|
||||
|
||||
// ChangePrepareSnapshot 变更预处理快照。
|
||||
type ChangePrepareSnapshot struct {
|
||||
SourceBatchNo string `json:"sourceBatchNo"`
|
||||
CustomerAgentId int64 `json:"customerAgentId"`
|
||||
CustomerName string `json:"customerName"`
|
||||
OldPackageCode string `json:"oldPackageCode"`
|
||||
OldPackageName string `json:"oldPackageName"`
|
||||
OldPackageType int `json:"oldPackageType"`
|
||||
OldPackageFlowMb float64 `json:"oldPackageFlowMb"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
|
||||
// Task 续费/变更通用任务。
|
||||
type Task struct {
|
||||
Batch *rc.BatchMainRow
|
||||
Rows []*RenewRow
|
||||
FailureReason string
|
||||
ValidCount int
|
||||
SuccessCount int
|
||||
FailCount int
|
||||
|
||||
// 续费
|
||||
PriceSnapshot *RenewPriceSnapshot
|
||||
|
||||
// 变更
|
||||
PrepareSnapshot *ChangePrepareSnapshot
|
||||
PriceSnapshot2 *ChangePriceSnapshot
|
||||
}
|
||||
|
||||
// Executor 抽象。
|
||||
type Executor interface {
|
||||
Execute(ctx context.Context, batchNo string) error
|
||||
}
|
||||
|
||||
// NowFunc 提供时钟,便于测试。
|
||||
type NowFunc func() time.Time
|
||||
|
||||
// timeNow 默认时钟。
|
||||
func timeNow() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// addMonths 在给定时间上增加 N 个月,保持日时分秒。
|
||||
func addMonths(t time.Time, months int) time.Time {
|
||||
if months <= 0 {
|
||||
return t
|
||||
}
|
||||
targetMonth := time.Date(
|
||||
t.Year(),
|
||||
t.Month()+time.Month(months),
|
||||
1,
|
||||
t.Hour(),
|
||||
t.Minute(),
|
||||
t.Second(),
|
||||
t.Nanosecond(),
|
||||
t.Location(),
|
||||
)
|
||||
day := t.Day()
|
||||
lastDay := time.Date(
|
||||
targetMonth.Year(),
|
||||
targetMonth.Month()+1,
|
||||
0,
|
||||
t.Hour(),
|
||||
t.Minute(),
|
||||
t.Second(),
|
||||
t.Nanosecond(),
|
||||
t.Location(),
|
||||
).Day()
|
||||
if day > lastDay {
|
||||
day = lastDay
|
||||
}
|
||||
return time.Date(
|
||||
targetMonth.Year(),
|
||||
targetMonth.Month(),
|
||||
day,
|
||||
t.Hour(),
|
||||
t.Minute(),
|
||||
t.Second(),
|
||||
t.Nanosecond(),
|
||||
t.Location(),
|
||||
)
|
||||
}
|
||||
|
||||
func renewBeginDateFromExpire(expireAt *gtime.Time) *gtime.Time {
|
||||
if expireAt == nil {
|
||||
return nil
|
||||
}
|
||||
return dayEndAt(expireAt)
|
||||
}
|
||||
|
||||
func renewBeginTimeFromExpire(expireAt *gtime.Time) (time.Time, bool) {
|
||||
beginAt := renewBeginDateFromExpire(expireAt)
|
||||
if beginAt == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return beginAt.Time, true
|
||||
}
|
||||
|
||||
func previousMonthEndAt(t *gtime.Time) *gtime.Time {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
tt := t.Time
|
||||
return gtime.NewFromTime(time.Date(tt.Year(), tt.Month(), 0, 23, 59, 59, 0, tt.Location()))
|
||||
}
|
||||
|
||||
func dayEndAt(t *gtime.Time) *gtime.Time {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
tt := t.Time
|
||||
return gtime.NewFromTime(time.Date(tt.Year(), tt.Month(), tt.Day(), 23, 59, 59, 0, tt.Location()))
|
||||
}
|
||||
|
||||
func renewDurationUnitForBillingMethod(billingMethod int) string {
|
||||
return consts.BillingMethodDurationUnit(billingMethod)
|
||||
}
|
||||
|
||||
func renewDurationUnitForPackage(pkg *rc.FlowPackageInfo) string {
|
||||
return consts.RenewDurationUnitYear
|
||||
}
|
||||
|
||||
func renewableYearOptions(config *gjson.Json) ([]int, error) {
|
||||
values, err := rc.RenewableDurationOptions(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacyMonthConfig := len(values) > 0
|
||||
for _, value := range values {
|
||||
if value < 12 || value%12 != 0 {
|
||||
legacyMonthConfig = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !legacyMonthConfig {
|
||||
return values, nil
|
||||
}
|
||||
years := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
years = append(years, value/12)
|
||||
}
|
||||
sort.Ints(years)
|
||||
return years, nil
|
||||
}
|
||||
|
||||
// changeDurationOptions returns months because the change contract and the
|
||||
// subscription records store the actual service duration in months. The
|
||||
// package configuration is normally expressed in years, while legacy rows
|
||||
// may still contain month values such as [12, 24, 36].
|
||||
func changeDurationOptions(config *gjson.Json) ([]int, error) {
|
||||
values, err := rc.RenewableDurationOptions(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacyMonthConfig := len(values) > 0
|
||||
for _, value := range values {
|
||||
if value < 12 || value%12 != 0 {
|
||||
legacyMonthConfig = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if legacyMonthConfig {
|
||||
return values, nil
|
||||
}
|
||||
months := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value > 0 {
|
||||
months = append(months, value*12)
|
||||
}
|
||||
}
|
||||
sort.Ints(months)
|
||||
if len(months) == 0 {
|
||||
return nil, gerror.New("package has no valid change duration options")
|
||||
}
|
||||
return months, nil
|
||||
}
|
||||
|
||||
func renewDurationLabel(value int, unit string) string {
|
||||
if value <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d%s", value, consts.RenewDurationUnitText(unit))
|
||||
}
|
||||
|
||||
func parseRenewDate(value string, now NowFunc) (*gtime.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
t := now()
|
||||
return gtime.NewFromTime(t), nil
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
|
||||
if err != nil {
|
||||
parsed, err = time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return gtime.NewFromTime(parsed), nil
|
||||
}
|
||||
|
||||
func formatTime(t *gtime.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format("Y-m-d H:i:s")
|
||||
}
|
||||
|
||||
func snapshotIntSlice(j *gjson.Json, path string) []int {
|
||||
if j == nil {
|
||||
return nil
|
||||
}
|
||||
raw := j.Get(path)
|
||||
if raw == nil || raw.IsNil() {
|
||||
return nil
|
||||
}
|
||||
values := raw.Interfaces()
|
||||
out := make([]int, 0, len(values))
|
||||
for _, v := range values {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
out = append(out, n)
|
||||
case int32:
|
||||
out = append(out, int(n))
|
||||
case int64:
|
||||
out = append(out, int(n))
|
||||
case float32:
|
||||
out = append(out, int(n))
|
||||
case float64:
|
||||
out = append(out, int(n))
|
||||
}
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// TxFn 在事务内执行回调。
|
||||
type TxFn func(ctx context.Context, fn func(ctx context.Context, tx gdb.TX) error) error
|
||||
|
||||
// DefaultTx 真实事务实现。
|
||||
func DefaultTx(database gdb.DB) TxFn {
|
||||
if database == nil {
|
||||
database = rc.DB()
|
||||
}
|
||||
return func(ctx context.Context, fn func(ctx context.Context, tx gdb.TX) error) error {
|
||||
return database.Transaction(ctx, fn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package rolesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rolepolicy"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SyncInheritedRole(ctx context.Context, message *model.AgentRoleSyncMessage) error {
|
||||
if message == nil || message.SourceRoleId <= 0 || message.SourceDeptId <= 0 || message.TenantId == "" {
|
||||
return gerror.New("invalid agent role sync message")
|
||||
}
|
||||
template, templateErr := rolepolicy.Template(ctx)
|
||||
if templateErr != nil {
|
||||
return templateErr
|
||||
}
|
||||
if message.TemplateRole && (template == nil || message.SourceRoleId != template.RoleId) {
|
||||
message.Superseded = true
|
||||
return nil
|
||||
}
|
||||
if template != nil && message.SourceRoleId == template.RoleId && message.SourceDeptId == template.DeptId && (message.TenantId == template.TenantId || message.TenantId == "") {
|
||||
return syncTemplateRoles(ctx, template, message)
|
||||
}
|
||||
if template != nil {
|
||||
message.Superseded = true
|
||||
return nil
|
||||
}
|
||||
var source *entity.SysRole
|
||||
sourceModel := dao.SysRole.Ctx(ctx).Master().Where(dao.SysRole.Columns().RoleId, message.SourceRoleId)
|
||||
if message.TenantId != "" {
|
||||
sourceModel = sourceModel.Where(dao.SysRole.Columns().TenantId, message.TenantId)
|
||||
}
|
||||
if err := sourceModel.Scan(&source); err != nil {
|
||||
return err
|
||||
}
|
||||
if source == nil || source.IsPass != 1 || source.DeletedAt != nil {
|
||||
message.Superseded = true
|
||||
return nil
|
||||
}
|
||||
if message.SourceDeptId > 0 && message.SourceDeptId != source.DeptId {
|
||||
return gerror.New("role sync source department changed")
|
||||
}
|
||||
var children []*entity.SysDept
|
||||
if err := dao.SysDept.Ctx(ctx).Master().
|
||||
Where(dao.SysDept.Columns().TenantId, source.TenantId).
|
||||
Where(dao.SysDept.Columns().ParentId, source.DeptId).
|
||||
Where(dao.SysDept.Columns().DeptType, 1).
|
||||
Scan(&children); err != nil {
|
||||
return err
|
||||
}
|
||||
failed := 0
|
||||
if len(message.ChildDeptIds) == 0 {
|
||||
message.TotalCount = len(children)
|
||||
message.SuccessCount = 0
|
||||
}
|
||||
failedDeptIds := make([]int64, 0)
|
||||
var firstErr error
|
||||
for _, child := range children {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(message.ChildDeptIds) > 0 && !slices.Contains(message.ChildDeptIds, child.DeptId) {
|
||||
continue
|
||||
}
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return syncInheritedRoleChildrenTx(ctx, tx, message.SourceRoleId, source.TenantId, source.DeptId, message.ActorId, child.DeptId)
|
||||
})
|
||||
if err != nil {
|
||||
failed++
|
||||
failedDeptIds = append(failedDeptIds, child.DeptId)
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
g.Log().Warningf(ctx, "agent role sync child failed, sourceRoleId:%d, childDeptId:%d, err:%v", source.RoleId, child.DeptId, err)
|
||||
} else {
|
||||
message.SuccessCount++
|
||||
}
|
||||
}
|
||||
message.ChildDeptIds = failedDeptIds
|
||||
message.FailedCount = len(failedDeptIds)
|
||||
if firstErr != nil {
|
||||
return gerror.Wrapf(firstErr, "agent role sync failed for %d of %d children", failed, len(children))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncInheritedRoleChildrenTx(ctx context.Context, tx gdb.TX, sourceRoleID int64, tenantId string, sourceDeptId, actorId, childDeptId int64) error {
|
||||
if sourceRoleID == 0 {
|
||||
return nil
|
||||
}
|
||||
var source *entity.SysRole
|
||||
sourceModel := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where(dao.SysRole.Columns().RoleId, sourceRoleID)
|
||||
if tenantId != "" {
|
||||
sourceModel = sourceModel.Where(dao.SysRole.Columns().TenantId, tenantId)
|
||||
}
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
sourceModel = sourceModel.LockUpdate()
|
||||
}
|
||||
if err := sourceModel.Scan(&source); err != nil {
|
||||
return err
|
||||
}
|
||||
if source == nil || source.RoleId == 0 || source.DeletedAt != nil || source.IsPass != 1 {
|
||||
return nil
|
||||
}
|
||||
if sourceDeptId > 0 && source.DeptId != sourceDeptId {
|
||||
return gerror.New("role sync source department changed")
|
||||
}
|
||||
var childDepts []*entity.SysDept
|
||||
childDeptModel := tx.Ctx(ctx).Model(dao.SysDept.Table()).Where(dao.SysDept.Columns().TenantId, source.TenantId).Where(dao.SysDept.Columns().ParentId, source.DeptId).Where(dao.SysDept.Columns().DeptType, 1).Where(dao.SysDept.Columns().DeptId, childDeptId)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
childDeptModel = childDeptModel.LockUpdate()
|
||||
}
|
||||
if err := childDeptModel.Scan(&childDepts); err != nil {
|
||||
return err
|
||||
}
|
||||
var menus []*entity.SysRoleMenu
|
||||
if err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where(dao.SysRoleMenu.Columns().RoleId, sourceRoleID).OrderAsc(dao.SysRoleMenu.Columns().MenuId).Scan(&menus); err != nil {
|
||||
return err
|
||||
}
|
||||
menuIDs := make([]int64, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
menuIDs = append(menuIDs, menu.MenuId)
|
||||
}
|
||||
allowed, err := rolepolicy.RestrictMenus(ctx, source.TenantId, source.DeptId, menuIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template, templateErr := rolepolicy.Template(ctx)
|
||||
if templateErr != nil {
|
||||
return templateErr
|
||||
}
|
||||
filtered := make([]*entity.SysRoleMenu, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
if slices.Contains(allowed, menu.MenuId) {
|
||||
copy := *menu
|
||||
if template != nil {
|
||||
copy.DataScope = consts.SysRoleMenuDataScopeRole
|
||||
}
|
||||
filtered = append(filtered, ©)
|
||||
}
|
||||
}
|
||||
menus = filtered
|
||||
for _, childDept := range childDepts {
|
||||
var children []*entity.SysRole
|
||||
if err := tx.Ctx(ctx).Model(dao.SysRole.Table()+" r").
|
||||
Fields("r.*").
|
||||
Where("r.tenant_id", source.TenantId).
|
||||
Where("r.dept_id", childDept.DeptId).
|
||||
Where("r.created_dept", source.DeptId).
|
||||
Where("r.role_id", childDept.DeptRole).
|
||||
Scan(&children); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(children) > 1 {
|
||||
return gerror.Newf("multiple inherited roles in department %d for role %d", childDept.DeptId, source.RoleId)
|
||||
}
|
||||
if len(children) == 0 {
|
||||
var legacy *entity.SysRole
|
||||
if childDept.DeptRole > 0 {
|
||||
if err := tx.Ctx(ctx).Model(dao.SysRole.Table()).
|
||||
Where(dao.SysRole.Columns().RoleId, childDept.DeptRole).
|
||||
Where(dao.SysRole.Columns().DeptId, childDept.DeptId).
|
||||
Where(dao.SysRole.Columns().TenantId, source.TenantId).
|
||||
Scan(&legacy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if legacy != nil && legacy.RoleId > 0 {
|
||||
if legacy.CreatedDept != source.DeptId || legacy.RoleKey != source.RoleKey {
|
||||
return gerror.Newf("cannot identify inherited role for department %d", childDept.DeptId)
|
||||
}
|
||||
children = []*entity.SysRole{legacy}
|
||||
} else {
|
||||
if childDept.DeptRole > 0 {
|
||||
return gerror.Newf("missing default role for department %d", childDept.DeptId)
|
||||
}
|
||||
return gerror.Newf("missing default role for department %d", childDept.DeptId)
|
||||
}
|
||||
}
|
||||
for _, child := range children {
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where(dao.SysRole.Columns().RoleId, child.RoleId).Data(do.SysRole{RoleName: strings.TrimSpace(source.RoleName), RoleKey: source.RoleKey, RoleSort: source.RoleSort, DataScope: source.DataScope, MenuCheckStrictly: source.MenuCheckStrictly, DeptCheckStrictly: source.DeptCheckStrictly, Status: source.Status, Remark: source.Remark, UpdatedBy: actorId}).OmitNil().Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where(dao.SysRoleMenu.Columns().RoleId, child.RoleId).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
menuData := make([]*do.SysRoleMenu, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
menuData = append(menuData, &do.SysRoleMenu{TenantId: source.TenantId, RoleId: child.RoleId, MenuId: menu.MenuId, DataScope: menu.DataScope})
|
||||
}
|
||||
if len(menuData) > 0 {
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Data(menuData).OmitNil().Batch(200).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package rolesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rolepolicy"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTemplateSwitchOverwritesDefaultRolesAndPreservesOtherRoles(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES (1,'000000',0,1,0),(2,'000000',1,1,21),(3,'000000',2,1,31),(4,'000000',0,1,41)")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role(role_id,tenant_id,dept_id,role_name,role_key,status,data_scope,is_pass,created_dept) VALUES (10,'000000',1,'模板','template','0','4',0,1),(11,'000000',1,'新模板','next','0','4',0,1),(21,'000000',2,'管理员','admin','0','4',0,1),(31,'000000',3,'三级管理员','admin','0','4',0,2),(22,'000000',2,'财务','finance','0','3',0,2),(41,'000000',4,'其他机构','other','0','4',0,4)")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES ('000000','agent.role.template.role_id','10')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES ('000000',10,100,'0'),('000000',10,200,'0'),('000000',11,200,'0'),('000000',22,100,'3'),('000000',41,100,'0')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_user_role(user_id,role_id) VALUES(99,21)")
|
||||
message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, TemplateRole: true}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err := SyncInheritedRole(ctx, message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if message.TotalCount != 2 || message.SuccessCount != 2 {
|
||||
t.Fatalf("unexpected hierarchy counts: %+v", message)
|
||||
}
|
||||
for _, roleID := range []int64{21, 31} {
|
||||
assertAgentRoleSyncCount(t, ctx, fmt.Sprintf("SELECT COUNT(*) FROM sys_role_menu WHERE role_id=%d AND menu_id IN(100,200)", roleID), 2)
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=22 AND menu_id=200", 0)
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_config SET config_value='11'")
|
||||
ids, err := rolepolicy.RestrictMenus(ctx, "000000", 3, []int64{100, 200})
|
||||
if err != nil || !reflect.DeepEqual(ids, []int64{100, 200}) {
|
||||
t.Fatalf("template switch must wait for confirmed distribution: %v %v", ids, err)
|
||||
}
|
||||
if err := SyncInheritedRole(ctx, message); err != nil || !message.Superseded {
|
||||
t.Fatalf("obsolete template must not propagate: %+v %v", message, err)
|
||||
}
|
||||
next := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 11, SourceDeptId: 1, TemplateRole: true}
|
||||
if err := SyncInheritedRole(ctx, next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, roleID := range []int64{21, 31} {
|
||||
assertAgentRoleSyncCount(t, ctx, fmt.Sprintf("SELECT COUNT(*) FROM sys_role_menu WHERE role_id=%d AND menu_id=100", roleID), 0)
|
||||
assertAgentRoleSyncCount(t, ctx, fmt.Sprintf("SELECT COUNT(*) FROM sys_role_menu WHERE role_id=%d AND menu_id=200", roleID), 1)
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE role_id=31 AND created_dept=2", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=22 AND menu_id=100", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=41 AND menu_id=100", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_user_role WHERE user_id=99 AND role_id=21", 1)
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role(role_id,tenant_id,dept_id,is_pass,status,data_scope) VALUES(25,'000000',2,1,'0','4')")
|
||||
legacy := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 25, SourceDeptId: 2}
|
||||
if err := SyncInheritedRole(ctx, legacy); err != nil || !legacy.Superseded {
|
||||
t.Fatalf("legacy local templates must not overwrite configured hierarchy: %+v %v", legacy, err)
|
||||
}
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_config SET config_value='invalid'")
|
||||
if _, err := rolepolicy.RestrictMenus(ctx, "000000", 3, []int64{200}); err == nil {
|
||||
t.Fatal("invalid template must deny")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncInheritedRoleOnlyUpdatesDirectEnterpriseChildren(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept (dept_id, tenant_id, parent_id, dept_type, dept_role) VALUES
|
||||
(1, '000000', 0, 1, 0),
|
||||
(2, '000000', 1, 1, 21),
|
||||
(3, '000000', 2, 1, 0),
|
||||
(4, '000000', 1, 0, 0)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role
|
||||
(role_id, tenant_id, dept_id, role_name, role_key, role_sort, data_scope, menu_check_strictly, dept_check_strictly, status, is_pass, created_dept, created_by, updated_by, remark)
|
||||
VALUES
|
||||
(10, '000000', 1, '企业客户', 'enterpriseCustomer', 1, '4', 1, 1, '0', 1, 1, 1, 1, 'source'),
|
||||
(20, '000000', 2, '下级自建角色', 'localRole', 5, '3', 1, 1, '0', 0, 2, 2, 2, 'local'),
|
||||
(21, '000000', 2, '旧下发角色', 'enterpriseCustomer', 1, '4', 1, 1, '0', 0, 1, 1, 1, 'legacy')`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu (tenant_id, role_id, menu_id, data_scope) VALUES ('000000', 10, 100, '7')`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu (tenant_id, role_id, menu_id, data_scope) VALUES ('000000', 21, 200, '7')`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_user_role (user_id, role_id) VALUES (99, 21)`)
|
||||
|
||||
message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, ActorId: 9}
|
||||
if err := SyncInheritedRole(ctx, message); err != nil {
|
||||
t.Fatalf("first sync failed: %v", err)
|
||||
}
|
||||
if err := SyncInheritedRole(ctx, message); err != nil {
|
||||
t.Fatalf("second sync failed: %v", err)
|
||||
}
|
||||
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE dept_id = 2 AND created_dept = 1", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE role_id = 21 AND created_dept = 1", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_user_role WHERE user_id = 99 AND role_id = 21", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id = 21 AND menu_id = 200", 0)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE dept_id = 3 AND created_dept = 1", 0)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE dept_id = 4 AND created_dept = 1", 0)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE role_id = 20 AND role_name = '下级自建角色' AND created_dept = 2", 1)
|
||||
|
||||
menuScope, err := g.DB().GetValue(ctx, `SELECT rm.data_scope FROM sys_role_menu rm JOIN sys_role r ON r.role_id = rm.role_id WHERE r.dept_id = 2 AND r.created_dept = 1 AND rm.menu_id = 100`)
|
||||
if err != nil {
|
||||
t.Fatalf("query inherited menu data scope failed: %v", err)
|
||||
}
|
||||
if menuScope.String() != "7" {
|
||||
t.Fatalf("inherited menu data scope = %q, want 7", menuScope.String())
|
||||
}
|
||||
|
||||
mustExecAgentRoleSyncTest(t, ctx, `UPDATE sys_role SET role_name = '停止下发后的名称', is_pass = 0 WHERE role_id = 10`)
|
||||
if err := SyncInheritedRole(ctx, message); err != nil {
|
||||
t.Fatalf("disabled source sync failed: %v", err)
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE dept_id = 2 AND created_dept = 1 AND role_name = '企业客户'", 1)
|
||||
}
|
||||
|
||||
func TestSyncInheritedRoleRejectsUnrelatedDefaultRole(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept (dept_id, tenant_id, parent_id, dept_type, dept_role) VALUES (1, '000000', 0, 1, 0), (2, '000000', 1, 1, 21)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role (role_id, tenant_id, dept_id, role_name, role_key, is_pass, created_dept) VALUES (10, '000000', 1, '下发角色', 'source', 1, 1), (21, '000000', 2, '自建默认角色', 'local', 0, 2)`)
|
||||
err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguous default role to be rejected")
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE role_id = 21 AND created_dept = 2 AND role_name = '自建默认角色'", 1)
|
||||
}
|
||||
|
||||
func TestTemplateSyncReportsMissingRoles(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_dept (dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES (1,'000000',0,1,10),(2,'000000',1,1,0),(3,'000000',1,1,31)")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role (role_id,tenant_id,dept_id,status,data_scope,is_pass) VALUES (10,'000000',1,'0','4',1),(31,'000000',3,'0','4',0)")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_role SET created_dept=1 WHERE role_id=31")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_config (config_id,tenant_id,config_key,config_value) VALUES (1,'000000','agent.role.template.role_id','10')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu (tenant_id,role_id,menu_id,data_scope) VALUES ('000000',10,200,'0')")
|
||||
if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err == nil {
|
||||
t.Fatal("missing default role must report partial failure")
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=200", 1)
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role (role_id,tenant_id,dept_id,status,data_scope) VALUES (21,'000000',2,'0','4')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_role SET created_dept=1 WHERE role_id=21")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_dept SET dept_role=21 WHERE dept_id=2")
|
||||
if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=200", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=200", 1)
|
||||
}
|
||||
func setupAgentRoleSyncTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := gdb.SetConfig(gdb.Config{
|
||||
"default": gdb.ConfigGroup{
|
||||
{Type: "sqlite", Link: "sqlite::@file(:memory:)", Debug: false},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("configure sqlite failed: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
t.Cleanup(func() { _ = g.DB().Close(ctx) })
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, dept_type INTEGER, status TEXT DEFAULT '0', dept_role INTEGER, deleted_at DATETIME)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_config (config_id INTEGER PRIMARY KEY, tenant_id TEXT, config_name TEXT, config_key TEXT, config_value TEXT, config_type TEXT, created_dept INTEGER, created_by INTEGER, created_at DATETIME, updated_by INTEGER, updated_at DATETIME, remark TEXT)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role (
|
||||
role_id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT, dept_id INTEGER, role_name TEXT, role_key TEXT, role_sort INTEGER, data_scope TEXT,
|
||||
menu_check_strictly INTEGER, dept_check_strictly INTEGER, status TEXT, is_pass INTEGER, created_dept INTEGER, created_by INTEGER, created_at DATETIME, updated_by INTEGER, updated_at DATETIME, deleted_by INTEGER, deleted_at DATETIME, remark TEXT)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role_menu (tenant_id TEXT, role_id INTEGER, menu_id INTEGER, data_scope TEXT)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role_dept (tenant_id TEXT, role_id INTEGER, dept_id INTEGER)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_user_role (tenant_id TEXT DEFAULT '000000', user_id INTEGER, role_id INTEGER)`)
|
||||
}
|
||||
|
||||
func mustExecAgentRoleSyncTest(t *testing.T, ctx context.Context, sql string) {
|
||||
t.Helper()
|
||||
if _, err := g.DB().Exec(ctx, sql); err != nil {
|
||||
t.Fatalf("execute sql failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAgentRoleSyncCount(t *testing.T, ctx context.Context, sql string, expected int) {
|
||||
t.Helper()
|
||||
count, err := g.DB().GetValue(ctx, sql)
|
||||
if err != nil {
|
||||
t.Fatalf("query count failed: %v", err)
|
||||
}
|
||||
if count.Int() != expected {
|
||||
t.Fatalf("count = %d, want %d, sql=%s", count.Int(), expected, sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleSyncRetriesOnlyFailedDepartments(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept (dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES (1,'000000',0,1,0),(2,'000000',1,1,21),(3,'000000',1,1,31)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role (role_id,tenant_id,dept_id,role_name,role_key,is_pass,created_dept) VALUES (10,'000000',1,'source','source',1,1),(21,'000000',2,'before','source',0,1),(31,'000000',3,'local','local',0,3)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu (tenant_id,role_id,menu_id,data_scope) VALUES ('000000',10,100,'7')`)
|
||||
message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}
|
||||
if err := SyncInheritedRole(ctx, message); err == nil {
|
||||
t.Fatal("expected invalid department failure")
|
||||
}
|
||||
if !reflect.DeepEqual(message.ChildDeptIds, []int64{3}) {
|
||||
t.Fatalf("unexpected failures: %v", message.ChildDeptIds)
|
||||
}
|
||||
mustExecAgentRoleSyncTest(t, ctx, `UPDATE sys_role SET role_name='already-successful' WHERE role_id=21`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `UPDATE sys_role SET created_dept=1,role_key='source' WHERE role_id=31`)
|
||||
if err := SyncInheritedRole(ctx, message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role WHERE role_id=21 AND role_name='already-successful'`, 1)
|
||||
assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=100`, 1)
|
||||
if len(message.ChildDeptIds) != 0 {
|
||||
t.Fatal("failure list must clear after success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestObsoleteTemplateTaskDoesNotPropagateAsNormalRole(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_dept (dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES (1,'000000',0,1,10),(2,'000000',1,1,21)")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role (role_id,tenant_id,dept_id,role_key,is_pass,created_dept) VALUES (10,'000000',1,'source',1,1),(21,'000000',2,'source',0,1)")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu (tenant_id,role_id,menu_id,data_scope) VALUES ('000000',10,100,'7'),('000000',21,200,'7')")
|
||||
message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, TemplateRole: true}
|
||||
if err := SyncInheritedRole(ctx, message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !message.Superseded {
|
||||
t.Fatal("obsolete template task must be marked superseded")
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=200", 1)
|
||||
}
|
||||
|
||||
func TestMissingDefaultRoleDoesNotCreateOrphanRole(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,0)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role(role_id,tenant_id,dept_id,role_name,role_key,is_pass,created_dept) VALUES(10,'000000',1,'source','source',1,1)`)
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err == nil {
|
||||
t.Fatal("missing role must fail")
|
||||
}
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role WHERE dept_id=2`, 0)
|
||||
}
|
||||
|
||||
func TestTemplateChildRevalidatesCurrentBindingAndSource(t *testing.T) {
|
||||
for _, change := range []string{
|
||||
"UPDATE sys_dept SET parent_id=99 WHERE dept_id=2",
|
||||
"UPDATE sys_dept SET dept_type=0 WHERE dept_id=2",
|
||||
"UPDATE sys_role SET status='1' WHERE role_id=10",
|
||||
"UPDATE sys_role_menu SET data_scope='1' WHERE role_id=10",
|
||||
} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,21)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role(role_id,tenant_id,dept_id,status,data_scope,is_pass) VALUES(10,'000000',1,'0','4',1),(21,'000000',2,'0','3',0)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_role SET created_dept=1 WHERE role_id=21")
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',21,200,'0')`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, change)
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return syncTemplateChildTx(ctx, tx, &entity.SysRole{RoleId: 10, TenantId: "000000", DeptId: 1}, &entity.SysDept{DeptId: 2, DeptRole: 21, ParentId: 1}, 7)
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("changed source or binding must fail")
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=200`, 1)
|
||||
assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=100`, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedDefaultRoleIsNotRestoredBySynchronization(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,21)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role(role_id,tenant_id,dept_id,role_name,role_key,is_pass,created_dept,deleted_at) VALUES(10,'000000',1,'source','source',1,1,NULL),(21,'000000',2,'deleted','source',0,1,'2026-10-13 00:00:00')`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',21,200,'0')`)
|
||||
if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err == nil {
|
||||
t.Fatal("deleted default role must fail")
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=200`, 1)
|
||||
assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=100`, 0)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package rolesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Start(ctx context.Context) (func(), error) {
|
||||
if !g.Cfg().MustGet(ctx, "roleSync.enabled", false).Bool() {
|
||||
return func() {}, nil
|
||||
}
|
||||
if !rabbitmq.Enabled(ctx) {
|
||||
return nil, gerror.New("权限同步 Worker 必须启用 RabbitMQ 并配置连接")
|
||||
}
|
||||
workerCtx, cancel := context.WithCancel(ctx)
|
||||
go superviseConsumer(workerCtx)
|
||||
g.Log().Info(ctx, "role sync Worker started")
|
||||
return cancel, nil
|
||||
}
|
||||
|
||||
func handleMessage(ctx context.Context, message model.AgentRoleSyncMessage) error {
|
||||
if err := syncRoleMessage(ctx, &message); err != nil {
|
||||
g.Log().Errorf(ctx, "role sync failed, taskId:%s, roleId:%d, attempt:%d, err:%+v", message.TaskId, message.SourceRoleId, message.RetryCount, err)
|
||||
message.RetryCount++
|
||||
body, marshalErr := json.Marshal(message)
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
if message.RetryCount > 5 {
|
||||
deadLetter, marshalErr := json.Marshal(model.AgentRoleSyncDeadLetterMessage{
|
||||
Message: message, Error: err.Error(), FailedAt: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
return publishRoleDeadLetter(ctx, deadLetter)
|
||||
}
|
||||
delay := time.Duration(1<<uint(message.RetryCount-1)) * 5 * time.Second
|
||||
if publishErr := publishRoleRetry(ctx, delay, body); publishErr != nil {
|
||||
return gerror.Wrap(publishErr, fmt.Sprintf("role sync retry publish failed: %v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var consumeRoleSync = rabbitmq.ConsumeWithDone
|
||||
|
||||
var syncRoleMessage = SyncInheritedRole
|
||||
|
||||
var publishRoleRetry = func(ctx context.Context, delay time.Duration, body []byte) error {
|
||||
return rabbitmq.PublishDelayedBytesWithHeaders(ctx, consts.QueueAgentRoleSync+".retry", consts.QueueAgentRoleSync, delay, body, nil)
|
||||
}
|
||||
|
||||
var publishRoleDeadLetter = func(ctx context.Context, body []byte) error {
|
||||
return rabbitmq.PublishBytesWithHeaders(ctx, consts.QueueAgentRoleSync+".dlq", body, nil)
|
||||
}
|
||||
|
||||
func superviseConsumer(ctx context.Context) {
|
||||
for ctx.Err() == nil {
|
||||
done, err := consumeRoleSync(ctx, consts.QueueAgentRoleSync, "iotlcm-worker-agent-role-sync", func(ctx context.Context, body []byte) error {
|
||||
var message model.AgentRoleSyncMessage
|
||||
invalid := json.Unmarshal(body, &message)
|
||||
if invalid != nil || message.SourceRoleId <= 0 || message.SourceDeptId <= 0 || strings.TrimSpace(message.TenantId) == "" || message.RetryCount < 0 {
|
||||
return quarantineRoleSync(ctx, body)
|
||||
}
|
||||
if err := handleMessage(ctx, message); err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
g.Log().Warningf(ctx, "role sync consumer disconnected, restarting: %v", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var quarantineRoleSync = func(ctx context.Context, body []byte) error {
|
||||
if err := publishRoleDeadLetter(ctx, body); err != nil {
|
||||
return gerror.Wrap(err, "保存无效权限消息到死信队列失败")
|
||||
}
|
||||
g.Log().Warning(ctx, "无效权限消息已隔离到死信队列")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package rolesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMessageRetryAndDeadLetter(t *testing.T) {
|
||||
originalSync, originalRetry, originalDead := syncRoleMessage, publishRoleRetry, publishRoleDeadLetter
|
||||
t.Cleanup(func() {
|
||||
syncRoleMessage, publishRoleRetry, publishRoleDeadLetter = originalSync, originalRetry, originalDead
|
||||
})
|
||||
syncRoleMessage = func(_ context.Context, message *model.AgentRoleSyncMessage) error {
|
||||
message.ChildDeptIds = []int64{200}
|
||||
return errors.New("child failed")
|
||||
}
|
||||
for attempt := 0; attempt <= 5; attempt++ {
|
||||
retries, deadLetters := 0, 0
|
||||
publishRoleRetry = func(_ context.Context, delay time.Duration, body []byte) error {
|
||||
retries++
|
||||
var message model.AgentRoleSyncMessage
|
||||
if err := json.Unmarshal(body, &message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message.RetryCount != attempt+1 || len(message.ChildDeptIds) != 1 || message.ChildDeptIds[0] != 200 || delay != time.Duration(1<<uint(attempt))*5*time.Second {
|
||||
t.Fatalf("invalid retry: %+v, delay=%s", message, delay)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
publishRoleDeadLetter = func(_ context.Context, body []byte) error {
|
||||
deadLetters++
|
||||
var dead model.AgentRoleSyncDeadLetterMessage
|
||||
if err := json.Unmarshal(body, &dead); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dead.Error != "child failed" || dead.FailedAt == "" || dead.Message.RetryCount != 6 {
|
||||
t.Fatalf("invalid dead letter: %+v", dead)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := handleMessage(context.Background(), model.AgentRoleSyncMessage{RetryCount: attempt}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if (attempt < 5 && (retries != 1 || deadLetters != 0)) || (attempt == 5 && (retries != 0 || deadLetters != 1)) {
|
||||
t.Fatalf("unexpected destinations: retry=%d, dead=%d", retries, deadLetters)
|
||||
}
|
||||
}
|
||||
brokerError := errors.New("broker offline")
|
||||
publishRoleRetry = func(context.Context, time.Duration, []byte) error { return brokerError }
|
||||
publishRoleDeadLetter = func(context.Context, []byte) error { return brokerError }
|
||||
for _, attempt := range []int{0, 5} {
|
||||
if err := handleMessage(context.Background(), model.AgentRoleSyncMessage{RetryCount: attempt}); err == nil {
|
||||
t.Fatal("publication failure must prevent acknowledgement")
|
||||
}
|
||||
}
|
||||
syncRoleMessage = func(context.Context, *model.AgentRoleSyncMessage) error { return nil }
|
||||
if err := handleMessage(context.Background(), model.AgentRoleSyncMessage{}); err != nil {
|
||||
t.Fatalf("successful sync should not publish retries: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeConfiguration(t *testing.T) {
|
||||
original := g.Cfg().GetAdapter()
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(original) })
|
||||
for _, test := range []struct {
|
||||
content string
|
||||
wantError bool
|
||||
}{
|
||||
{"roleSync:\n enabled: false\n", false},
|
||||
{"roleSync:\n enabled: true\nrabbitmq:\n enabled: false\n", true},
|
||||
} {
|
||||
adapter, err := gcfg.NewAdapterContent(test.content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
stop, err := Start(context.Background())
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("unexpected initialization: %v", err)
|
||||
}
|
||||
if stop != nil {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumerReconnectsAfterConnectionFailureAndChannelClose(t *testing.T) {
|
||||
original := consumeRoleSync
|
||||
t.Cleanup(func() { consumeRoleSync = original })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
reconnected := make(chan struct{})
|
||||
calls := 0
|
||||
consumeRoleSync = func(context.Context, string, string, rabbitmq.Handler) (<-chan struct{}, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return nil, errors.New("broker offline")
|
||||
}
|
||||
if calls == 2 {
|
||||
closed := make(chan struct{})
|
||||
close(closed)
|
||||
return closed, nil
|
||||
}
|
||||
close(reconnected)
|
||||
return make(chan struct{}), nil
|
||||
}
|
||||
finished := make(chan struct{})
|
||||
go func() { defer close(finished); superviseConsumer(ctx) }()
|
||||
select {
|
||||
case <-reconnected:
|
||||
case <-time.After(10 * time.Second):
|
||||
cancel()
|
||||
<-finished
|
||||
t.Fatal("consumer did not reconnect")
|
||||
}
|
||||
cancel()
|
||||
<-finished
|
||||
if calls != 3 {
|
||||
t.Fatalf("unexpected restarts: %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidMessagesAreQuarantinedAndPublicationFailureIsRetried(t *testing.T) {
|
||||
originalConsume, originalQuarantine := consumeRoleSync, quarantineRoleSync
|
||||
t.Cleanup(func() { consumeRoleSync = originalConsume; quarantineRoleSync = originalQuarantine })
|
||||
for _, body := range []string{`broken json`, `{"sourceRoleId":18}`} {
|
||||
for _, publishFails := range []bool{false, true} {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
calls := 0
|
||||
quarantineRoleSync = func(context.Context, []byte) error {
|
||||
calls++
|
||||
if publishFails {
|
||||
return errors.New("broker offline")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
consumeRoleSync = func(ctx context.Context, queue, consumer string, handler rabbitmq.Handler) (<-chan struct{}, error) {
|
||||
cancel()
|
||||
err := handler(ctx, []byte(body))
|
||||
if (err != nil) != publishFails {
|
||||
t.Fatalf("unexpected quarantine result: %v", err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
close(done)
|
||||
return done, nil
|
||||
}
|
||||
superviseConsumer(ctx)
|
||||
cancel()
|
||||
if calls != 1 {
|
||||
t.Fatalf("invalid message not isolated: %d", calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package rolesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rolepolicy"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func syncTemplateRoles(ctx context.Context, template *entity.SysRole, message *model.AgentRoleSyncMessage) error {
|
||||
departments, err := templateDescendants(ctx, template)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
retrying := len(message.ChildDeptIds) > 0
|
||||
if !retrying {
|
||||
message.TotalCount = len(departments)
|
||||
message.SuccessCount = 0
|
||||
}
|
||||
pending := make(map[int64]bool, len(message.ChildDeptIds))
|
||||
for _, deptID := range message.ChildDeptIds {
|
||||
pending[deptID] = true
|
||||
}
|
||||
failed := make(map[int64]bool)
|
||||
failedDeptIDs := make([]int64, 0)
|
||||
var firstErr error
|
||||
for _, child := range departments {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if retrying && !pending[child.DeptId] {
|
||||
continue
|
||||
}
|
||||
delete(pending, child.DeptId)
|
||||
var syncErr error
|
||||
if failed[child.ParentId] {
|
||||
syncErr = gerror.Newf("上级机构%d尚未同步成功", child.ParentId)
|
||||
} else {
|
||||
syncErr = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return syncTemplateChildTx(ctx, tx, template, child, message.ActorId)
|
||||
})
|
||||
}
|
||||
if syncErr != nil {
|
||||
failed[child.DeptId] = true
|
||||
failedDeptIDs = append(failedDeptIDs, child.DeptId)
|
||||
if firstErr == nil {
|
||||
firstErr = syncErr
|
||||
}
|
||||
g.Log().Errorf(ctx, "agent template sync child failed, deptId:%d, err:%v", child.DeptId, syncErr)
|
||||
continue
|
||||
}
|
||||
message.SuccessCount++
|
||||
}
|
||||
for deptID := range pending {
|
||||
failedDeptIDs = append(failedDeptIDs, deptID)
|
||||
if firstErr == nil {
|
||||
firstErr = gerror.Newf("待同步机构%d已不在当前模板的下级范围,请核对机构关系", deptID)
|
||||
}
|
||||
}
|
||||
slices.Sort(failedDeptIDs)
|
||||
message.ChildDeptIds = failedDeptIDs
|
||||
message.FailedCount = len(failedDeptIDs)
|
||||
if firstErr != nil {
|
||||
return gerror.Wrapf(firstErr, "代理模板同步失败企业数:%d", len(failedDeptIDs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func templateDescendants(ctx context.Context, template *entity.SysRole) ([]*entity.SysDept, error) {
|
||||
queue := []int64{template.DeptId}
|
||||
seen := map[int64]bool{template.DeptId: true}
|
||||
departments := make([]*entity.SysDept, 0)
|
||||
for len(queue) > 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentID := queue[0]
|
||||
queue = queue[1:]
|
||||
var children []*entity.SysDept
|
||||
if err := dao.SysDept.Ctx(ctx).Master().Where("tenant_id", template.TenantId).Where("parent_id", parentID).Where("dept_type", 1).OrderAsc("dept_id").Scan(&children); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
if seen[child.DeptId] {
|
||||
return nil, gerror.New("机构上下级存在循环")
|
||||
}
|
||||
seen[child.DeptId] = true
|
||||
queue = append(queue, child.DeptId)
|
||||
departments = append(departments, child)
|
||||
}
|
||||
}
|
||||
return departments, nil
|
||||
}
|
||||
|
||||
func syncTemplateChildTx(ctx context.Context, tx gdb.TX, template *entity.SysRole, child *entity.SysDept, actorID int64) error {
|
||||
if child.DeptRole <= 0 {
|
||||
return gerror.New("下级机构缺失默认角色")
|
||||
}
|
||||
lock := tx.Ctx(ctx).Model(dao.SysDept.Table()).Where("dept_id", child.DeptId).Where("tenant_id", template.TenantId)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
lock = lock.LockUpdate()
|
||||
}
|
||||
var locked *entity.SysDept
|
||||
if err := lock.Scan(&locked); err != nil {
|
||||
return err
|
||||
}
|
||||
if locked == nil || locked.DeptRole != child.DeptRole || locked.ParentId != child.ParentId || locked.DeptType != 1 {
|
||||
return gerror.New("下级机构角色绑定已变化")
|
||||
}
|
||||
currentLock := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("role_id", child.DeptRole).Where("tenant_id", template.TenantId).Where("dept_id", child.DeptId)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
currentLock = currentLock.LockUpdate()
|
||||
}
|
||||
var current *entity.SysRole
|
||||
if err := currentLock.Scan(¤t); err != nil {
|
||||
return err
|
||||
}
|
||||
if current == nil || current.Status != "0" {
|
||||
return gerror.New("下级机构默认角色不存在或已删除")
|
||||
}
|
||||
if current.CreatedDept != child.ParentId {
|
||||
return gerror.New("下级机构默认角色非直属上级继承角色,请核对角色来源")
|
||||
}
|
||||
configLock := tx.Ctx(ctx).Model(dao.SysConfig.Table()).Where("tenant_id", template.TenantId).Where("config_key", rolepolicy.TemplateConfigKey)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
configLock = configLock.LockUpdate()
|
||||
}
|
||||
var config *entity.SysConfig
|
||||
if err := configLock.Scan(&config); err != nil {
|
||||
return err
|
||||
}
|
||||
if config == nil || strings.TrimSpace(config.ConfigValue) != strconv.FormatInt(template.RoleId, 10) {
|
||||
return gerror.New("模板已切换,请重新同步")
|
||||
}
|
||||
templateLock := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("role_id", template.RoleId)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
templateLock = templateLock.LockUpdate()
|
||||
}
|
||||
var latest *entity.SysRole
|
||||
if err := templateLock.Scan(&latest); err != nil {
|
||||
return err
|
||||
}
|
||||
if latest == nil || latest.Status != "0" || latest.TenantId != template.TenantId || latest.DeptId != template.DeptId || latest.DataScope != consts.SysRoleDataScopeDeptAndBelow || (latest.CreatedDept > 0 && latest.CreatedDept != latest.DeptId) {
|
||||
return gerror.New("模板不可用")
|
||||
}
|
||||
menuLock := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where("tenant_id", template.TenantId).Where("role_id", template.RoleId)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
menuLock = menuLock.LockUpdate()
|
||||
}
|
||||
var menus []*entity.SysRoleMenu
|
||||
if err := menuLock.Scan(&menus); err != nil {
|
||||
return err
|
||||
}
|
||||
// Every linked default role receives the same template snapshot. Individual
|
||||
// changes are intentionally replaced; unrelated roles are never updated.
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("tenant_id", template.TenantId).Where("role_id", current.RoleId).Data(do.SysRole{DataScope: consts.SysRoleDataScopeDeptAndBelow, UpdatedBy: actorID}).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where("tenant_id", template.TenantId).Where("role_id", current.RoleId).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
menuData := make([]*do.SysRoleMenu, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
if menu.DataScope == consts.SysRoleDataScopeAll || menu.DataScope == consts.SysRoleDataScopeCustom {
|
||||
return gerror.New("模板按钮数据范围已变化,请重新同步")
|
||||
}
|
||||
menuData = append(menuData, &do.SysRoleMenu{TenantId: template.TenantId, RoleId: current.RoleId, MenuId: menu.MenuId, DataScope: consts.SysRoleMenuDataScopeRole})
|
||||
}
|
||||
if len(menuData) > 0 {
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Data(menuData).Batch(200).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package rolesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestTemplateHierarchyRetriesFailedParentAndDescendants(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
for _, statement := range []string{
|
||||
"INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,0),(3,'000000',2,1,31),(4,'000000',1,1,41),(5,'other',1,1,51),(6,'000000',1,0,61)",
|
||||
"INSERT INTO sys_role(role_id,tenant_id,dept_id,status,is_pass,created_dept,data_scope) VALUES(10,'000000',1,'0',0,1,'4'),(21,'000000',2,'0',0,1,'4'),(31,'000000',3,'0',0,2,'4'),(41,'000000',4,'0',0,1,'4'),(51,'other',5,'0',0,1,'4'),(61,'000000',6,'0',0,1,'4')",
|
||||
"INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')",
|
||||
"INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',31,999,'0')",
|
||||
} {
|
||||
mustExecAgentRoleSyncTest(t, ctx, statement)
|
||||
}
|
||||
message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, TemplateRole: true}
|
||||
if err := SyncInheritedRole(ctx, message); err == nil {
|
||||
t.Fatal("missing parent default role must fail")
|
||||
}
|
||||
if !reflect.DeepEqual(message.ChildDeptIds, []int64{2, 3}) || message.TotalCount != 3 || message.SuccessCount != 1 || message.FailedCount != 2 {
|
||||
t.Fatalf("unexpected failed hierarchy: %+v", message)
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=999", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(51,61)", 0)
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_dept SET dept_role=21 WHERE dept_id=2")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',41,777,'0')")
|
||||
if err := SyncInheritedRole(ctx, message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message.SuccessCount != 3 || message.FailedCount != 0 || len(message.ChildDeptIds) != 0 {
|
||||
t.Fatalf("retry counts: %+v", message)
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=100", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=999", 0)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=41 AND menu_id=777", 1)
|
||||
message.ChildDeptIds = []int64{3}
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_dept SET parent_id=99 WHERE dept_id=3")
|
||||
if err := SyncInheritedRole(ctx, message); err == nil || !reflect.DeepEqual(message.ChildDeptIds, []int64{3}) {
|
||||
t.Fatalf("removed retry target must not be reported as successful: %+v %v", message, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateHierarchyRejectsCycleBeforeWriting(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',2,1,0),(2,'000000',1,1,21)")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role(role_id,tenant_id,dept_id,status,is_pass,created_dept,data_scope) VALUES(10,'000000',1,'0',0,1,'4'),(21,'000000',2,'0',0,1,'4')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',21,999,'0')")
|
||||
if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err == nil {
|
||||
t.Fatal("cycle must fail before updating roles")
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=999", 1)
|
||||
}
|
||||
|
||||
func TestTemplateOverwriteResetsIndividualDefaultsOnly(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
for _, statement := range []string{
|
||||
"INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,21),(3,'000000',2,1,31)",
|
||||
"INSERT INTO sys_role(role_id,tenant_id,dept_id,status,is_pass,created_dept,data_scope) VALUES(10,'000000',1,'0',0,1,'4'),(21,'000000',2,'0',0,1,'4'),(31,'000000',3,'0',0,2,'4'),(22,'000000',2,'0',0,2,'3'),(32,'000000',3,'0',0,3,'5')",
|
||||
"INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')",
|
||||
"INSERT INTO sys_role_menu VALUES('000000',10,100,'0'),('000000',10,200,'0'),('000000',22,999,'3'),('000000',32,888,'5')",
|
||||
"INSERT INTO sys_user_role(user_id,role_id) VALUES(99,21),(100,31),(101,22)",
|
||||
} {
|
||||
mustExecAgentRoleSyncTest(t, ctx, statement)
|
||||
}
|
||||
sync := func() {
|
||||
t.Helper()
|
||||
if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, TemplateRole: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sync()
|
||||
// Platform expands one role, removes a default permission from another, and
|
||||
// gives unrelated staff roles independent permissions.
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu VALUES('000000',21,999,'0'),('000000',31,888,'0')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "DELETE FROM sys_role_menu WHERE role_id=21 AND menu_id=200")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "DELETE FROM sys_role_menu WHERE role_id=10 AND menu_id=100")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu VALUES('000000',10,300,'0')")
|
||||
sync()
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31)", 4)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31) AND menu_id IN(200,300)", 4)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31) AND menu_id IN(100,888,999)", 0)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE (role_id=22 AND menu_id=999 AND data_scope='3') OR (role_id=32 AND menu_id=888 AND data_scope='5')", 2)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_user_role", 3)
|
||||
// A repeated distribution must also replace individual default-role edits.
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu VALUES('000000',31,999,'0')")
|
||||
sync()
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31)", 4)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package simstatus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
zsetconsumer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consumer/zset"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
simstatusrepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/simstatus"
|
||||
)
|
||||
|
||||
const (
|
||||
telecomRetryableCode = "S00004"
|
||||
telecomStatusTemplate = "sim_dx_v1"
|
||||
chanGatewayInternalError = 9999
|
||||
// tokenExpiredCode is the carrier code for an expired/invalid upstream
|
||||
// token. The gateway already invalidates and refreshes the token once on
|
||||
// this code; when it still reaches the worker, retrying in a later round
|
||||
// lets a refreshed token succeed instead of parking the card as a terminal
|
||||
// business failure.
|
||||
tokenExpiredCode = 12021
|
||||
)
|
||||
|
||||
type StatusClient interface {
|
||||
QuerySimCardStatus(context.Context, *model.SimCardStatusInput) ([]model.SimBaseRes[model.SimCardStatusItem], error)
|
||||
}
|
||||
|
||||
type StatusRepository interface {
|
||||
ApplyBatch(context.Context, []simstatusrepo.Result) error
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
client StatusClient
|
||||
repo StatusRepository
|
||||
networkRetries int
|
||||
}
|
||||
|
||||
func NewHandler(client StatusClient, repo StatusRepository, networkRetries ...int) (*Handler, error) {
|
||||
if client == nil || repo == nil {
|
||||
return nil, fmt.Errorf("sim status handler dependencies are incomplete")
|
||||
}
|
||||
retries := 0
|
||||
if len(networkRetries) > 0 && networkRetries[0] > 0 {
|
||||
retries = networkRetries[0]
|
||||
}
|
||||
return &Handler{client: client, repo: repo, networkRetries: retries}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) Query(ctx context.Context, task queue.Task) zsetconsumer.Result {
|
||||
result := zsetconsumer.Result{Task: task}
|
||||
input := &model.SimCardStatusInput{SimKeys: []*model.SimKey{{
|
||||
ChanKey: task.ChanKey,
|
||||
ICCID: task.ICCID,
|
||||
IMSI: task.IMSI,
|
||||
MSISDN: task.MSISDN,
|
||||
}}}
|
||||
var (
|
||||
output []model.SimBaseRes[model.SimCardStatusItem]
|
||||
err error
|
||||
)
|
||||
for attempt := 0; attempt <= h.networkRetries; attempt++ {
|
||||
output, err = h.client.QuerySimCardStatus(ctx, input)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
result.ErrorClass = "network"
|
||||
result.Retryable = true
|
||||
result.Message = err.Error()
|
||||
return result
|
||||
}
|
||||
if len(output) == 0 {
|
||||
result.ErrorClass = "protocol"
|
||||
result.Retryable = true
|
||||
result.Message = "chan returned empty status result"
|
||||
return result
|
||||
}
|
||||
item := output[0]
|
||||
if item.SimKey == nil {
|
||||
item.SimKey = &model.SimKey{ChanKey: task.ChanKey, ICCID: task.ICCID, IMSI: task.IMSI, MSISDN: task.MSISDN}
|
||||
}
|
||||
if task.ICCID != "" && item.SimKey.ICCID != task.ICCID {
|
||||
result.ErrorClass = "protocol"
|
||||
result.Retryable = true
|
||||
result.Message = "chan returned mismatched card identifier"
|
||||
return result
|
||||
}
|
||||
result.Code = item.Code
|
||||
result.Message = item.Message
|
||||
result.ErrCode = item.ErrCode
|
||||
if item.Code != 0 {
|
||||
result.ErrorClass = "business"
|
||||
if model.IsTelecomTemplate(task.TempKey) && strings.EqualFold(strings.TrimSpace(item.ErrCode), telecomRetryableCode) {
|
||||
result.ErrorClass = "carrier_retry"
|
||||
result.Retryable = true
|
||||
} else if item.Code == chanGatewayInternalError && strings.TrimSpace(item.ErrCode) == "" {
|
||||
result.ErrorClass = "gateway"
|
||||
result.Retryable = true
|
||||
} else if item.Code == tokenExpiredCode {
|
||||
result.ErrorClass = "token_expired"
|
||||
result.Retryable = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
result.Success = true
|
||||
status := item.ResData.CardStatus
|
||||
result.CardStatus = &status
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *Handler) Apply(ctx context.Context, results []zsetconsumer.Result) error {
|
||||
updates := make([]simstatusrepo.Result, 0, len(results))
|
||||
for _, result := range results {
|
||||
if result.Success && result.CardStatus != nil {
|
||||
updates = append(updates, simstatusrepo.Result{CardID: result.Task.CardID, CardStatus: *result.CardStatus})
|
||||
}
|
||||
}
|
||||
return h.repo.ApplyBatch(ctx, updates)
|
||||
}
|
||||
|
||||
// ShouldRetryNextRound is the full-status-sync retry policy. It is passed to
|
||||
// the Run-aware ZSet consumer only; single-card tasks retain lease recovery.
|
||||
func ShouldRetryNextRound(result zsetconsumer.Result) bool {
|
||||
return !result.Success && result.Retryable
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package simstatus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
zsetconsumer "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consumer/zset"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset"
|
||||
simstatusrepo "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/simstatus"
|
||||
)
|
||||
|
||||
type fakeStatusClient struct {
|
||||
responses [][]model.SimBaseRes[model.SimCardStatusItem]
|
||||
errors []error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeStatusClient) QuerySimCardStatus(context.Context, *model.SimCardStatusInput) ([]model.SimBaseRes[model.SimCardStatusItem], error) {
|
||||
index := f.calls
|
||||
f.calls++
|
||||
var err error
|
||||
if index < len(f.errors) {
|
||||
err = f.errors[index]
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if index < len(f.responses) {
|
||||
return f.responses[index], nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type fakeStatusRepository struct {
|
||||
results []simstatusrepo.Result
|
||||
}
|
||||
|
||||
func (f *fakeStatusRepository) ApplyBatch(_ context.Context, results []simstatusrepo.Result) error {
|
||||
f.results = append(f.results, results...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHandlerUsesTaskCardWhenResponseOmitsSimKey(t *testing.T) {
|
||||
client := &fakeStatusClient{responses: [][]model.SimBaseRes[model.SimCardStatusItem]{{
|
||||
{Code: 0, ResData: model.SimCardStatusItem{CardStatus: 2}},
|
||||
}}}
|
||||
repo := &fakeStatusRepository{}
|
||||
handler, err := NewHandler(client, repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
task := queue.Task{CardID: 42, ICCID: "8986000000000042", ChanKey: "mobile-a", TempKey: "sim_yd_v5"}
|
||||
result := handler.Query(context.Background(), task)
|
||||
if !result.Success || result.CardStatus == nil || *result.CardStatus != 2 {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if err := handler.Apply(context.Background(), []zsetconsumer.Result{result}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.results) != 1 || repo.results[0].CardID != 42 || repo.results[0].CardStatus != 2 {
|
||||
t.Fatalf("unexpected updates: %+v", repo.results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRetriesNetworkErrorsOnlyForConfiguredAttempts(t *testing.T) {
|
||||
client := &fakeStatusClient{
|
||||
errors: []error{errors.New("temporary-1"), errors.New("temporary-2")},
|
||||
responses: [][]model.SimBaseRes[model.SimCardStatusItem]{nil, nil, {
|
||||
{Code: 0, ResData: model.SimCardStatusItem{CardStatus: 4}},
|
||||
}},
|
||||
}
|
||||
handler, err := NewHandler(client, &fakeStatusRepository{}, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := handler.Query(context.Background(), queue.Task{CardID: 1, ICCID: "iccid-1", ChanKey: "mobile-a", TempKey: "sim_yd_v5"})
|
||||
if !result.Success || client.calls != 3 {
|
||||
t.Fatalf("success=%v calls=%d result=%+v", result.Success, client.calls, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerClassifiesRetryableStatusFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item model.SimBaseRes[model.SimCardStatusItem]
|
||||
tempKey string
|
||||
errorClass string
|
||||
retryable bool
|
||||
}{
|
||||
{
|
||||
name: "telecom S00004",
|
||||
item: model.SimBaseRes[model.SimCardStatusItem]{Code: 9999, ErrCode: telecomRetryableCode},
|
||||
tempKey: telecomStatusTemplate,
|
||||
errorClass: "carrier_retry",
|
||||
retryable: true,
|
||||
},
|
||||
{
|
||||
name: "telecom v3 S00004",
|
||||
item: model.SimBaseRes[model.SimCardStatusItem]{Code: 9999, ErrCode: telecomRetryableCode},
|
||||
tempKey: "sim_dx_v3",
|
||||
errorClass: "carrier_retry",
|
||||
retryable: true,
|
||||
},
|
||||
{
|
||||
name: "gateway error without carrier code",
|
||||
item: model.SimBaseRes[model.SimCardStatusItem]{Code: chanGatewayInternalError},
|
||||
tempKey: telecomStatusTemplate,
|
||||
errorClass: "gateway",
|
||||
retryable: true,
|
||||
},
|
||||
{
|
||||
name: "token expired is retryable",
|
||||
item: model.SimBaseRes[model.SimCardStatusItem]{Code: tokenExpiredCode},
|
||||
tempKey: "sim_yd_v5",
|
||||
errorClass: "token_expired",
|
||||
retryable: true,
|
||||
},
|
||||
{
|
||||
name: "known business error remains terminal",
|
||||
item: model.SimBaseRes[model.SimCardStatusItem]{Code: 11011, ErrCode: "11011"},
|
||||
tempKey: telecomStatusTemplate,
|
||||
errorClass: "business",
|
||||
retryable: false,
|
||||
},
|
||||
{
|
||||
name: "non telecom S00004 remains terminal",
|
||||
item: model.SimBaseRes[model.SimCardStatusItem]{Code: 9999, ErrCode: telecomRetryableCode},
|
||||
tempKey: "sim_yd_v5",
|
||||
errorClass: "business",
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client := &fakeStatusClient{responses: [][]model.SimBaseRes[model.SimCardStatusItem]{{test.item}}}
|
||||
handler, err := NewHandler(client, &fakeStatusRepository{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := handler.Query(context.Background(), queue.Task{CardID: 1, ICCID: "iccid-1", ChanKey: "telecom-a", TempKey: test.tempKey})
|
||||
if result.ErrorClass != test.errorClass || result.Retryable != test.retryable || result.BlockChannel || result.Success {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRetryNextRoundUsesRetryableFlag(t *testing.T) {
|
||||
if !ShouldRetryNextRound(zsetconsumer.Result{Retryable: true}) {
|
||||
t.Fatal("retryable status failure should advance to the next round")
|
||||
}
|
||||
if ShouldRetryNextRound(zsetconsumer.Result{Success: true, Retryable: true}) || ShouldRetryNextRound(zsetconsumer.Result{}) {
|
||||
t.Fatal("successful and terminal status results must not advance rounds")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package trafficcutoff
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
const (
|
||||
cascadeFailureReason = "同一批次存在无效流量断网明细,已跳过更新"
|
||||
validationSkipReason = "批次校验未通过,已跳过流量断网更新"
|
||||
emptyICCIDReason = "ICCID不能为空"
|
||||
duplicateICCIDReason = "ICCID在批次内重复"
|
||||
unauthorizedReason = "ICCID不在当前操作员授权范围内"
|
||||
cardMissingReason = "ICCID不存在或不属于当前租户"
|
||||
statusCanceledReason = "ICCID已销户"
|
||||
statusInconsistentReason = "同一批次内卡状态不一致"
|
||||
)
|
||||
|
||||
type executor struct {
|
||||
repository Repository
|
||||
}
|
||||
|
||||
func NewExecutor(repository Repository) Executor {
|
||||
return &executor{repository: repository}
|
||||
}
|
||||
|
||||
func (e *executor) Execute(ctx context.Context, batchNo string) error {
|
||||
task, err := e.repository.Load(ctx, strings.TrimSpace(batchNo))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load traffic cutoff batch")
|
||||
}
|
||||
if !validateTask(task) {
|
||||
if err = e.repository.SaveValidationFailure(ctx, task); err != nil {
|
||||
return gerror.Wrap(err, "save traffic cutoff validation failure")
|
||||
}
|
||||
e.saveResultFile(ctx, task)
|
||||
return nil
|
||||
}
|
||||
if err = e.repository.CommitBusiness(ctx, task); err != nil {
|
||||
return gerror.Wrap(err, "commit traffic cutoff business")
|
||||
}
|
||||
e.saveResultFile(ctx, task)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *executor) saveResultFile(ctx context.Context, task *Task) {
|
||||
request := batchresult.Request{
|
||||
BatchNo: task.Batch.BatchNo,
|
||||
BatchType: task.Batch.BatchType,
|
||||
TenantID: task.Batch.TenantID,
|
||||
Rows: task.ResultRows(),
|
||||
}
|
||||
if err := e.repository.SaveResultFile(ctx, task, request); err != nil {
|
||||
g.Log().Errorf(ctx, "save traffic cutoff result file status failed, batchNo:%s, err:%+v", task.Batch.BatchNo, err)
|
||||
}
|
||||
}
|
||||
|
||||
func validateTask(task *Task) bool {
|
||||
if task == nil || len(task.Details) == 0 {
|
||||
if task != nil {
|
||||
task.FailureReason = "流量断网批次无有效明细"
|
||||
}
|
||||
return false
|
||||
}
|
||||
if task.TrafficCutoffStatus != 1 {
|
||||
task.FailureReason = "流量断网批次必须开启断网"
|
||||
markAllFailed(task, task.FailureReason)
|
||||
return false
|
||||
}
|
||||
if task.TrafficCutoffThreshold <= 0 {
|
||||
task.FailureReason = "流量断网阈值必须大于0"
|
||||
markAllFailed(task, task.FailureReason)
|
||||
return false
|
||||
}
|
||||
|
||||
hasFailure := false
|
||||
iccids := make(map[string]int, len(task.Details))
|
||||
statusSet := make(map[int]struct{})
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
hasFailure = true
|
||||
continue
|
||||
}
|
||||
validateDetail(detail)
|
||||
if detail.Reason != "" {
|
||||
hasFailure = true
|
||||
continue
|
||||
}
|
||||
iccids[detail.ICCID]++
|
||||
statusSet[detail.BossCardStatusBefore] = struct{}{}
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil || detail.Reason != "" {
|
||||
continue
|
||||
}
|
||||
if iccids[detail.ICCID] > 1 {
|
||||
detail.Reason = duplicateICCIDReason
|
||||
hasFailure = true
|
||||
}
|
||||
}
|
||||
if len(statusSet) > 1 {
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && detail.Reason == "" {
|
||||
detail.Reason = statusInconsistentReason
|
||||
}
|
||||
}
|
||||
hasFailure = true
|
||||
}
|
||||
if !hasFailure {
|
||||
task.ValidationOK = true
|
||||
return true
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && detail.Reason == "" {
|
||||
detail.Reason = cascadeFailureReason
|
||||
}
|
||||
}
|
||||
task.FailureReason = validationSkipReason
|
||||
return false
|
||||
}
|
||||
|
||||
func validateDetail(detail *Detail) {
|
||||
if detail.Reason != "" {
|
||||
return
|
||||
}
|
||||
detail.ICCID = strings.TrimSpace(detail.ICCID)
|
||||
detail.InputMSISDN = strings.TrimSpace(detail.InputMSISDN)
|
||||
detail.InputIMSI = strings.TrimSpace(detail.InputIMSI)
|
||||
if detail.ICCID == "" {
|
||||
detail.Reason = emptyICCIDReason
|
||||
return
|
||||
}
|
||||
if !detail.Authorized {
|
||||
detail.Reason = unauthorizedReason
|
||||
return
|
||||
}
|
||||
if detail.Card == nil {
|
||||
detail.Reason = cardMissingReason
|
||||
return
|
||||
}
|
||||
detail.MSISDN = strings.TrimSpace(detail.Card.MSISDN)
|
||||
detail.IMSI = strings.TrimSpace(detail.Card.IMSI)
|
||||
detail.BossCardStatusBefore = detail.Card.BossCardStatus
|
||||
detail.TrafficCutoffStatusBefore = detail.Card.TrafficCutoffStatus
|
||||
detail.TrafficCutoffThresholdBefore = detail.Card.TrafficCutoffThreshold
|
||||
|
||||
if detail.InputMSISDN != "" && detail.InputMSISDN != detail.MSISDN {
|
||||
detail.Reason = "MSISDN与卡档案不一致"
|
||||
return
|
||||
}
|
||||
if detail.InputIMSI != "" && detail.InputIMSI != detail.IMSI {
|
||||
detail.Reason = "IMSI与卡档案不一致"
|
||||
return
|
||||
}
|
||||
if detail.BossCardStatusBefore == BossCardStatusCanceled {
|
||||
detail.Reason = statusCanceledReason
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func markAllFailed(task *Task, reason string) {
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil {
|
||||
detail.Reason = reason
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package trafficcutoff
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
func TestValidateTaskFailsOnCanceledCard(t *testing.T) {
|
||||
task := validTask()
|
||||
task.Details[0].Card.BossCardStatus = BossCardStatusCanceled
|
||||
|
||||
if validateTask(task) {
|
||||
t.Fatal("expected canceled card to fail validation")
|
||||
}
|
||||
if task.Details[0].Reason != statusCanceledReason {
|
||||
t.Fatalf("reason = %q, want %q", task.Details[0].Reason, statusCanceledReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTaskFailsOnMixedCardStatus(t *testing.T) {
|
||||
task := validTask()
|
||||
task.Details = append(task.Details, &Detail{
|
||||
RowNo: 2,
|
||||
ICCID: "898600000000002",
|
||||
Authorized: true,
|
||||
Card: &Card{
|
||||
ID: 2,
|
||||
ICCID: "898600000000002",
|
||||
MSISDN: "13800000002",
|
||||
IMSI: "460001000000002",
|
||||
BossCardStatus: 5,
|
||||
},
|
||||
})
|
||||
|
||||
if validateTask(task) {
|
||||
t.Fatal("expected mixed status batch to fail validation")
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail.Reason != statusInconsistentReason {
|
||||
t.Fatalf("detail %d reason = %q, want %q", detail.RowNo, detail.Reason, statusInconsistentReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTaskFailsOnUnauthorizedAndCascades(t *testing.T) {
|
||||
task := validTask()
|
||||
task.Details = append(task.Details, &Detail{
|
||||
RowNo: 2,
|
||||
ICCID: "898600000000002",
|
||||
Authorized: false,
|
||||
Card: &Card{
|
||||
ID: 2,
|
||||
ICCID: "898600000000002",
|
||||
MSISDN: "13800000002",
|
||||
IMSI: "460001000000002",
|
||||
BossCardStatus: 4,
|
||||
},
|
||||
})
|
||||
|
||||
if validateTask(task) {
|
||||
t.Fatal("expected unauthorized row to fail validation")
|
||||
}
|
||||
if task.Details[1].Reason != unauthorizedReason {
|
||||
t.Fatalf("unauthorized reason = %q", task.Details[1].Reason)
|
||||
}
|
||||
if task.Details[0].Reason != cascadeFailureReason {
|
||||
t.Fatalf("cascade reason = %q", task.Details[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHappyPathCommitsAndSavesResult(t *testing.T) {
|
||||
task := validTask()
|
||||
repo := &fakeRepository{task: task}
|
||||
|
||||
if err := NewExecutor(repo).Execute(context.Background(), "DCT-1"); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !repo.committed {
|
||||
t.Fatal("expected business commit")
|
||||
}
|
||||
if repo.validationSaved {
|
||||
t.Fatal("did not expect validation failure save")
|
||||
}
|
||||
if !repo.resultSaved {
|
||||
t.Fatal("expected result file save")
|
||||
}
|
||||
if repo.resultRequest.BatchType != 107 || len(repo.resultRequest.Rows) != 1 {
|
||||
t.Fatalf("unexpected result request: %+v", repo.resultRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidationFailureSkipsCommit(t *testing.T) {
|
||||
task := validTask()
|
||||
task.Details[0].Authorized = false
|
||||
repo := &fakeRepository{task: task}
|
||||
|
||||
if err := NewExecutor(repo).Execute(context.Background(), "DCT-2"); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !repo.validationSaved {
|
||||
t.Fatal("expected validation failure save")
|
||||
}
|
||||
if repo.committed {
|
||||
t.Fatal("did not expect business commit")
|
||||
}
|
||||
if !repo.resultSaved {
|
||||
t.Fatal("expected result file save")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteCommitFailurePropagatesAndSkipsResult(t *testing.T) {
|
||||
task := validTask()
|
||||
commitErr := errors.New("transaction failed")
|
||||
repo := &fakeRepository{task: task, commitErr: commitErr}
|
||||
|
||||
err := NewExecutor(repo).Execute(context.Background(), "DCT-3")
|
||||
if err == nil || !errors.Is(err, commitErr) {
|
||||
t.Fatalf("Execute() error = %v, want wrap of %v", err, commitErr)
|
||||
}
|
||||
if repo.resultSaved {
|
||||
t.Fatal("result file should not be saved after commit error")
|
||||
}
|
||||
}
|
||||
|
||||
func validTask() *Task {
|
||||
return &Task{
|
||||
Batch: Batch{BatchNo: "DCT-1", BatchType: 107, TenantID: "000000"},
|
||||
TrafficCutoffStatus: 1,
|
||||
TrafficCutoffThreshold: 1024.5,
|
||||
ThresholdUnit: "MB",
|
||||
Details: []*Detail{
|
||||
{
|
||||
RowNo: 1,
|
||||
ICCID: "898600000000001",
|
||||
Authorized: true,
|
||||
Card: &Card{
|
||||
ID: 1,
|
||||
ICCID: "898600000000001",
|
||||
MSISDN: "13800000001",
|
||||
IMSI: "460001000000001",
|
||||
BossCardStatus: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRepository struct {
|
||||
task *Task
|
||||
committed bool
|
||||
commitErr error
|
||||
validationSaved bool
|
||||
resultSaved bool
|
||||
resultRequest batchresult.Request
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Load(_ context.Context, _ string) (*Task, error) {
|
||||
return r.task, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) SaveValidationFailure(_ context.Context, _ *Task) error {
|
||||
r.validationSaved = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) CommitBusiness(_ context.Context, _ *Task) error {
|
||||
r.committed = true
|
||||
return r.commitErr
|
||||
}
|
||||
|
||||
func (r *fakeRepository) SaveResultFile(_ context.Context, _ *Task, request batchresult.Request) error {
|
||||
r.resultSaved = true
|
||||
r.resultRequest = request
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package trafficcutoff
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
const BossCardStatusCanceled = 9
|
||||
|
||||
type Batch struct {
|
||||
ID int64
|
||||
BatchNo string
|
||||
BatchType int
|
||||
TenantID string
|
||||
DeptID int64
|
||||
CreatedBy int64
|
||||
CreatedDept int64
|
||||
StartTime *gtime.Time
|
||||
BatchInfo *gjson.Json
|
||||
}
|
||||
|
||||
type Card struct {
|
||||
ID int64
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
BossCardStatus int
|
||||
TrafficCutoffStatus int
|
||||
TrafficCutoffThreshold float64
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
ID int64
|
||||
RowNo int
|
||||
ICCID string
|
||||
InputMSISDN string
|
||||
InputIMSI string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
Authorized bool
|
||||
BossCardStatusBefore int
|
||||
TrafficCutoffStatusBefore int
|
||||
TrafficCutoffThresholdBefore float64
|
||||
TrafficCutoffCrossMonthHold int
|
||||
TrafficCutoffStatusAfter int
|
||||
TrafficCutoffThresholdAfter float64
|
||||
Reason string
|
||||
Card *Card
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Batch Batch
|
||||
TrafficCutoffStatus int
|
||||
TrafficCutoffThreshold float64
|
||||
ThresholdUnit string
|
||||
Details []*Detail
|
||||
ValidationOK bool
|
||||
FailureReason string
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
Load(ctx context.Context, batchNo string) (*Task, error)
|
||||
SaveValidationFailure(ctx context.Context, task *Task) error
|
||||
CommitBusiness(ctx context.Context, task *Task) error
|
||||
SaveResultFile(ctx context.Context, task *Task, request batchresult.Request) error
|
||||
}
|
||||
|
||||
type Executor interface {
|
||||
Execute(ctx context.Context, batchNo string) error
|
||||
}
|
||||
|
||||
func (d *Detail) Snapshot(task *Task) *gjson.Json {
|
||||
if d == nil {
|
||||
return gjson.New(nil)
|
||||
}
|
||||
return gjson.New(map[string]any{
|
||||
"rowNo": d.RowNo,
|
||||
"ICCID": d.ICCID,
|
||||
"MSISDN": d.MSISDN,
|
||||
"IMSI": d.IMSI,
|
||||
"inputMsisdn": d.InputMSISDN,
|
||||
"inputImsi": d.InputIMSI,
|
||||
"authorized": d.Authorized,
|
||||
"operation": "dataCut",
|
||||
"trafficCutoffStatusBefore": d.TrafficCutoffStatusBefore,
|
||||
"trafficCutoffThresholdBefore": d.TrafficCutoffThresholdBefore,
|
||||
"trafficCutoffStatusAfter": d.TrafficCutoffStatusAfter,
|
||||
"trafficCutoffThresholdAfter": d.TrafficCutoffThresholdAfter,
|
||||
"trafficCutoffThreshold": task.TrafficCutoffThreshold,
|
||||
"trafficCutoffCrossMonthHold": d.TrafficCutoffCrossMonthHold,
|
||||
"thresholdUnit": task.ThresholdUnit,
|
||||
"bossCardStatusBefore": d.BossCardStatusBefore,
|
||||
"errorReason": d.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
func (t *Task) ResultRows() []batchresult.Row {
|
||||
rows := make([]batchresult.Row, 0, len(t.Details))
|
||||
for _, detail := range t.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, batchresult.Row{
|
||||
RowNo: detail.RowNo,
|
||||
ICCID: detail.ICCID,
|
||||
MSISDN: detail.MSISDN,
|
||||
IMSI: detail.IMSI,
|
||||
Operation: "达量断网",
|
||||
Success: detail.Reason == "",
|
||||
Reason: detail.Reason,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (t *Task) SuccessCount() int {
|
||||
count := 0
|
||||
for _, detail := range t.Details {
|
||||
if detail != nil && detail.Reason == "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (t *Task) FailCount() int {
|
||||
if t == nil {
|
||||
return 0
|
||||
}
|
||||
return len(t.Details) - t.SuccessCount()
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package trafficcutoffconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
const (
|
||||
noDetailsReason = "达量断网配置批次没有可处理明细"
|
||||
validationFailureReason = "部分达量断网配置校验失败"
|
||||
emptyICCIDReason = "ICCID不能为空"
|
||||
duplicateICCIDReason = "ICCID在批次内重复"
|
||||
unauthorizedReason = "ICCID不在当前操作员授权范围内"
|
||||
cardMissingReason = "ICCID不存在或不属于当前租户"
|
||||
msisdnMismatchReason = "MSISDN与卡档案不一致"
|
||||
imsiMismatchReason = "IMSI与卡档案不一致"
|
||||
flowPoolCardReason = "流量池卡不支持达量断网配置"
|
||||
packageTotalExceededReason = "达量断网阈值不能大于套餐总流量"
|
||||
)
|
||||
|
||||
type executor struct {
|
||||
repository *Repository
|
||||
}
|
||||
|
||||
const trafficCutoffConfigProcessingRetryAfter = 2 * time.Minute
|
||||
|
||||
func NewExecutor() *executor {
|
||||
return &executor{repository: NewRepository()}
|
||||
}
|
||||
|
||||
func (e *executor) CanExecuteProcessing(_ context.Context, main *entity.BatchMain) (bool, error) {
|
||||
if main == nil || main.BatchType != consts.BatchTypeTrafficCutoffConfig || main.BatchAsyncStatus != consts.BatchAsyncStatusProcessing {
|
||||
return false, nil
|
||||
}
|
||||
if main.StartTime == nil || main.StartTime.IsZero() {
|
||||
return true, nil
|
||||
}
|
||||
return gtime.Now().Sub(main.StartTime) >= trafficCutoffConfigProcessingRetryAfter, nil
|
||||
}
|
||||
|
||||
func (e *executor) Execute(ctx context.Context, batchNo string) error {
|
||||
task, err := e.repository.Load(ctx, strings.TrimSpace(batchNo))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load traffic cutoff config batch failed")
|
||||
}
|
||||
validateTask(task)
|
||||
return gerror.Wrap(e.repository.Commit(ctx, task), "commit traffic cutoff config batch failed")
|
||||
}
|
||||
|
||||
func validateTask(task *Task) {
|
||||
if task == nil || len(task.Details) == 0 {
|
||||
if task != nil {
|
||||
task.FailureReason = noDetailsReason
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
iccidCounts := make(map[string]int, len(task.Details))
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
detail.ICCID = strings.TrimSpace(detail.ICCID)
|
||||
detail.MSISDN = strings.TrimSpace(detail.MSISDN)
|
||||
detail.IMSI = strings.TrimSpace(detail.IMSI)
|
||||
if detail.ICCID != "" {
|
||||
iccidCounts[detail.ICCID]++
|
||||
}
|
||||
}
|
||||
|
||||
for _, detail := range task.Details {
|
||||
validateDetail(task, detail)
|
||||
if detail != nil && detail.Reason == "" && iccidCounts[detail.ICCID] > 1 {
|
||||
detail.Reason = duplicateICCIDReason
|
||||
}
|
||||
}
|
||||
_, failCount := countResult(task)
|
||||
if failCount > 0 {
|
||||
task.FailureReason = validationFailureReason
|
||||
} else {
|
||||
task.FailureReason = ""
|
||||
}
|
||||
}
|
||||
|
||||
func validateDetail(task *Task, detail *Detail) {
|
||||
if detail == nil || detail.Reason != "" {
|
||||
return
|
||||
}
|
||||
if detail.ICCID == "" {
|
||||
detail.Reason = emptyICCIDReason
|
||||
return
|
||||
}
|
||||
if !detail.Authorized {
|
||||
detail.Reason = unauthorizedReason
|
||||
return
|
||||
}
|
||||
if detail.Card == nil {
|
||||
detail.Reason = cardMissingReason
|
||||
return
|
||||
}
|
||||
detail.Card.ICCID = strings.TrimSpace(detail.Card.ICCID)
|
||||
detail.Card.MSISDN = strings.TrimSpace(detail.Card.MSISDN)
|
||||
detail.Card.IMSI = strings.TrimSpace(detail.Card.IMSI)
|
||||
if detail.MSISDN != "" && detail.MSISDN != detail.Card.MSISDN {
|
||||
detail.Reason = msisdnMismatchReason
|
||||
return
|
||||
}
|
||||
if detail.IMSI != "" && detail.IMSI != detail.Card.IMSI {
|
||||
detail.Reason = imsiMismatchReason
|
||||
return
|
||||
}
|
||||
if detail.Card.IsFlowPool != 0 {
|
||||
detail.Reason = flowPoolCardReason
|
||||
return
|
||||
}
|
||||
if task.Config.TrafficCutoffStatus == 1 &&
|
||||
detail.Card.PackageTotalFlowMb > 0 &&
|
||||
task.Config.TrafficCutoffThreshold > detail.Card.PackageTotalFlowMb {
|
||||
detail.Reason = packageTotalExceededReason
|
||||
}
|
||||
}
|
||||
|
||||
func countResult(task *Task) (successCount, failCount int) {
|
||||
if task == nil {
|
||||
return 0, 0
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && detail.Success() {
|
||||
successCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
return successCount, failCount
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package trafficcutoffconfig
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateTaskAllowsDifferentCardStatusesAndMissingEffectivePackage(t *testing.T) {
|
||||
task := validConfigTask(512)
|
||||
task.Details = []*Detail{
|
||||
validConfigDetail(1, "898600000000001"),
|
||||
validConfigDetail(2, "898600000000002"),
|
||||
validConfigDetail(3, "898600000000003"),
|
||||
}
|
||||
task.Details[0].Card.BossCardStatus = 1
|
||||
task.Details[0].Card.PackageStatus = 0
|
||||
task.Details[0].Card.PackageTotalFlowMb = 0
|
||||
task.Details[1].Card.BossCardStatus = 4
|
||||
task.Details[1].Card.PackageStatus = 1
|
||||
task.Details[1].Card.PackageTotalFlowMb = 0
|
||||
task.Details[2].Card.BossCardStatus = 9
|
||||
task.Details[2].Card.PackageStatus = 3
|
||||
task.Details[2].Card.PackageTotalFlowMb = 0
|
||||
|
||||
validateTask(task)
|
||||
|
||||
if success, fail := countResult(task); success != 3 || fail != 0 {
|
||||
t.Fatalf("countResult() = (%d, %d), want (3, 0); task = %+v", success, fail, task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTaskAllowsPartialSuccessForThresholdGreaterThanPositivePackageTotal(t *testing.T) {
|
||||
task := validConfigTask(512)
|
||||
task.Details = []*Detail{
|
||||
validConfigDetail(1, "898600000000001"),
|
||||
validConfigDetail(2, "898600000000002"),
|
||||
}
|
||||
task.Details[0].Card.PackageTotalFlowMb = 128
|
||||
task.Details[1].Card.PackageTotalFlowMb = 0
|
||||
|
||||
validateTask(task)
|
||||
|
||||
assertDetailReason(t, task.Details[0], packageTotalExceededReason)
|
||||
assertDetailReason(t, task.Details[1], "")
|
||||
if success, fail := countResult(task); success != 1 || fail != 1 {
|
||||
t.Fatalf("countResult() = (%d, %d), want (1, 1)", success, fail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTaskAllowsPartialSuccessForInvalidRows(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
details []*Detail
|
||||
wantReason map[int]string
|
||||
}{
|
||||
{
|
||||
name: "empty ICCID",
|
||||
details: []*Detail{
|
||||
{RowNo: 1, ICCID: " ", Authorized: true, Card: validConfigCard(1, "")},
|
||||
validConfigDetail(2, "898600000000002"),
|
||||
},
|
||||
wantReason: map[int]string{1: emptyICCIDReason, 2: ""},
|
||||
},
|
||||
{
|
||||
name: "duplicate ICCID",
|
||||
details: []*Detail{
|
||||
validConfigDetail(1, "898600000000001"),
|
||||
validConfigDetail(2, "898600000000001"),
|
||||
validConfigDetail(3, "898600000000003"),
|
||||
},
|
||||
wantReason: map[int]string{1: duplicateICCIDReason, 2: duplicateICCIDReason, 3: ""},
|
||||
},
|
||||
{
|
||||
name: "unauthorized",
|
||||
details: []*Detail{
|
||||
validConfigDetail(1, "898600000000001"),
|
||||
validConfigDetail(2, "898600000000002"),
|
||||
},
|
||||
wantReason: map[int]string{1: unauthorizedReason, 2: ""},
|
||||
},
|
||||
{
|
||||
name: "missing card",
|
||||
details: []*Detail{
|
||||
{RowNo: 1, ICCID: "898600000000001", Authorized: true},
|
||||
validConfigDetail(2, "898600000000002"),
|
||||
},
|
||||
wantReason: map[int]string{1: cardMissingReason, 2: ""},
|
||||
},
|
||||
{
|
||||
name: "MSISDN mismatch",
|
||||
details: []*Detail{
|
||||
validConfigDetail(1, "898600000000001"),
|
||||
validConfigDetail(2, "898600000000002"),
|
||||
},
|
||||
wantReason: map[int]string{1: msisdnMismatchReason, 2: ""},
|
||||
},
|
||||
{
|
||||
name: "IMSI mismatch",
|
||||
details: []*Detail{
|
||||
validConfigDetail(1, "898600000000001"),
|
||||
validConfigDetail(2, "898600000000002"),
|
||||
},
|
||||
wantReason: map[int]string{1: imsiMismatchReason, 2: ""},
|
||||
},
|
||||
{
|
||||
name: "flow pool card",
|
||||
details: []*Detail{
|
||||
validConfigDetail(1, "898600000000001"),
|
||||
validConfigDetail(2, "898600000000002"),
|
||||
},
|
||||
wantReason: map[int]string{1: flowPoolCardReason, 2: ""},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
task := validConfigTask(512)
|
||||
task.Details = tt.details
|
||||
switch tt.name {
|
||||
case "unauthorized":
|
||||
task.Details[0].Authorized = false
|
||||
case "MSISDN mismatch":
|
||||
task.Details[0].MSISDN = "13900009999"
|
||||
case "IMSI mismatch":
|
||||
task.Details[0].IMSI = "460009999999999"
|
||||
case "flow pool card":
|
||||
task.Details[0].Card.IsFlowPool = 1
|
||||
}
|
||||
|
||||
validateTask(task)
|
||||
|
||||
for _, detail := range task.Details {
|
||||
assertDetailReason(t, detail, tt.wantReason[detail.RowNo])
|
||||
}
|
||||
if success, fail := countResult(task); success != 1 || fail != len(task.Details)-1 {
|
||||
t.Fatalf("countResult() = (%d, %d), want (1, %d)", success, fail, len(task.Details)-1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validConfigTask(threshold float64) *Task {
|
||||
return &Task{
|
||||
Batch: Batch{
|
||||
BatchNo: "TCC-41",
|
||||
BatchType: 41,
|
||||
TenantID: "000000",
|
||||
CreatedBy: 7,
|
||||
},
|
||||
Config: Config{
|
||||
TrafficCutoffStatus: 1,
|
||||
TrafficCutoffThreshold: threshold,
|
||||
},
|
||||
Details: []*Detail{validConfigDetail(1, "898600000000001")},
|
||||
}
|
||||
}
|
||||
|
||||
func validConfigDetail(rowNo int, iccid string) *Detail {
|
||||
card := validConfigCard(rowNo, iccid)
|
||||
return &Detail{
|
||||
ID: int64(rowNo),
|
||||
RowNo: rowNo,
|
||||
ICCID: iccid,
|
||||
MSISDN: card.MSISDN,
|
||||
IMSI: card.IMSI,
|
||||
Authorized: true,
|
||||
Card: card,
|
||||
}
|
||||
}
|
||||
|
||||
func validConfigCard(rowNo int, iccid string) *Card {
|
||||
return &Card{
|
||||
ID: int64(rowNo),
|
||||
ICCID: iccid,
|
||||
MSISDN: "1380000000" + string(rune('0'+rowNo)),
|
||||
IMSI: "46000100000000" + string(rune('0'+rowNo)),
|
||||
PackageStatus: 0,
|
||||
BossCardStatus: rowNo,
|
||||
PackageTotalFlowMb: 1024,
|
||||
TrafficCutoffStatus: 0,
|
||||
TrafficCutoffThreshold: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func assertDetailReason(t *testing.T, detail *Detail, want string) {
|
||||
t.Helper()
|
||||
if detail == nil {
|
||||
t.Fatal("detail is nil")
|
||||
}
|
||||
if detail.Reason != want {
|
||||
t.Fatalf("row %d reason = %q, want %q", detail.RowNo, detail.Reason, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package trafficcutoffconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db gdb.DB
|
||||
results batchresult.Generator
|
||||
lockEnabled bool
|
||||
}
|
||||
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{results: batchresult.New(), lockEnabled: true}
|
||||
}
|
||||
|
||||
func (r *Repository) database() gdb.DB {
|
||||
if r.db != nil {
|
||||
return r.db
|
||||
}
|
||||
return g.DB()
|
||||
}
|
||||
|
||||
func (r *Repository) applyLock(model *gdb.Model) *gdb.Model {
|
||||
if r.lockEnabled {
|
||||
return model.LockUpdate()
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func (r *Repository) Load(ctx context.Context, batchNo string) (*Task, error) {
|
||||
batchNo = strings.TrimSpace(batchNo)
|
||||
if batchNo == "" {
|
||||
return nil, gerror.New("达量断网配置批次号不能为空")
|
||||
}
|
||||
|
||||
var main *entity.BatchMain
|
||||
if err := r.database().Model(dao.BatchMain.Table()).Safe().Ctx(ctx).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeTrafficCutoffConfig).
|
||||
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
|
||||
Scan(&main); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询达量断网配置批次主表失败")
|
||||
}
|
||||
if main == nil || main.Id == 0 {
|
||||
return nil, gerror.New("处理中达量断网配置批次不存在")
|
||||
}
|
||||
config, err := configFromBatchInfo(main.BatchInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var batchDetails []*entity.BatchDetail
|
||||
if err = r.database().Model(dao.BatchDetail.Table()).Safe().Ctx(ctx).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeTrafficCutoffConfig).
|
||||
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
|
||||
OrderAsc(dao.BatchDetail.Columns().RowNo).
|
||||
Scan(&batchDetails); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询达量断网配置批次明细失败")
|
||||
}
|
||||
if len(batchDetails) == 0 {
|
||||
return nil, gerror.New("达量断网配置批次没有运行中明细")
|
||||
}
|
||||
|
||||
task := &Task{
|
||||
Batch: Batch{
|
||||
ID: main.Id,
|
||||
BatchNo: main.BatchNo,
|
||||
BatchType: main.BatchType,
|
||||
TenantID: main.TenantId,
|
||||
CreatedDept: main.CreatedDept,
|
||||
CreatedBy: main.CreatedBy,
|
||||
StartTime: main.StartTime,
|
||||
BatchInfo: main.BatchInfo,
|
||||
},
|
||||
Config: config,
|
||||
Details: make([]*Detail, 0, len(batchDetails)),
|
||||
}
|
||||
for _, batchDetail := range batchDetails {
|
||||
if batchDetail == nil {
|
||||
continue
|
||||
}
|
||||
detail := &Detail{
|
||||
ID: batchDetail.Id,
|
||||
RowNo: batchDetail.RowNo,
|
||||
ICCID: strings.TrimSpace(batchDetail.ItemId),
|
||||
Reason: strings.TrimSpace(batchDetail.ErrorReason),
|
||||
}
|
||||
if batchDetail.Snapshot != nil {
|
||||
if value := firstSnapshot(batchDetail.Snapshot, "ICCID", "iccid"); value != "" {
|
||||
detail.ICCID = value
|
||||
}
|
||||
detail.MSISDN = firstSnapshot(batchDetail.Snapshot, "MSISDN", "msisdn", "inputMsisdn", "input_msisdn")
|
||||
detail.IMSI = firstSnapshot(batchDetail.Snapshot, "IMSI", "imsi", "inputImsi", "input_imsi")
|
||||
detail.Authorized = batchDetail.Snapshot.Get("authorized").Bool()
|
||||
}
|
||||
task.Details = append(task.Details, detail)
|
||||
}
|
||||
if len(task.Details) == 0 {
|
||||
return nil, gerror.New("达量断网配置批次没有可读取明细")
|
||||
}
|
||||
if err = r.loadCards(ctx, task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func firstSnapshot(snapshot *gjson.Json, keys ...string) string {
|
||||
if snapshot == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(snapshot.Get(key).String()); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *Repository) loadCards(ctx context.Context, task *Task) error {
|
||||
iccids := make([]string, 0, len(task.Details))
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && strings.TrimSpace(detail.ICCID) != "" {
|
||||
iccids = append(iccids, strings.TrimSpace(detail.ICCID))
|
||||
}
|
||||
}
|
||||
iccids = uniqueSortedStrings(iccids)
|
||||
if len(iccids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var cards []*entity.TSimCardInfo
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
if err := r.database().Model(dao.TSimCardInfo.Table()).Safe().Ctx(ctx).
|
||||
Where(columns.TenantId, task.Batch.TenantID).
|
||||
WhereIn(columns.Iccid, iccids).
|
||||
Scan(&cards); err != nil {
|
||||
return gerror.Wrap(err, "查询SIM卡档案失败")
|
||||
}
|
||||
byICCID := make(map[string]*entity.TSimCardInfo, len(cards))
|
||||
for _, card := range cards {
|
||||
if card != nil {
|
||||
byICCID[strings.TrimSpace(card.Iccid)] = card
|
||||
}
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
card := byICCID[strings.TrimSpace(detail.ICCID)]
|
||||
if card == nil {
|
||||
continue
|
||||
}
|
||||
detail.Card = &Card{
|
||||
ID: card.Id,
|
||||
ICCID: strings.TrimSpace(card.Iccid),
|
||||
MSISDN: strings.TrimSpace(card.Msisdn),
|
||||
IMSI: strings.TrimSpace(card.Imsi),
|
||||
CurrentPackageCode: strings.TrimSpace(card.CurrentPackageCode),
|
||||
PackageStatus: card.PackageStatus,
|
||||
BossCardStatus: card.BossCardStatus,
|
||||
IsFlowPool: card.IsFlowPool,
|
||||
PackageTotalFlowMb: card.PackageTotalFlowMb,
|
||||
TrafficCutoffStatus: card.TrafficCutoffStatus,
|
||||
TrafficCutoffThreshold: card.TrafficCutoffThreshold,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) Commit(ctx context.Context, task *Task) error {
|
||||
if task == nil {
|
||||
return gerror.New("达量断网配置任务不能为空")
|
||||
}
|
||||
successCount, failCount := countResult(task)
|
||||
artifact, artifactErr := r.createResultFile(ctx, task)
|
||||
if artifactErr != nil {
|
||||
g.Log().Warningf(ctx, "traffic cutoff config result file generation failed, batchNo:%s, err:%+v", task.Batch.BatchNo, artifactErr)
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
err := r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if successCount > 0 {
|
||||
if err := r.updateAllCardConfigs(ctx, tx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := r.updateDetails(ctx, tx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ossID, downloadID int64
|
||||
if artifactErr == nil {
|
||||
var err error
|
||||
ossID, downloadID, err = r.persistArtifact(ctx, tx, task, artifact, successCount, failCount, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return r.finalizeBatch(ctx, tx, task, successCount, failCount, ossID, downloadID, now, artifactErr != nil)
|
||||
})
|
||||
if err != nil && artifactErr == nil && artifact.Path != "" {
|
||||
batchresult.RemoveLocalFile(artifact)
|
||||
}
|
||||
return gerror.Wrap(err, "保存达量断网配置批次结果失败")
|
||||
}
|
||||
|
||||
func (r *Repository) updateAllCardConfigs(ctx context.Context, tx gdb.TX, task *Task) error {
|
||||
cardIDs := successDetailCardIDs(task.Details)
|
||||
if len(cardIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
var lockedCards []*entity.TSimCardInfo
|
||||
model := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).Safe().
|
||||
Fields(columns.Id).
|
||||
Where(columns.TenantId, task.Batch.TenantID).
|
||||
WhereIn(columns.Id, cardIDs)
|
||||
if err := r.applyLock(model).Scan(&lockedCards); err != nil {
|
||||
return gerror.Wrap(err, "锁定达量断网配置卡档案失败")
|
||||
}
|
||||
if len(lockedCards) != len(cardIDs) {
|
||||
return gerror.New("部分达量断网配置卡档案已发生变化")
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).Safe().
|
||||
Where(columns.TenantId, task.Batch.TenantID).
|
||||
WhereIn(columns.Id, cardIDs).
|
||||
Data(trafficCutoffConfigData(task)).Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "保存达量断网配置失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) updateDetails(ctx context.Context, tx gdb.TX, task *Task) error {
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
if err := r.updateDetail(ctx, tx, task, detail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) updateDetail(ctx context.Context, tx gdb.TX, task *Task, detail *Detail) error {
|
||||
status := consts.BatchDetailStatusFailed
|
||||
if detail.Success() {
|
||||
status = consts.BatchDetailStatusSuccess
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().Id, detail.ID).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeTrafficCutoffConfig).
|
||||
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
|
||||
Data(do.BatchDetail{
|
||||
DetailStatus: status,
|
||||
ErrorReason: detail.Reason,
|
||||
Snapshot: detail.Snapshot(task.Config),
|
||||
UpdatedBy: task.Batch.CreatedBy,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "更新达量断网配置批次明细失败")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return gerror.Newf("达量断网配置批次明细%d已不是运行中状态", detail.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) finalizeBatch(ctx context.Context, tx gdb.TX, task *Task, successCount, failCount int, ossID, downloadID int64, now *gtime.Time, resultFileFailed bool) error {
|
||||
batchAsyncStatus, runResult := batchResult(successCount, failCount)
|
||||
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, task.Batch.BatchNo).
|
||||
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeTrafficCutoffConfig).
|
||||
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
|
||||
Data(do.BatchMain{
|
||||
BatchAsyncStatus: batchAsyncStatus,
|
||||
RunResult: runResult,
|
||||
TotalCount: len(task.Details),
|
||||
ValidCount: successCount,
|
||||
SuccessCount: successCount,
|
||||
FailCount: failCount,
|
||||
FailReason: task.FailureReason,
|
||||
BatchInfo: mergeBatchInfo(task.Batch.BatchInfo, ossID, downloadID, resultFileFailed),
|
||||
EndTime: now,
|
||||
UpdatedBy: task.Batch.CreatedBy,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "更新达量断网配置批次主表失败")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return gerror.New("达量断网配置批次已不是处理中状态")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) createResultFile(ctx context.Context, task *Task) (batchresult.Artifact, error) {
|
||||
if r.results == nil {
|
||||
return batchresult.Artifact{}, gerror.New("result generator is not configured")
|
||||
}
|
||||
return r.results.Create(ctx, batchresult.Request{
|
||||
BatchNo: task.Batch.BatchNo,
|
||||
BatchType: task.Batch.BatchType,
|
||||
TenantID: task.Batch.TenantID,
|
||||
Rows: task.ResultRows(),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) persistArtifact(ctx context.Context, tx gdb.TX, task *Task, artifact batchresult.Artifact, successCount, failCount int, now *gtime.Time) (int64, int64, error) {
|
||||
ossID, err := tx.Ctx(ctx).Model(dao.SysOss.Table()).Data(do.SysOss{
|
||||
TenantId: task.Batch.TenantID,
|
||||
FileName: artifact.FileName,
|
||||
OriginalName: artifact.OriginalName,
|
||||
FileSuffix: artifact.FileSuffix,
|
||||
Path: artifact.Path,
|
||||
Url: artifact.URL,
|
||||
CreatedDept: task.Batch.CreatedDept,
|
||||
CreatedBy: task.Batch.CreatedBy,
|
||||
UpdatedBy: task.Batch.CreatedBy,
|
||||
Service: "local",
|
||||
Md5: artifact.MD5,
|
||||
FileSize: artifact.Size,
|
||||
}).InsertAndGetId()
|
||||
if err != nil {
|
||||
return 0, 0, gerror.Wrap(err, "写入达量断网配置结果文件OSS失败")
|
||||
}
|
||||
downloadID, err := tx.Ctx(ctx).Model(dao.TResourceBatchDownload.Table()).Data(do.TResourceBatchDownload{
|
||||
BatchNo: task.Batch.BatchNo,
|
||||
TaskName: batchresult.TaskName(task.Batch.BatchType),
|
||||
ResourceType: consts.ResourceTypeExcel,
|
||||
ExportScene: consts.BatchTypeTrafficCutoffConfig,
|
||||
TotalCount: len(task.Details),
|
||||
SuccessCount: successCount,
|
||||
FailCount: failCount,
|
||||
DownloadStatus: consts.DownloadStatusGenerated,
|
||||
OssId: ossID,
|
||||
FileName: artifact.FileName,
|
||||
FileExt: artifact.FileSuffix,
|
||||
FileUrl: artifact.URL,
|
||||
FilePath: artifact.Path,
|
||||
FileSize: artifact.Size,
|
||||
FileMd5: artifact.MD5,
|
||||
DownloadCount: 0,
|
||||
MaxDownloadCount: 0,
|
||||
DeptId: task.Batch.CreatedDept,
|
||||
StartTime: task.Batch.StartTime,
|
||||
FinishTime: now,
|
||||
TenantId: task.Batch.TenantID,
|
||||
CreatedDept: task.Batch.CreatedDept,
|
||||
CreatedBy: task.Batch.CreatedBy,
|
||||
UpdatedBy: task.Batch.CreatedBy,
|
||||
}).InsertAndGetId()
|
||||
if err != nil {
|
||||
return 0, 0, gerror.Wrap(err, "写入达量断网配置结果下载记录失败")
|
||||
}
|
||||
return ossID, downloadID, nil
|
||||
}
|
||||
|
||||
func configFromBatchInfo(info *gjson.Json) (Config, error) {
|
||||
if info == nil || info.IsNil() {
|
||||
return Config{}, gerror.New("达量断网配置批次缺少批次参数")
|
||||
}
|
||||
status := info.Get("trafficCutoffStatus")
|
||||
if status.IsNil() {
|
||||
return Config{}, gerror.New("达量断网配置批次缺少trafficCutoffStatus")
|
||||
}
|
||||
config := Config{
|
||||
TrafficCutoffCrossMonthHold: info.Get("trafficCutoffCrossMonthHold").Int(),
|
||||
TrafficCutoffStatus: status.Int(),
|
||||
TrafficCutoffThreshold: info.Get("trafficCutoffThreshold").Float64(),
|
||||
}
|
||||
if config.TrafficCutoffStatus != 0 && config.TrafficCutoffStatus != 1 && config.TrafficCutoffStatus != 2 {
|
||||
return Config{}, gerror.New("达量断网启停状态无效")
|
||||
}
|
||||
if config.TrafficCutoffThreshold < 0 {
|
||||
return Config{}, gerror.New("达量断网阈值不能小于0")
|
||||
}
|
||||
if config.TrafficCutoffStatus == 0 && config.TrafficCutoffThreshold != 0 {
|
||||
return Config{}, gerror.New("未配置达量断网时阈值必须为0")
|
||||
}
|
||||
if config.TrafficCutoffStatus == 1 && config.TrafficCutoffThreshold <= 0 {
|
||||
return Config{}, gerror.New("开启达量断网时阈值必须大于0")
|
||||
}
|
||||
if config.TrafficCutoffCrossMonthHold != 0 && config.TrafficCutoffCrossMonthHold != 1 {
|
||||
return Config{}, gerror.New("invalid cross-month traffic cutoff hold value")
|
||||
}
|
||||
if config.TrafficCutoffStatus != 1 && config.TrafficCutoffCrossMonthHold != 0 {
|
||||
return Config{}, gerror.New("cross-month hold requires traffic cutoff to be enabled")
|
||||
}
|
||||
rounded := math.Round(config.TrafficCutoffThreshold*1000) / 1000
|
||||
if math.Abs(config.TrafficCutoffThreshold-rounded) > 0.0000001 {
|
||||
return Config{}, gerror.New("达量断网阈值最多保留3位小数")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func trafficCutoffConfigData(task *Task) do.TSimCardTrafficCutoffConfig {
|
||||
data := do.TSimCardTrafficCutoffConfig{
|
||||
TrafficCutoffStatus: task.Config.TrafficCutoffStatus,
|
||||
TrafficCutoffThreshold: task.Config.TrafficCutoffThreshold,
|
||||
TrafficCutoffCrossMonthHold: task.Config.TrafficCutoffCrossMonthHold,
|
||||
UpdatedBy: task.Batch.CreatedBy,
|
||||
}
|
||||
if task.Config.TrafficCutoffStatus != 1 || task.Config.TrafficCutoffCrossMonthHold == 0 {
|
||||
data.TrafficCutoffTriggeredAt = gdb.Raw("NULL")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func mergeBatchInfo(info *gjson.Json, ossID, downloadID int64, resultFileFailed bool) *gjson.Json {
|
||||
data := make(map[string]any)
|
||||
if info != nil {
|
||||
for key, value := range info.Map() {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
if ossID > 0 {
|
||||
data["resultFileOssId"] = ossID
|
||||
}
|
||||
if resultFileFailed {
|
||||
data["resultFile"] = map[string]any{"status": "failed"}
|
||||
} else if downloadID > 0 {
|
||||
data["resultDownloadId"] = downloadID
|
||||
data["resultFile"] = map[string]any{"status": "generated"}
|
||||
} else {
|
||||
data["resultFile"] = map[string]any{"status": "skipped"}
|
||||
}
|
||||
return gjson.New(data)
|
||||
}
|
||||
|
||||
func batchResult(successCount, failCount int) (int, int) {
|
||||
if failCount == 0 {
|
||||
return consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess
|
||||
}
|
||||
if successCount > 0 {
|
||||
return consts.BatchAsyncStatusPartialSuccess, consts.BatchRunResultPartialSuccess
|
||||
}
|
||||
return consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed
|
||||
}
|
||||
|
||||
func successDetailCardIDs(details []*Detail) []int64 {
|
||||
ids := make([]int64, 0, len(details))
|
||||
seen := make(map[int64]struct{}, len(details))
|
||||
for _, detail := range details {
|
||||
if detail != nil && detail.Success() && detail.Card != nil && detail.Card.ID > 0 {
|
||||
if _, exists := seen[detail.Card.ID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[detail.Card.ID] = struct{}{}
|
||||
ids = append(ids, detail.Card.ID)
|
||||
}
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids
|
||||
}
|
||||
|
||||
func uniqueSortedStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
package trafficcutoffconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
|
||||
batchdomain "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/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
)
|
||||
|
||||
const (
|
||||
testTenantID = "000000"
|
||||
testDeptID = int64(100)
|
||||
)
|
||||
|
||||
func TestExecuteAllSuccessUpdatesAllCardsAndFinalizesSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newConfigTestRepository(t)
|
||||
batchNo := "TCC-41-SUCCESS"
|
||||
seedConfigProcessingBatch(t, ctx, repo.db, batchNo, 512)
|
||||
seedConfigRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", true)
|
||||
seedConfigRunningDetail(t, ctx, repo.db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", true)
|
||||
seedConfigCard(t, ctx, repo.db, cardSeed{ICCID: "898600000000001", MSISDN: "13800000001", IMSI: "460001000000001", BossStatus: 1, PackageStatus: 0, PackageTotal: 0})
|
||||
seedConfigCard(t, ctx, repo.db, cardSeed{ICCID: "898600000000002", MSISDN: "13800000002", IMSI: "460001000000002", BossStatus: 9, PackageStatus: 3, PackageTotal: 0})
|
||||
|
||||
if err := (&executor{repository: repo}).Execute(ctx, batchNo); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
assertConfigBatchMain(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess, 2, 0)
|
||||
assertConfigDetailStatusCount(t, ctx, repo.db, batchNo, consts.BatchDetailStatusSuccess, 2)
|
||||
assertConfigDetailStatusCount(t, ctx, repo.db, batchNo, consts.BatchDetailStatusFailed, 0)
|
||||
assertConfigTrafficCutoff(t, ctx, repo.db, "898600000000001", 1, 512, 7)
|
||||
assertConfigTrafficCutoff(t, ctx, repo.db, "898600000000002", 1, 512, 7)
|
||||
assertNoPartialSuccess(t, ctx, repo.db, batchNo)
|
||||
}
|
||||
|
||||
func TestExecuteDuplicateRowsMarksWholeBatchFailedWithoutCardUpdate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newConfigTestRepository(t)
|
||||
batchNo := "TCC-41-DUPLICATE"
|
||||
seedConfigProcessingBatch(t, ctx, repo.db, batchNo, 512)
|
||||
seedConfigRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", true)
|
||||
seedConfigRunningDetail(t, ctx, repo.db, batchNo, 2, "898600000000001", "13800000001", "460001000000001", true)
|
||||
seedConfigCard(t, ctx, repo.db, cardSeed{ICCID: "898600000000001", MSISDN: "13800000001", IMSI: "460001000000001", BossStatus: 2, PackageStatus: 2, PackageTotal: 1024})
|
||||
|
||||
if err := (&executor{repository: repo}).Execute(ctx, batchNo); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
assertConfigBatchMain(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed, 0, 2)
|
||||
assertConfigDetailStatusCount(t, ctx, repo.db, batchNo, consts.BatchDetailStatusSuccess, 0)
|
||||
assertConfigDetailStatusCount(t, ctx, repo.db, batchNo, consts.BatchDetailStatusFailed, 2)
|
||||
assertAnyDetailReason(t, ctx, repo.db, batchNo, duplicateICCIDReason)
|
||||
assertAllConfigTrafficCutoffUnchanged(t, ctx, repo.db)
|
||||
assertNoPartialSuccess(t, ctx, repo.db, batchNo)
|
||||
}
|
||||
|
||||
func TestExecuteInvalidRowsAllowPartialSuccess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
threshold float64
|
||||
validICCID string
|
||||
invalidICCID string
|
||||
mutate func(ctx context.Context, t *testing.T, db gdb.DB, batchNo string)
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "unauthorized",
|
||||
threshold: 512,
|
||||
validICCID: "898600000000002",
|
||||
invalidICCID: "898600000000001",
|
||||
wantReason: unauthorizedReason,
|
||||
mutate: func(ctx context.Context, t *testing.T, db gdb.DB, batchNo string) {
|
||||
seedConfigRunningDetail(t, ctx, db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", false)
|
||||
seedConfigRunningDetail(t, ctx, db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", true)
|
||||
seedConfigCard(t, ctx, db, cardSeed{ICCID: "898600000000001", MSISDN: "13800000001", IMSI: "460001000000001", BossStatus: 2, PackageStatus: 2, PackageTotal: 1024})
|
||||
seedConfigCard(t, ctx, db, cardSeed{ICCID: "898600000000002", MSISDN: "13800000002", IMSI: "460001000000002", BossStatus: 2, PackageStatus: 2, PackageTotal: 1024})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing",
|
||||
threshold: 512,
|
||||
validICCID: "898600000000002",
|
||||
wantReason: cardMissingReason,
|
||||
mutate: func(ctx context.Context, t *testing.T, db gdb.DB, batchNo string) {
|
||||
seedConfigRunningDetail(t, ctx, db, batchNo, 1, "898600000000404", "13800000404", "460001000000404", true)
|
||||
seedConfigRunningDetail(t, ctx, db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", true)
|
||||
seedConfigCard(t, ctx, db, cardSeed{ICCID: "898600000000002", MSISDN: "13800000002", IMSI: "460001000000002", BossStatus: 2, PackageStatus: 2, PackageTotal: 1024})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MSISDN mismatch",
|
||||
threshold: 512,
|
||||
validICCID: "898600000000002",
|
||||
invalidICCID: "898600000000001",
|
||||
wantReason: msisdnMismatchReason,
|
||||
mutate: func(ctx context.Context, t *testing.T, db gdb.DB, batchNo string) {
|
||||
seedConfigRunningDetail(t, ctx, db, batchNo, 1, "898600000000001", "13900009999", "460001000000001", true)
|
||||
seedConfigRunningDetail(t, ctx, db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", true)
|
||||
seedConfigCard(t, ctx, db, cardSeed{ICCID: "898600000000001", MSISDN: "13800000001", IMSI: "460001000000001", BossStatus: 2, PackageStatus: 2, PackageTotal: 1024})
|
||||
seedConfigCard(t, ctx, db, cardSeed{ICCID: "898600000000002", MSISDN: "13800000002", IMSI: "460001000000002", BossStatus: 2, PackageStatus: 2, PackageTotal: 1024})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IMSI mismatch",
|
||||
threshold: 512,
|
||||
validICCID: "898600000000002",
|
||||
invalidICCID: "898600000000001",
|
||||
wantReason: imsiMismatchReason,
|
||||
mutate: func(ctx context.Context, t *testing.T, db gdb.DB, batchNo string) {
|
||||
seedConfigRunningDetail(t, ctx, db, batchNo, 1, "898600000000001", "13800000001", "460009999999999", true)
|
||||
seedConfigRunningDetail(t, ctx, db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", true)
|
||||
seedConfigCard(t, ctx, db, cardSeed{ICCID: "898600000000001", MSISDN: "13800000001", IMSI: "460001000000001", BossStatus: 2, PackageStatus: 2, PackageTotal: 1024})
|
||||
seedConfigCard(t, ctx, db, cardSeed{ICCID: "898600000000002", MSISDN: "13800000002", IMSI: "460001000000002", BossStatus: 2, PackageStatus: 2, PackageTotal: 1024})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flow pool",
|
||||
threshold: 512,
|
||||
validICCID: "898600000000002",
|
||||
invalidICCID: "898600000000001",
|
||||
wantReason: flowPoolCardReason,
|
||||
mutate: func(ctx context.Context, t *testing.T, db gdb.DB, batchNo string) {
|
||||
seedConfigRunningDetail(t, ctx, db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", true)
|
||||
seedConfigRunningDetail(t, ctx, db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", true)
|
||||
seedConfigCard(t, ctx, db, cardSeed{ICCID: "898600000000001", MSISDN: "13800000001", IMSI: "460001000000001", BossStatus: 2, PackageStatus: 2, PackageTotal: 1024, IsFlowPool: 1})
|
||||
seedConfigCard(t, ctx, db, cardSeed{ICCID: "898600000000002", MSISDN: "13800000002", IMSI: "460001000000002", BossStatus: 2, PackageStatus: 2, PackageTotal: 1024})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "package total exceeded",
|
||||
threshold: 2048,
|
||||
validICCID: "898600000000002",
|
||||
invalidICCID: "898600000000001",
|
||||
wantReason: packageTotalExceededReason,
|
||||
mutate: func(ctx context.Context, t *testing.T, db gdb.DB, batchNo string) {
|
||||
seedConfigRunningDetail(t, ctx, db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", true)
|
||||
seedConfigRunningDetail(t, ctx, db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", true)
|
||||
seedConfigCard(t, ctx, db, cardSeed{ICCID: "898600000000001", MSISDN: "13800000001", IMSI: "460001000000001", BossStatus: 2, PackageStatus: 2, PackageTotal: 1024})
|
||||
seedConfigCard(t, ctx, db, cardSeed{ICCID: "898600000000002", MSISDN: "13800000002", IMSI: "460001000000002", BossStatus: 2, PackageStatus: 2, PackageTotal: 0})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newConfigTestRepository(t)
|
||||
batchNo := "TCC-41-" + tt.name
|
||||
seedConfigProcessingBatch(t, ctx, repo.db, batchNo, tt.threshold)
|
||||
tt.mutate(ctx, t, repo.db, batchNo)
|
||||
|
||||
if err := (&executor{repository: repo}).Execute(ctx, batchNo); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
assertConfigBatchMain(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusPartialSuccess, consts.BatchRunResultPartialSuccess, 1, 1)
|
||||
assertConfigDetailStatusCount(t, ctx, repo.db, batchNo, consts.BatchDetailStatusSuccess, 1)
|
||||
assertConfigDetailStatusCount(t, ctx, repo.db, batchNo, consts.BatchDetailStatusFailed, 1)
|
||||
assertAnyDetailReason(t, ctx, repo.db, batchNo, tt.wantReason)
|
||||
assertConfigTrafficCutoff(t, ctx, repo.db, tt.validICCID, 1, tt.threshold, 7)
|
||||
if tt.invalidICCID != "" {
|
||||
assertConfigTrafficCutoff(t, ctx, repo.db, tt.invalidICCID, 0, 0, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDuplicateMessageDoesNotReapplyBusinessUpdate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newConfigTestRepository(t)
|
||||
batchNo := "TCC-41-REPLAY"
|
||||
seedConfigProcessingBatch(t, ctx, repo.db, batchNo, 512)
|
||||
seedConfigRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", true)
|
||||
seedConfigCard(t, ctx, repo.db, cardSeed{ICCID: "898600000000001", MSISDN: "13800000001", IMSI: "460001000000001", BossStatus: 2, PackageStatus: 0, PackageTotal: 0})
|
||||
|
||||
exec := &executor{repository: repo}
|
||||
if err := exec.Execute(ctx, batchNo); err != nil {
|
||||
t.Fatalf("first Execute() error = %v", err)
|
||||
}
|
||||
assertConfigTrafficCutoff(t, ctx, repo.db, "898600000000001", 1, 512, 7)
|
||||
if _, err := repo.db.Model(dao.TSimCardInfo.Table()).Ctx(ctx).
|
||||
Where(dao.TSimCardInfo.Columns().Iccid, "898600000000001").
|
||||
Data(do.TSimCardInfo{TrafficCutoffStatus: 0, TrafficCutoffThreshold: 0, UpdatedBy: 99}).
|
||||
Update(); err != nil {
|
||||
t.Fatalf("reset card after first execute: %v", err)
|
||||
}
|
||||
|
||||
if err := exec.Execute(ctx, batchNo); err == nil {
|
||||
t.Fatal("second Execute() error = nil, want non-nil for finalized duplicate message")
|
||||
}
|
||||
assertConfigBatchMain(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess, 1, 0)
|
||||
assertConfigTrafficCutoff(t, ctx, repo.db, "898600000000001", 0, 0, 99)
|
||||
}
|
||||
|
||||
type cardSeed struct {
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
BossStatus int
|
||||
PackageStatus int
|
||||
PackageTotal float64
|
||||
IsFlowPool int
|
||||
}
|
||||
|
||||
func newConfigTestRepository(t *testing.T) *Repository {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
db, err := gdb.New(gdb.ConfigNode{
|
||||
Type: "sqlite",
|
||||
Link: "sqlite::@file(:memory:)",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlite database: %v", err)
|
||||
}
|
||||
statements := []string{
|
||||
`CREATE TABLE batch_main (
|
||||
source_channel TEXT NOT NULL DEFAULT 'legacy',
|
||||
execution_mode INTEGER NOT NULL DEFAULT 0,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_no TEXT NOT NULL,
|
||||
batch_type INTEGER NOT NULL,
|
||||
batch_async_status INTEGER NOT NULL,
|
||||
run_result INTEGER NOT NULL DEFAULT 0,
|
||||
total_count INTEGER NOT NULL DEFAULT 0,
|
||||
valid_count INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
fail_reason TEXT,
|
||||
start_time DATETIME,
|
||||
end_time DATETIME,
|
||||
created_by INTEGER NOT NULL DEFAULT 0,
|
||||
created_dept INTEGER NOT NULL DEFAULT 0,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
batch_info TEXT,
|
||||
updated_by INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE batch_detail (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_no TEXT NOT NULL,
|
||||
batch_type INTEGER NOT NULL,
|
||||
detail_status INTEGER NOT NULL,
|
||||
error_reason TEXT,
|
||||
snapshot TEXT,
|
||||
item_id TEXT,
|
||||
row_no INTEGER NOT NULL DEFAULT 0,
|
||||
updated_by INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE t_sim_card_info (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
iccid TEXT NOT NULL,
|
||||
msisdn TEXT,
|
||||
imsi TEXT,
|
||||
current_package_code TEXT,
|
||||
package_status INTEGER NOT NULL DEFAULT 0,
|
||||
boss_card_status INTEGER NOT NULL DEFAULT 0,
|
||||
is_flow_pool INTEGER NOT NULL DEFAULT 0,
|
||||
package_total_flow_mb REAL NOT NULL DEFAULT 0,
|
||||
traffic_cutoff_status INTEGER NOT NULL DEFAULT 0,
|
||||
traffic_cutoff_threshold REAL NOT NULL DEFAULT 0,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
updated_by INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err = db.Exec(ctx, statement); err != nil {
|
||||
t.Fatalf("create test schema: %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close(ctx) })
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func seedConfigProcessingBatch(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, threshold float64) {
|
||||
t.Helper()
|
||||
info := gjson.New(map[string]any{
|
||||
"operation": "traffic_cutoff_config",
|
||||
"trafficCutoffStatus": 1,
|
||||
"trafficCutoffThreshold": threshold,
|
||||
"thresholdUnit": "MB",
|
||||
})
|
||||
if _, err := db.Model(dao.BatchMain.Table()).Ctx(ctx).Data(do.BatchMain{
|
||||
BatchNo: batchNo,
|
||||
BatchType: consts.BatchTypeTrafficCutoffConfig,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
RunResult: batchdomain.RunResultRunning,
|
||||
CreatedBy: 7,
|
||||
CreatedDept: testDeptID,
|
||||
TenantId: testTenantID,
|
||||
BatchInfo: info,
|
||||
}).Insert(); err != nil {
|
||||
t.Fatalf("insert batch main: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFromBatchInfoValidatesThreeStateContract(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
info map[string]any
|
||||
wantStatus int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "unconfigured", info: map[string]any{"trafficCutoffStatus": 0, "trafficCutoffThreshold": 0}},
|
||||
{name: "enabled", info: map[string]any{"trafficCutoffStatus": 1, "trafficCutoffThreshold": 1.125}, wantStatus: 1},
|
||||
{name: "disabled with retained threshold", info: map[string]any{"trafficCutoffStatus": 2, "trafficCutoffThreshold": 1.125}, wantStatus: 2},
|
||||
{name: "old field only", info: map[string]any{"trafficCutoffEnabled": 1, "trafficCutoffThreshold": 1}, wantErr: true},
|
||||
{name: "unconfigured nonzero threshold", info: map[string]any{"trafficCutoffStatus": 0, "trafficCutoffThreshold": 1}, wantErr: true},
|
||||
{name: "enabled zero threshold", info: map[string]any{"trafficCutoffStatus": 1, "trafficCutoffThreshold": 0}, wantErr: true},
|
||||
{name: "negative threshold", info: map[string]any{"trafficCutoffStatus": 2, "trafficCutoffThreshold": -1}, wantErr: true},
|
||||
{name: "too many decimals", info: map[string]any{"trafficCutoffStatus": 2, "trafficCutoffThreshold": 1.0001}, wantErr: true},
|
||||
{name: "invalid status", info: map[string]any{"trafficCutoffStatus": 3, "trafficCutoffThreshold": 0}, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := configFromBatchInfo(gjson.New(tt.info))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("configFromBatchInfo() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if got.TrafficCutoffStatus != tt.wantStatus {
|
||||
t.Fatalf("configFromBatchInfo() status = %d, want %d", got.TrafficCutoffStatus, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func seedConfigRunningDetail(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, rowNo int, iccid, msisdn, imsi string, authorized bool) {
|
||||
t.Helper()
|
||||
snapshot := gjson.New(map[string]any{
|
||||
"ICCID": iccid,
|
||||
"MSISDN": msisdn,
|
||||
"IMSI": imsi,
|
||||
"authorized": authorized,
|
||||
})
|
||||
if _, err := db.Model(dao.BatchDetail.Table()).Ctx(ctx).Data(do.BatchDetail{
|
||||
BatchNo: batchNo,
|
||||
BatchType: consts.BatchTypeTrafficCutoffConfig,
|
||||
DetailStatus: consts.BatchDetailStatusRunning,
|
||||
RowNo: rowNo,
|
||||
ItemId: iccid,
|
||||
Snapshot: snapshot,
|
||||
}).Insert(); err != nil {
|
||||
t.Fatalf("insert batch detail: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedConfigCard(t *testing.T, ctx context.Context, db gdb.DB, seed cardSeed) {
|
||||
t.Helper()
|
||||
if _, err := db.Model(dao.TSimCardInfo.Table()).Ctx(ctx).Data(do.TSimCardInfo{
|
||||
Iccid: seed.ICCID,
|
||||
Msisdn: seed.MSISDN,
|
||||
Imsi: seed.IMSI,
|
||||
CurrentPackageCode: "PKG-" + seed.ICCID,
|
||||
PackageStatus: seed.PackageStatus,
|
||||
BossCardStatus: seed.BossStatus,
|
||||
IsFlowPool: seed.IsFlowPool,
|
||||
PackageTotalFlowMb: seed.PackageTotal,
|
||||
TrafficCutoffStatus: 0,
|
||||
TrafficCutoffThreshold: 0,
|
||||
TenantId: testTenantID,
|
||||
}).Insert(); err != nil {
|
||||
t.Fatalf("insert sim card: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertConfigBatchMain(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, wantAsync, wantRun, wantSuccess, wantFail int) {
|
||||
t.Helper()
|
||||
var row struct {
|
||||
Async int `orm:"batch_async_status"`
|
||||
Run int `orm:"run_result"`
|
||||
Success int `orm:"success_count"`
|
||||
Fail int `orm:"fail_count"`
|
||||
}
|
||||
if err := db.Model(dao.BatchMain.Table()).Ctx(ctx).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Fields(dao.BatchMain.Columns().BatchAsyncStatus, dao.BatchMain.Columns().RunResult, dao.BatchMain.Columns().SuccessCount, dao.BatchMain.Columns().FailCount).
|
||||
Scan(&row); err != nil {
|
||||
t.Fatalf("query batch main: %v", err)
|
||||
}
|
||||
if row.Async != wantAsync || row.Run != wantRun || row.Success != wantSuccess || row.Fail != wantFail {
|
||||
t.Fatalf("batch main = %+v, want async=%d run=%d success=%d fail=%d", row, wantAsync, wantRun, wantSuccess, wantFail)
|
||||
}
|
||||
}
|
||||
|
||||
func assertConfigDetailStatusCount(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, status, want int) {
|
||||
t.Helper()
|
||||
count, err := db.Model(dao.BatchDetail.Table()).Ctx(ctx).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().DetailStatus, status).
|
||||
Count()
|
||||
if err != nil {
|
||||
t.Fatalf("count details: %v", err)
|
||||
}
|
||||
if count != want {
|
||||
t.Fatalf("detail status %d count = %d, want %d", status, count, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertConfigTrafficCutoff(t *testing.T, ctx context.Context, db gdb.DB, iccid string, enabled int, threshold float64, updatedBy int64) {
|
||||
t.Helper()
|
||||
var row struct {
|
||||
Enabled int `orm:"traffic_cutoff_status"`
|
||||
Threshold float64 `orm:"traffic_cutoff_threshold"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
}
|
||||
if err := db.Model(dao.TSimCardInfo.Table()).Ctx(ctx).
|
||||
Where(dao.TSimCardInfo.Columns().Iccid, iccid).
|
||||
Fields(dao.TSimCardInfo.Columns().TrafficCutoffStatus, dao.TSimCardInfo.Columns().TrafficCutoffThreshold, dao.TSimCardInfo.Columns().UpdatedBy).
|
||||
Scan(&row); err != nil {
|
||||
t.Fatalf("query traffic cutoff fields: %v", err)
|
||||
}
|
||||
if row.Enabled != enabled || row.Threshold != threshold || row.UpdatedBy != updatedBy {
|
||||
t.Fatalf("%s cutoff = (%d, %v, updatedBy=%d), want (%d, %v, updatedBy=%d)", iccid, row.Enabled, row.Threshold, row.UpdatedBy, enabled, threshold, updatedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAllConfigTrafficCutoffUnchanged(t *testing.T, ctx context.Context, db gdb.DB) {
|
||||
t.Helper()
|
||||
count, err := db.Model(dao.TSimCardInfo.Table()).Ctx(ctx).
|
||||
Where(dao.TSimCardInfo.Columns().TrafficCutoffStatus, 1).
|
||||
Count()
|
||||
if err != nil {
|
||||
t.Fatalf("count updated cards: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("updated card count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAnyDetailReason(t *testing.T, ctx context.Context, db gdb.DB, batchNo, want string) {
|
||||
t.Helper()
|
||||
count, err := db.Model(dao.BatchDetail.Table()).Ctx(ctx).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().ErrorReason, want).
|
||||
Count()
|
||||
if err != nil {
|
||||
t.Fatalf("count detail reason: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatalf("detail reason %q count = 0, want at least 1", want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoPartialSuccess(t *testing.T, ctx context.Context, db gdb.DB, batchNo string) {
|
||||
t.Helper()
|
||||
count, err := db.Model(dao.BatchMain.Table()).Ctx(ctx).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusPartialSuccess).
|
||||
Count()
|
||||
if err != nil {
|
||||
t.Fatalf("count partial success batch main: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("partial success batch count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package trafficcutoffconfig
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
type Batch struct {
|
||||
ID int64
|
||||
BatchNo string
|
||||
BatchType int
|
||||
TenantID string
|
||||
CreatedDept int64
|
||||
CreatedBy int64
|
||||
StartTime *gtime.Time
|
||||
BatchInfo *gjson.Json
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
TrafficCutoffCrossMonthHold int
|
||||
TrafficCutoffStatus int
|
||||
TrafficCutoffThreshold float64
|
||||
}
|
||||
|
||||
type Card struct {
|
||||
ID int64
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
CurrentPackageCode string
|
||||
PackageStatus int
|
||||
BossCardStatus int
|
||||
IsFlowPool int
|
||||
PackageTotalFlowMb float64
|
||||
TrafficCutoffStatus int
|
||||
TrafficCutoffThreshold float64
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
ID int64
|
||||
RowNo int
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
Authorized bool
|
||||
Card *Card
|
||||
Reason string
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Batch Batch
|
||||
Config Config
|
||||
Details []*Detail
|
||||
FailureReason string
|
||||
}
|
||||
|
||||
func (d *Detail) Success() bool {
|
||||
return d != nil && strings.TrimSpace(d.Reason) == ""
|
||||
}
|
||||
|
||||
func (d *Detail) DisplayCode() string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
if d.Card != nil && strings.TrimSpace(d.Card.ICCID) != "" {
|
||||
return strings.TrimSpace(d.Card.ICCID)
|
||||
}
|
||||
return strings.TrimSpace(d.ICCID)
|
||||
}
|
||||
|
||||
func (d *Detail) Snapshot(config Config) *gjson.Json {
|
||||
if d == nil {
|
||||
return gjson.New(nil)
|
||||
}
|
||||
data := map[string]any{
|
||||
"rowNo": d.RowNo,
|
||||
"ICCID": d.ICCID,
|
||||
"MSISDN": d.MSISDN,
|
||||
"IMSI": d.IMSI,
|
||||
"authorized": d.Authorized,
|
||||
"trafficCutoffStatusAfter": config.TrafficCutoffStatus,
|
||||
"trafficCutoffThresholdAfter": config.TrafficCutoffThreshold,
|
||||
"trafficCutoffCrossMonthHoldAfter": config.TrafficCutoffCrossMonthHold,
|
||||
"thresholdUnit": "MB",
|
||||
"errorReason": d.Reason,
|
||||
}
|
||||
if d.Card != nil {
|
||||
data["cardId"] = d.Card.ID
|
||||
data["cardIccid"] = d.Card.ICCID
|
||||
data["cardMsisdn"] = d.Card.MSISDN
|
||||
data["cardImsi"] = d.Card.IMSI
|
||||
data["currentPackageCode"] = d.Card.CurrentPackageCode
|
||||
data["packageStatus"] = d.Card.PackageStatus
|
||||
data["bossCardStatus"] = d.Card.BossCardStatus
|
||||
data["isFlowPool"] = d.Card.IsFlowPool
|
||||
data["packageTotalFlowMb"] = d.Card.PackageTotalFlowMb
|
||||
data["trafficCutoffStatusBefore"] = d.Card.TrafficCutoffStatus
|
||||
data["trafficCutoffThresholdBefore"] = d.Card.TrafficCutoffThreshold
|
||||
}
|
||||
return gjson.New(data)
|
||||
}
|
||||
|
||||
func (t *Task) ResultRows() []batchresult.Row {
|
||||
rows := make([]batchresult.Row, 0, len(t.Details))
|
||||
for _, detail := range t.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, batchresult.Row{
|
||||
RowNo: detail.RowNo,
|
||||
ObjectCode: detail.DisplayCode(),
|
||||
ICCID: detail.ICCID,
|
||||
MSISDN: detail.MSISDN,
|
||||
IMSI: detail.IMSI,
|
||||
Operation: "达量断网配置",
|
||||
Success: detail.Success(),
|
||||
Reason: detail.Reason,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package trafficusage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
type UpstreamClient interface {
|
||||
QuerySimDatausageDaily(context.Context, *model.SimDataUsageDailyInput) ([]model.SimBaseRes[model.SimDataUsageDailyOutputItem], error)
|
||||
QuerySimDatausageMonthly(context.Context, *model.SimDataUsageMonthlyInput) ([]model.SimBaseRes[model.SimDataUsageMonthlyOutputItem], error)
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
UpdateDaily(context.Context, []model.TrafficUsageDailyItem) error
|
||||
UpdateMonthly(context.Context, []model.TrafficUsageMonthlyItem) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
upstream UpstreamClient
|
||||
repository Repository
|
||||
}
|
||||
|
||||
type Failure struct {
|
||||
Cause error
|
||||
UsageMb *float64
|
||||
Permanent bool
|
||||
}
|
||||
|
||||
func (failure *Failure) Error() string { return failure.Cause.Error() }
|
||||
func (failure *Failure) Unwrap() error { return failure.Cause }
|
||||
|
||||
func New(upstream UpstreamClient, repository Repository) *Service {
|
||||
return &Service{upstream: upstream, repository: repository}
|
||||
}
|
||||
|
||||
var usageLocation = time.FixedZone("UTC+8", 8*60*60)
|
||||
|
||||
func validateMessage(simKey *model.SimKey, card model.TrafficUsageSimCard, queryDate, layout string) (time.Time, error) {
|
||||
if simKey == nil || card.Iccid == "" || simKey.ICCID != card.Iccid {
|
||||
return time.Time{}, &Failure{Cause: gerror.New("invalid traffic usage card identity"), Permanent: true}
|
||||
}
|
||||
date, err := time.ParseInLocation(layout, queryDate, usageLocation)
|
||||
if err != nil {
|
||||
return time.Time{}, &Failure{Cause: gerror.Wrap(err, "invalid traffic usage period"), Permanent: true}
|
||||
}
|
||||
return date, nil
|
||||
}
|
||||
|
||||
func validateUsage(usage float64) error {
|
||||
if math.IsNaN(usage) || math.IsInf(usage, 0) || usage < 0 {
|
||||
return &Failure{Cause: gerror.New("invalid traffic usage amount"), Permanent: true}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RetryDaily(ctx context.Context, message model.TrafficUsageDailyRetryMessage) error {
|
||||
date, err := validateMessage(message.SimKey, message.Card, message.QueryDate, "20060102")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
usage := message.UsageMb
|
||||
if usage == nil {
|
||||
output, err := s.upstream.QuerySimDatausageDaily(ctx, &model.SimDataUsageDailyInput{
|
||||
SimKeys: []*model.SimKey{message.SimKey},
|
||||
ReqData: model.SimDataUsageDailyInputItem{QueryDate: message.QueryDate},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(output) != 1 || output[0].SimKey == nil || output[0].SimKey.ICCID != message.Card.Iccid {
|
||||
return gerror.New("daily usage response missing, failed or card identity mismatched")
|
||||
}
|
||||
if output[0].Code != 0 {
|
||||
return &Failure{Cause: gerror.Newf("daily usage query failed: %s", output[0].Message), Permanent: isPermanentUsageResponse(output[0].Message)}
|
||||
}
|
||||
if queryDate, ok := normalizeDailyQueryDate(output[0].ResData.QueryDate); !ok || queryDate != message.QueryDate {
|
||||
return gerror.New("daily usage response period mismatched")
|
||||
}
|
||||
usage = &output[0].ResData.DataAmount
|
||||
}
|
||||
if err := validateUsage(*usage); err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.repository.UpdateDaily(ctx, []model.TrafficUsageDailyItem{{
|
||||
TrafficUsageBase: model.TrafficUsageBase{Iccid: message.Card.Iccid, UsageMb: *usage, PackageCode: message.Card.PackageCode}, Date: date,
|
||||
}})
|
||||
if err != nil {
|
||||
return &Failure{Cause: err, UsageMb: usage}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RetryMonthly(ctx context.Context, message model.TrafficUsageMonthlyRetryMessage) error {
|
||||
month, err := validateMessage(message.SimKey, message.Card, message.QueryDate, "200601")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
usage := message.UsageMb
|
||||
if usage == nil {
|
||||
output, err := s.upstream.QuerySimDatausageMonthly(ctx, &model.SimDataUsageMonthlyInput{
|
||||
SimKeys: []*model.SimKey{message.SimKey},
|
||||
ReqData: model.SimDataUsageMonthlyInputItem{QueryDate: message.QueryDate},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(output) != 1 || output[0].SimKey == nil || output[0].SimKey.ICCID != message.Card.Iccid {
|
||||
return gerror.New("monthly usage response missing, failed or card identity mismatched")
|
||||
}
|
||||
if output[0].Code != 0 {
|
||||
return &Failure{Cause: gerror.Newf("monthly usage query failed: %s", output[0].Message), Permanent: isPermanentUsageResponse(output[0].Message)}
|
||||
}
|
||||
if queryMonth, ok := normalizeMonthlyQueryDate(output[0].ResData.DeadLine); !ok || queryMonth != message.QueryDate {
|
||||
return gerror.New("monthly usage response period mismatched")
|
||||
}
|
||||
usage = &output[0].ResData.DataAmount
|
||||
}
|
||||
if err := validateUsage(*usage); err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.repository.UpdateMonthly(ctx, []model.TrafficUsageMonthlyItem{{
|
||||
TrafficUsageBase: model.TrafficUsageBase{Iccid: message.Card.Iccid, UsageMb: *usage, PackageCode: message.Card.PackageCode}, Month: month,
|
||||
}})
|
||||
if err != nil {
|
||||
return &Failure{Cause: err, UsageMb: usage}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDailyQueryDate(value string) (string, bool) {
|
||||
for _, layout := range []string{"20060102", "2006-01-02"} {
|
||||
parsed, err := time.ParseInLocation(layout, value, usageLocation)
|
||||
if err == nil {
|
||||
return parsed.Format("20060102"), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func normalizeMonthlyQueryDate(value string) (string, bool) {
|
||||
for _, layout := range []string{"200601", "2006-01", "20060102", "2006-01-02"} {
|
||||
parsed, err := time.ParseInLocation(layout, value, usageLocation)
|
||||
if err == nil {
|
||||
return parsed.Format("200601"), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isPermanentUsageResponse(message string) bool {
|
||||
message = strings.ToLower(strings.TrimSpace(message))
|
||||
return strings.Contains(message, "unsupported") || strings.Contains(message, "does not support") || strings.Contains(message, "not supported")
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package trafficusage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
g.Log().SetWriter(io.Discard)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
type upstreamStub struct {
|
||||
dailyCalls int
|
||||
monthlyCalls int
|
||||
dailyOutput []model.SimBaseRes[model.SimDataUsageDailyOutputItem]
|
||||
monthlyOutput []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]
|
||||
dailyErr error
|
||||
monthlyErr error
|
||||
}
|
||||
|
||||
func (s *upstreamStub) QuerySimDatausageDaily(_ context.Context, _ *model.SimDataUsageDailyInput) ([]model.SimBaseRes[model.SimDataUsageDailyOutputItem], error) {
|
||||
s.dailyCalls++
|
||||
return s.dailyOutput, s.dailyErr
|
||||
}
|
||||
|
||||
func (s *upstreamStub) QuerySimDatausageMonthly(_ context.Context, _ *model.SimDataUsageMonthlyInput) ([]model.SimBaseRes[model.SimDataUsageMonthlyOutputItem], error) {
|
||||
s.monthlyCalls++
|
||||
return s.monthlyOutput, s.monthlyErr
|
||||
}
|
||||
|
||||
type repositoryStub struct {
|
||||
dailyCalls int
|
||||
monthlyCalls int
|
||||
dailyItems []model.TrafficUsageDailyItem
|
||||
monthlyItems []model.TrafficUsageMonthlyItem
|
||||
dailyErr error
|
||||
monthlyErr error
|
||||
}
|
||||
|
||||
func (s *repositoryStub) UpdateDaily(_ context.Context, items []model.TrafficUsageDailyItem) error {
|
||||
s.dailyCalls++
|
||||
s.dailyItems = items
|
||||
return s.dailyErr
|
||||
}
|
||||
|
||||
func (s *repositoryStub) UpdateMonthly(_ context.Context, items []model.TrafficUsageMonthlyItem) error {
|
||||
s.monthlyCalls++
|
||||
s.monthlyItems = items
|
||||
return s.monthlyErr
|
||||
}
|
||||
|
||||
func TestRetryDailySuccess(t *testing.T) {
|
||||
upstream := &upstreamStub{
|
||||
dailyOutput: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
|
||||
{
|
||||
SimKey: &model.SimKey{ICCID: "89860001"},
|
||||
ResData: model.SimDataUsageDailyOutputItem{QueryDate: "20260714", DataAmount: 12.5},
|
||||
},
|
||||
},
|
||||
}
|
||||
repository := &repositoryStub{}
|
||||
service := New(upstream, repository)
|
||||
message := model.TrafficUsageDailyRetryMessage{
|
||||
SimKey: &model.SimKey{ICCID: "89860001"},
|
||||
Card: model.TrafficUsageSimCard{Iccid: "89860001", PackageCode: "PKG-1"},
|
||||
QueryDate: "20260714",
|
||||
}
|
||||
|
||||
if err := service.RetryDaily(context.Background(), message); err != nil {
|
||||
t.Fatalf("RetryDaily returned error: %v", err)
|
||||
}
|
||||
if upstream.dailyCalls != 1 || repository.dailyCalls != 1 {
|
||||
t.Fatalf("unexpected calls: upstream=%d repository=%d", upstream.dailyCalls, repository.dailyCalls)
|
||||
}
|
||||
if len(repository.dailyItems) != 1 {
|
||||
t.Fatalf("expected one daily item, got %d", len(repository.dailyItems))
|
||||
}
|
||||
item := repository.dailyItems[0]
|
||||
if item.Iccid != "89860001" || item.UsageMb != 12.5 || item.PackageCode != "PKG-1" {
|
||||
t.Fatalf("unexpected daily item: %+v", item)
|
||||
}
|
||||
if got := item.Date.Format("20060102"); got != "20260714" {
|
||||
t.Fatalf("unexpected daily date: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDailyPreservesRetryBehavior(t *testing.T) {
|
||||
t.Run("period mismatch", func(t *testing.T) {
|
||||
upstream := &upstreamStub{dailyOutput: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{{SimKey: &model.SimKey{ICCID: "89860001"}, ResData: model.SimDataUsageDailyOutputItem{QueryDate: "20260713", DataAmount: 1}}}}
|
||||
repository := &repositoryStub{}
|
||||
service := New(upstream, repository)
|
||||
if err := service.RetryDaily(context.Background(), validDailyMessage()); err == nil {
|
||||
t.Fatal("expected period mismatch failure")
|
||||
}
|
||||
if repository.dailyCalls != 0 {
|
||||
t.Fatal("period mismatch was persisted")
|
||||
}
|
||||
})
|
||||
t.Run("unsupported channel is permanent", func(t *testing.T) {
|
||||
upstream := &upstreamStub{dailyOutput: []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{{SimKey: &model.SimKey{ICCID: "89860001"}, Code: 1, Message: "daily usage query is not supported"}}}
|
||||
service := New(upstream, &repositoryStub{})
|
||||
err := service.RetryDaily(context.Background(), validDailyMessage())
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) || !failure.Permanent {
|
||||
t.Fatalf("expected permanent failure, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upstream failure", func(t *testing.T) {
|
||||
upstream := &upstreamStub{dailyErr: errors.New("upstream failed")}
|
||||
repository := &repositoryStub{}
|
||||
service := New(upstream, repository)
|
||||
|
||||
if err := service.RetryDaily(context.Background(), validDailyMessage()); err == nil {
|
||||
t.Fatal("expected retryable failure")
|
||||
}
|
||||
if upstream.dailyCalls != 1 || repository.dailyCalls != 0 {
|
||||
t.Fatalf("unexpected calls: upstream=%d repository=%d", upstream.dailyCalls, repository.dailyCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("repository failure", func(t *testing.T) {
|
||||
upstream := &upstreamStub{dailyOutput: validDailyOutput()}
|
||||
repository := &repositoryStub{dailyErr: errors.New("repository failed")}
|
||||
service := New(upstream, repository)
|
||||
|
||||
if err := service.RetryDaily(context.Background(), validDailyMessage()); err == nil {
|
||||
t.Fatal("expected persistence failure")
|
||||
}
|
||||
if upstream.dailyCalls != 1 || repository.dailyCalls != 1 {
|
||||
t.Fatalf("unexpected calls: upstream=%d repository=%d", upstream.dailyCalls, repository.dailyCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid date", func(t *testing.T) {
|
||||
upstream := &upstreamStub{}
|
||||
repository := &repositoryStub{}
|
||||
service := New(upstream, repository)
|
||||
message := validDailyMessage()
|
||||
message.QueryDate = "invalid"
|
||||
|
||||
if err := service.RetryDaily(context.Background(), message); err == nil {
|
||||
t.Fatal("expected invalid date failure")
|
||||
}
|
||||
if upstream.dailyCalls != 0 || repository.dailyCalls != 0 {
|
||||
t.Fatalf("invalid date reached dependencies")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRetryMonthlySuccessAndRetry(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
upstream := &upstreamStub{
|
||||
monthlyOutput: []model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{
|
||||
{
|
||||
SimKey: &model.SimKey{ICCID: "89860002"},
|
||||
ResData: model.SimDataUsageMonthlyOutputItem{DeadLine: "202607", DataAmount: 25.5},
|
||||
},
|
||||
},
|
||||
}
|
||||
repository := &repositoryStub{}
|
||||
service := New(upstream, repository)
|
||||
message := model.TrafficUsageMonthlyRetryMessage{
|
||||
SimKey: &model.SimKey{ICCID: "89860002"},
|
||||
Card: model.TrafficUsageSimCard{Iccid: "89860002", PackageCode: "PKG-2"},
|
||||
QueryDate: "202607",
|
||||
}
|
||||
|
||||
if err := service.RetryMonthly(context.Background(), message); err != nil {
|
||||
t.Fatalf("RetryMonthly returned error: %v", err)
|
||||
}
|
||||
if upstream.monthlyCalls != 1 || repository.monthlyCalls != 1 {
|
||||
t.Fatalf("unexpected calls: upstream=%d repository=%d", upstream.monthlyCalls, repository.monthlyCalls)
|
||||
}
|
||||
if len(repository.monthlyItems) != 1 {
|
||||
t.Fatalf("expected one monthly item, got %d", len(repository.monthlyItems))
|
||||
}
|
||||
item := repository.monthlyItems[0]
|
||||
if item.Iccid != "89860002" || item.UsageMb != 25.5 || item.PackageCode != "PKG-2" {
|
||||
t.Fatalf("unexpected monthly item: %+v", item)
|
||||
}
|
||||
if got := item.Month.Format("200601"); got != "202607" {
|
||||
t.Fatalf("unexpected month: %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upstream failure", func(t *testing.T) {
|
||||
upstream := &upstreamStub{monthlyErr: errors.New("upstream failed")}
|
||||
repository := &repositoryStub{}
|
||||
service := New(upstream, repository)
|
||||
message := model.TrafficUsageMonthlyRetryMessage{
|
||||
SimKey: &model.SimKey{ICCID: "89860002"},
|
||||
Card: model.TrafficUsageSimCard{Iccid: "89860002"},
|
||||
QueryDate: "202607",
|
||||
}
|
||||
|
||||
if err := service.RetryMonthly(context.Background(), message); err == nil {
|
||||
t.Fatal("expected retryable failure")
|
||||
}
|
||||
if upstream.monthlyCalls != 1 || repository.monthlyCalls != 0 {
|
||||
t.Fatalf("unexpected calls: upstream=%d repository=%d", upstream.monthlyCalls, repository.monthlyCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func validDailyMessage() model.TrafficUsageDailyRetryMessage {
|
||||
return model.TrafficUsageDailyRetryMessage{
|
||||
SimKey: &model.SimKey{ICCID: "89860001"},
|
||||
Card: model.TrafficUsageSimCard{Iccid: "89860001"},
|
||||
QueryDate: "20260714",
|
||||
}
|
||||
}
|
||||
|
||||
func validDailyOutput() []model.SimBaseRes[model.SimDataUsageDailyOutputItem] {
|
||||
return []model.SimBaseRes[model.SimDataUsageDailyOutputItem]{
|
||||
{
|
||||
SimKey: &model.SimKey{ICCID: "89860001"},
|
||||
ResData: model.SimDataUsageDailyOutputItem{QueryDate: "20260714", DataAmount: 1},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package trafficusage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPersistenceFailureRetainsSnapshot(t *testing.T) {
|
||||
upstream := &upstreamStub{dailyOutput: validDailyOutput()}
|
||||
repository := &repositoryStub{dailyErr: errors.New("database offline")}
|
||||
service := New(upstream, repository)
|
||||
message := validDailyMessage()
|
||||
var failure *Failure
|
||||
if err := service.RetryDaily(context.Background(), message); !errors.As(err, &failure) || failure.UsageMb == nil || *failure.UsageMb != 1 {
|
||||
t.Fatalf("snapshot missing: %v", err)
|
||||
}
|
||||
message.UsageMb = failure.UsageMb
|
||||
repository.dailyErr = nil
|
||||
if err := service.RetryDaily(context.Background(), message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.dailyCalls != 1 || repository.dailyCalls != 2 {
|
||||
t.Fatal("persistence retry queried upstream again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroSnapshotBypassesUpstream(t *testing.T) {
|
||||
upstream, repository := &upstreamStub{}, &repositoryStub{}
|
||||
service := New(upstream, repository)
|
||||
zero := 0.0
|
||||
message := validDailyMessage()
|
||||
message.UsageMb = &zero
|
||||
if err := service.RetryDaily(context.Background(), message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
monthly := model.TrafficUsageMonthlyRetryMessage{SimKey: message.SimKey, Card: message.Card, QueryDate: "202607", UsageMb: &zero}
|
||||
if err := service.RetryMonthly(context.Background(), monthly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.dailyCalls != 0 || upstream.monthlyCalls != 0 || repository.dailyCalls != 1 || repository.monthlyCalls != 1 {
|
||||
t.Fatal("zero snapshot was not persisted directly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectMismatchedOrMissingResult(t *testing.T) {
|
||||
for _, output := range [][]model.SimBaseRes[model.SimDataUsageDailyOutputItem]{nil, {{}}, {{SimKey: &model.SimKey{ICCID: "wrong"}}}, {validDailyOutput()[0], validDailyOutput()[0]}} {
|
||||
upstream, repository := &upstreamStub{dailyOutput: output}, &repositoryStub{}
|
||||
if err := New(upstream, repository).RetryDaily(context.Background(), validDailyMessage()); err == nil || repository.dailyCalls != 0 {
|
||||
t.Fatal("invalid result accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package unsub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/packagecustomer"
|
||||
)
|
||||
|
||||
const (
|
||||
cascadeFailureReason = "同一批次存在无效退订明细,该行已跳过"
|
||||
validationSkipReason = "批次校验未通过,已跳过退订请求"
|
||||
emptyICCIDReason = "ICCID不能为空"
|
||||
duplicateICCIDReason = "ICCID在批次内重复"
|
||||
)
|
||||
|
||||
type executor struct {
|
||||
repository Repository
|
||||
}
|
||||
|
||||
func NewExecutor(repository Repository) Executor {
|
||||
return &executor{repository: repository}
|
||||
}
|
||||
|
||||
func (e *executor) Execute(ctx context.Context, batchNo string) error {
|
||||
task, err := e.repository.Load(ctx, strings.TrimSpace(batchNo))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load unsub batch failed")
|
||||
}
|
||||
if !validateTask(task) {
|
||||
if err = e.repository.SaveValidationFailure(ctx, task); err != nil {
|
||||
return gerror.Wrap(err, "save unsub validation failure failed")
|
||||
}
|
||||
e.saveResultFile(ctx, task)
|
||||
return nil
|
||||
}
|
||||
if err = e.repository.CommitBusiness(ctx, task); err != nil {
|
||||
return gerror.Wrap(err, "commit unsub business failed")
|
||||
}
|
||||
packagecustomer.PublishSyncForDeptsSafe(ctx, task.Batch.TenantID, task.Batch.CreatedBy, unsubPackageSyncDeptIDs(task), "unsub", task.Batch.BatchNo)
|
||||
e.saveResultFile(ctx, task)
|
||||
return nil
|
||||
}
|
||||
|
||||
func unsubPackageSyncDeptIDs(task *Task) []int64 {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
deptIds := []int64{task.Batch.DeptID, task.Batch.CreatedDept}
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil || strings.TrimSpace(detail.Reason) != "" {
|
||||
continue
|
||||
}
|
||||
if detail.CardDeptID > 0 {
|
||||
deptIds = append(deptIds, detail.CardDeptID)
|
||||
}
|
||||
}
|
||||
return deptIds
|
||||
}
|
||||
|
||||
func (e *executor) saveResultFile(ctx context.Context, task *Task) {
|
||||
request := batchresult.Request{
|
||||
BatchNo: task.Batch.BatchNo,
|
||||
BatchType: task.Batch.BatchType,
|
||||
TenantID: task.Batch.TenantID,
|
||||
Rows: task.ResultRows(),
|
||||
}
|
||||
if err := e.repository.SaveResultFile(ctx, task, request); err != nil {
|
||||
g.Log().Errorf(ctx, "save unsub result file status failed, batchNo:%s, err:%+v", task.Batch.BatchNo, err)
|
||||
}
|
||||
}
|
||||
|
||||
func validateTask(task *Task) bool {
|
||||
if task == nil || len(task.Details) == 0 {
|
||||
if task != nil {
|
||||
task.FailureReason = "退订批次无有效明细"
|
||||
}
|
||||
return false
|
||||
}
|
||||
hasFailure := false
|
||||
iccids := make(map[string]int, len(task.Details))
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
hasFailure = true
|
||||
continue
|
||||
}
|
||||
if detail.Reason != "" {
|
||||
hasFailure = true
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(detail.ICCID)
|
||||
if iccid == "" {
|
||||
detail.Reason = emptyICCIDReason
|
||||
hasFailure = true
|
||||
continue
|
||||
}
|
||||
iccids[iccid]++
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
if detail.Reason != "" {
|
||||
continue
|
||||
}
|
||||
if iccids[strings.TrimSpace(detail.ICCID)] > 1 {
|
||||
detail.Reason = duplicateICCIDReason
|
||||
hasFailure = true
|
||||
}
|
||||
}
|
||||
if !hasFailure {
|
||||
return true
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && detail.Reason == "" {
|
||||
detail.Reason = cascadeFailureReason
|
||||
}
|
||||
}
|
||||
task.FailureReason = validationSkipReason
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package unsub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
func TestValidateTaskFailsWhenAnyDetailHasPreExistingErrorReason(t *testing.T) {
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "B-1"},
|
||||
Details: []*Detail{
|
||||
{RowNo: 1, ICCID: "8986"},
|
||||
{RowNo: 2, ICCID: "8987", Reason: "snapshot has reason"},
|
||||
},
|
||||
}
|
||||
if validateTask(task) {
|
||||
t.Fatal("expected validation to fail when any detail has a pre-existing reason")
|
||||
}
|
||||
if task.Details[0].Reason != cascadeFailureReason {
|
||||
t.Fatalf("expected cascade reason on clean detail, got %q", task.Details[0].Reason)
|
||||
}
|
||||
if task.FailureReason == "" {
|
||||
t.Fatal("expected task failure reason to be populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTaskFailsOnEmptyICCID(t *testing.T) {
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "B-2"},
|
||||
Details: []*Detail{
|
||||
{RowNo: 1, ICCID: ""},
|
||||
},
|
||||
}
|
||||
if validateTask(task) {
|
||||
t.Fatal("expected empty ICCID to fail validation")
|
||||
}
|
||||
if task.Details[0].Reason != emptyICCIDReason {
|
||||
t.Fatalf("expected empty ICCID reason, got %q", task.Details[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTaskFailsOnDuplicateICCID(t *testing.T) {
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "B-3"},
|
||||
Details: []*Detail{
|
||||
{RowNo: 1, ICCID: "8986"},
|
||||
{RowNo: 2, ICCID: "8986"},
|
||||
},
|
||||
}
|
||||
if validateTask(task) {
|
||||
t.Fatal("expected duplicate ICCID to fail validation")
|
||||
}
|
||||
if task.Details[0].Reason != duplicateICCIDReason || task.Details[1].Reason != duplicateICCIDReason {
|
||||
t.Fatalf("expected both duplicates flagged, got %q / %q", task.Details[0].Reason, task.Details[1].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTaskPassesOnCleanDetails(t *testing.T) {
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "B-4"},
|
||||
Details: []*Detail{
|
||||
{RowNo: 1, ICCID: "8986"},
|
||||
{RowNo: 2, ICCID: "8987"},
|
||||
},
|
||||
}
|
||||
if !validateTask(task) {
|
||||
t.Fatal("expected validation to pass for clean details")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHappyPathCommitsAndSavesResult(t *testing.T) {
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "B-5", BatchType: 108, TenantID: "000000"},
|
||||
Details: []*Detail{
|
||||
{RowNo: 1, ICCID: "8986"},
|
||||
},
|
||||
}
|
||||
repo := &fakeRepository{task: task}
|
||||
if err := NewExecutor(repo).Execute(context.Background(), "B-5"); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !repo.committed {
|
||||
t.Fatal("expected business to be committed")
|
||||
}
|
||||
if !repo.resultSaved {
|
||||
t.Fatal("expected result file to be saved")
|
||||
}
|
||||
if repo.validationSaved {
|
||||
t.Fatal("did not expect validation failure path on happy path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidationFailureSkipsCommit(t *testing.T) {
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "B-6", BatchType: 108},
|
||||
Details: []*Detail{
|
||||
{RowNo: 1, ICCID: "8986"},
|
||||
{RowNo: 2, ICCID: "8987", Reason: "snapshot error"},
|
||||
},
|
||||
}
|
||||
repo := &fakeRepository{task: task}
|
||||
if err := NewExecutor(repo).Execute(context.Background(), "B-6"); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !repo.validationSaved {
|
||||
t.Fatal("expected validation failure to be persisted")
|
||||
}
|
||||
if repo.committed {
|
||||
t.Fatal("did not expect business to be committed when validation fails")
|
||||
}
|
||||
if !repo.resultSaved {
|
||||
t.Fatal("expected result file to be saved even when validation fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteCommitFailurePropagatesAndSkipsResult(t *testing.T) {
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "B-7", BatchType: 108},
|
||||
Details: []*Detail{
|
||||
{RowNo: 1, ICCID: "8986"},
|
||||
},
|
||||
}
|
||||
commitErr := errors.New("transaction failed")
|
||||
repo := &fakeRepository{task: task, commitErr: commitErr}
|
||||
err := NewExecutor(repo).Execute(context.Background(), "B-7")
|
||||
if err == nil || !errors.Is(err, commitErr) {
|
||||
t.Fatalf("Execute() error = %v, want wrap of %v", err, commitErr)
|
||||
}
|
||||
if repo.validationSaved {
|
||||
t.Fatal("validation path should not run when commit failure happens before it")
|
||||
}
|
||||
if repo.resultSaved {
|
||||
t.Fatal("result file should not be saved when commit fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteResultFileFailureOnlyMarksBatchInfo(t *testing.T) {
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "B-8", BatchType: 108, TenantID: "000000"},
|
||||
Details: []*Detail{
|
||||
{RowNo: 1, ICCID: "8986"},
|
||||
},
|
||||
}
|
||||
repo := &fakeRepository{task: task, resultErr: errors.New("disk full")}
|
||||
if err := NewExecutor(repo).Execute(context.Background(), "B-8"); err != nil {
|
||||
t.Fatalf("Execute() error = %v, want nil when only result file fails", err)
|
||||
}
|
||||
if !repo.committed {
|
||||
t.Fatal("expected business to be committed before result file failure")
|
||||
}
|
||||
if !repo.resultSaved {
|
||||
t.Fatal("expected result file save attempt to be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRepository struct {
|
||||
task *Task
|
||||
committed bool
|
||||
commitErr error
|
||||
validationSaved bool
|
||||
resultSaved bool
|
||||
resultErr error
|
||||
loadErr error
|
||||
loadCalls int
|
||||
saveCalls int
|
||||
resultRequest batchresult.Request
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Load(_ context.Context, _ string) (*Task, error) {
|
||||
r.loadCalls++
|
||||
if r.loadErr != nil {
|
||||
return nil, r.loadErr
|
||||
}
|
||||
return r.task, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) SaveValidationFailure(_ context.Context, _ *Task) error {
|
||||
r.validationSaved = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) CommitBusiness(_ context.Context, _ *Task) error {
|
||||
r.committed = true
|
||||
return r.commitErr
|
||||
}
|
||||
|
||||
func (r *fakeRepository) SaveResultFile(_ context.Context, _ *Task, request batchresult.Request) error {
|
||||
r.saveCalls++
|
||||
r.resultRequest = request
|
||||
r.resultSaved = true
|
||||
return r.resultErr
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package unsub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
type Batch struct {
|
||||
ID int64
|
||||
BatchNo string
|
||||
BatchType int
|
||||
TenantID string
|
||||
DeptID int64
|
||||
CreatedBy int64
|
||||
CreatedDept int64
|
||||
StartTime *gtime.Time
|
||||
BatchInfo *gjson.Json
|
||||
SourceBatchNo string
|
||||
Remark string
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
ID int64
|
||||
RowNo int
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
Reason string
|
||||
|
||||
Authorized bool
|
||||
Remark string
|
||||
SourceBatchNo string
|
||||
|
||||
SubscriptionNo string
|
||||
OrderCode string
|
||||
PackageCode string
|
||||
PackageType int
|
||||
EffectiveTime string
|
||||
ExpectedExpireTime string
|
||||
ActualExpireTime string
|
||||
ReturnedToStock bool
|
||||
StockDeptID int64
|
||||
CardDeptID int64
|
||||
StockBatchNo string
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Batch Batch
|
||||
Details []*Detail
|
||||
FailureReason string
|
||||
}
|
||||
|
||||
type OrderRefund struct {
|
||||
Amount float64
|
||||
SerialNo string
|
||||
Status string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
Load(ctx context.Context, batchNo string) (*Task, error)
|
||||
SaveValidationFailure(ctx context.Context, task *Task) error
|
||||
CommitBusiness(ctx context.Context, task *Task) error
|
||||
SaveResultFile(ctx context.Context, task *Task, request batchresult.Request) error
|
||||
}
|
||||
|
||||
type Executor interface {
|
||||
Execute(ctx context.Context, batchNo string) error
|
||||
}
|
||||
|
||||
func (d *Detail) Snapshot() *gjson.Json {
|
||||
if d == nil {
|
||||
return gjson.New(nil)
|
||||
}
|
||||
return gjson.New(map[string]any{
|
||||
"rowNo": d.RowNo,
|
||||
"ICCID": d.ICCID,
|
||||
"MSISDN": d.MSISDN,
|
||||
"IMSI": d.IMSI,
|
||||
"authorized": d.Authorized,
|
||||
"remark": d.Remark,
|
||||
"sourceBatchNo": d.SourceBatchNo,
|
||||
"subscriptionNo": d.SubscriptionNo,
|
||||
"orderCode": d.OrderCode,
|
||||
"packageCode": d.PackageCode,
|
||||
"packageType": d.PackageType,
|
||||
"effectiveTime": d.EffectiveTime,
|
||||
"expectedExpireTime": d.ExpectedExpireTime,
|
||||
"actualExpireTime": d.ActualExpireTime,
|
||||
"returnedToStock": d.ReturnedToStock,
|
||||
"stockDeptId": d.StockDeptID,
|
||||
"cardDeptId": d.CardDeptID,
|
||||
"stockBatchNo": d.StockBatchNo,
|
||||
"errorReason": d.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
func (t *Task) ResultRows() []batchresult.Row {
|
||||
rows := make([]batchresult.Row, 0, len(t.Details))
|
||||
for _, detail := range t.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, batchresult.Row{
|
||||
RowNo: detail.RowNo,
|
||||
ICCID: detail.ICCID,
|
||||
MSISDN: detail.MSISDN,
|
||||
IMSI: detail.IMSI,
|
||||
Success: detail.Reason == "",
|
||||
Reason: detail.Reason,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func trimSnapshotString(value any) string {
|
||||
return strings.TrimSpace(toStringValue(value))
|
||||
}
|
||||
|
||||
func toStringValue(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v
|
||||
case *string:
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return *v
|
||||
case []byte:
|
||||
return string(v)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package warningconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
const warningConfigProcessingRetryAfter = 2 * time.Minute
|
||||
|
||||
type executor struct {
|
||||
repository *Repository
|
||||
}
|
||||
|
||||
func NewExecutor() *executor {
|
||||
return &executor{repository: NewRepository()}
|
||||
}
|
||||
|
||||
func (e *executor) CanExecuteProcessing(_ context.Context, main *entity.BatchMain) (bool, error) {
|
||||
if main == nil || main.BatchType != consts.BatchTypeSimWarningConfig || main.BatchAsyncStatus != consts.BatchAsyncStatusProcessing {
|
||||
return false, nil
|
||||
}
|
||||
if main.StartTime == nil || main.StartTime.IsZero() {
|
||||
return true, nil
|
||||
}
|
||||
return gtime.Now().Sub(main.StartTime) >= warningConfigProcessingRetryAfter, nil
|
||||
}
|
||||
|
||||
func (e *executor) Execute(ctx context.Context, batchNo string) error {
|
||||
task, err := e.repository.Load(ctx, strings.TrimSpace(batchNo))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load warning config batch failed")
|
||||
}
|
||||
validateTask(task)
|
||||
return gerror.Wrap(e.repository.Commit(ctx, task), "commit warning config batch failed")
|
||||
}
|
||||
|
||||
func validateTask(task *Task) {
|
||||
if task == nil || len(task.Details) == 0 {
|
||||
if task != nil {
|
||||
task.FailureReason = "预警配置批次没有可处理明细"
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
validateDetail(task, detail)
|
||||
}
|
||||
_, failCount := countResult(task)
|
||||
if failCount > 0 {
|
||||
markAtomicFailure(task, "预警配置批次存在失败明细,整批未执行")
|
||||
}
|
||||
}
|
||||
|
||||
func markAtomicFailure(task *Task, reason string) {
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
reason = "本批次存在失败明细,整批未执行"
|
||||
}
|
||||
task.FailureReason = reason
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && strings.TrimSpace(detail.Reason) == "" {
|
||||
detail.Reason = "本批次存在失败明细,整批未执行"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateDetail(task *Task, detail *Detail) {
|
||||
if detail == nil || detail.Reason != "" {
|
||||
return
|
||||
}
|
||||
config := detail.Config
|
||||
if config.ItemType == warnItemTypeFlowPool {
|
||||
validateFlowPoolDetail(config, detail)
|
||||
return
|
||||
}
|
||||
validateCardDetail(config, detail)
|
||||
}
|
||||
|
||||
func validateCardDetail(config Config, detail *Detail) {
|
||||
detail.ICCID = strings.TrimSpace(detail.ICCID)
|
||||
detail.MSISDN = strings.TrimSpace(detail.MSISDN)
|
||||
detail.IMSI = strings.TrimSpace(detail.IMSI)
|
||||
if detail.ICCID == "" {
|
||||
detail.Reason = "ICCID不能为空"
|
||||
return
|
||||
}
|
||||
if !detail.Authorized {
|
||||
detail.Reason = "ICCID不在当前操作员授权范围内"
|
||||
return
|
||||
}
|
||||
if detail.Card == nil {
|
||||
detail.Reason = "ICCID不存在或不属于当前租户"
|
||||
return
|
||||
}
|
||||
if detail.MSISDN != "" && detail.MSISDN != detail.Card.MSISDN {
|
||||
detail.Reason = "MSISDN与卡档案不一致"
|
||||
return
|
||||
}
|
||||
if detail.IMSI != "" && detail.IMSI != detail.Card.IMSI {
|
||||
detail.Reason = "IMSI与卡档案不一致"
|
||||
return
|
||||
}
|
||||
if detail.Card.BossCardStatus == consts.SimBossCardStatusStopped {
|
||||
detail.Reason = "卡已停机,不能配置预警"
|
||||
return
|
||||
}
|
||||
if detail.Card.BossCardStatus != consts.SimBossCardStatusActivated {
|
||||
detail.Reason = "当前卡状态为" + bossCardStatusText(detail.Card.BossCardStatus) + ",只有已激活卡可以配置预警"
|
||||
return
|
||||
}
|
||||
if detail.Card.PackageStatus != packageStatusEffective {
|
||||
detail.Reason = "套餐未生效,不能配置预警"
|
||||
return
|
||||
}
|
||||
if detail.Card.IsFlowPool != 0 {
|
||||
detail.Reason = "流量池卡暂不支持单卡预警配置"
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(detail.Card.CurrentPackageCode) == "" {
|
||||
detail.Reason = "当前套餐编号为空,不能配置预警"
|
||||
return
|
||||
}
|
||||
switch config.WarnType {
|
||||
case warnTypeCardTraffic:
|
||||
validateTrafficDetail(config, detail)
|
||||
case warnTypeExpireDay, warnTypeExpireMonth:
|
||||
if detail.Card.FlowPkgEndAt == nil || detail.Card.FlowPkgEndAt.IsZero() {
|
||||
detail.Reason = "套餐到期时间为空,不能配置到期预警"
|
||||
}
|
||||
default:
|
||||
detail.Reason = "预警类型无效"
|
||||
}
|
||||
}
|
||||
|
||||
func validateFlowPoolDetail(config Config, detail *Detail) {
|
||||
detail.FlowPoolCode = strings.TrimSpace(detail.FlowPoolCode)
|
||||
if detail.FlowPoolCode == "" {
|
||||
detail.Reason = "流量池编号不能为空"
|
||||
return
|
||||
}
|
||||
if !detail.Authorized {
|
||||
detail.Reason = "流量池不在当前操作员授权范围内"
|
||||
return
|
||||
}
|
||||
if detail.Pool == nil {
|
||||
detail.Reason = "流量池不存在或不属于当前租户"
|
||||
return
|
||||
}
|
||||
if detail.Pool.PoolStatus != flowPoolStatusNormal {
|
||||
detail.Reason = "流量池状态不是正常,不能配置预警"
|
||||
return
|
||||
}
|
||||
if detail.Pool.TotalFlowMb <= 0 {
|
||||
detail.Reason = "流量池总流量为空,不能配置预警"
|
||||
return
|
||||
}
|
||||
if config.WarnType != warnTypeFlowPoolPercent || config.WarnUnit != warnUnitPercent {
|
||||
detail.Reason = "流量池预警仅支持百分比配置"
|
||||
return
|
||||
}
|
||||
if config.AutoAction != 0 {
|
||||
detail.Reason = "流量池预警暂不支持第三次关停"
|
||||
}
|
||||
}
|
||||
|
||||
func validateTrafficDetail(config Config, detail *Detail) {
|
||||
if detail.Card.PackageTotalFlowMb <= 0 {
|
||||
detail.Reason = "套餐总流量为空,不能配置流量预警"
|
||||
return
|
||||
}
|
||||
if config.WarnUnit != warnUnitMb {
|
||||
return
|
||||
}
|
||||
total := detail.Card.PackageTotalFlowMb
|
||||
if config.FirstWarn > total || config.SecondWarn > total || config.ThirdWarn > total {
|
||||
detail.Reason = "流量阈值不能大于套餐总流量"
|
||||
}
|
||||
}
|
||||
|
||||
func countResult(task *Task) (successCount, failCount int) {
|
||||
if task == nil {
|
||||
return 0, 0
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && detail.Success() {
|
||||
successCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
return successCount, failCount
|
||||
}
|
||||
|
||||
func bossCardStatusText(status int) string {
|
||||
switch status {
|
||||
case consts.SimBossCardStatusActivated:
|
||||
return "已激活"
|
||||
case consts.SimBossCardStatusStopped:
|
||||
return "已停机"
|
||||
case 1:
|
||||
return "待激活"
|
||||
default:
|
||||
return "未知状态"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package warningconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestCanExecuteProcessingAllowsStaleWarningConfigBatch(t *testing.T) {
|
||||
executor := &executor{}
|
||||
main := &entity.BatchMain{
|
||||
BatchType: consts.BatchTypeSimWarningConfig,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
StartTime: gtime.Now().Add(-warningConfigProcessingRetryAfter - time.Second),
|
||||
}
|
||||
|
||||
canExecute, err := executor.CanExecuteProcessing(context.Background(), main)
|
||||
if err != nil || !canExecute {
|
||||
t.Fatalf("CanExecuteProcessing() = %t, %v; want true, nil", canExecute, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFromDetailSnapshotUsesRowValues(t *testing.T) {
|
||||
fallback := Config{
|
||||
ItemType: warnItemTypeCard,
|
||||
WarnUnit: warnUnitMb,
|
||||
WarnMethods: warnDefaultMethod,
|
||||
IsEnabled: 1,
|
||||
}
|
||||
snapshot := gjson.New(map[string]any{
|
||||
"configSet": true,
|
||||
"configItemType": warnItemTypeCard,
|
||||
"warnUnit": warnUnitMb,
|
||||
"firstWarn": 100,
|
||||
"secondWarn": 500,
|
||||
"thirdWarn": 900,
|
||||
"warnMethods": "3,1,0",
|
||||
"warnPhones": "13800008000",
|
||||
"warnEmails": "xxx@example.com",
|
||||
"autoAction": warnAutoActionSuspend,
|
||||
"isEnabled": 1,
|
||||
"remark": "row config",
|
||||
})
|
||||
|
||||
got := configFromDetailSnapshot(snapshot, fallback)
|
||||
if got.FirstWarn != 100 || got.SecondWarn != 500 || got.ThirdWarn != 900 {
|
||||
t.Fatalf("unexpected thresholds: %#v", got)
|
||||
}
|
||||
if got.WarnMethods != "3,1,0" || got.AutoAction != warnAutoActionSuspend {
|
||||
t.Fatalf("unexpected row notification config: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanExecuteProcessingSkipsFreshOrDifferentBatch(t *testing.T) {
|
||||
executor := &executor{}
|
||||
tests := []struct {
|
||||
name string
|
||||
main *entity.BatchMain
|
||||
}{
|
||||
{
|
||||
name: "fresh processing batch",
|
||||
main: &entity.BatchMain{
|
||||
BatchType: consts.BatchTypeSimWarningConfig,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
StartTime: gtime.Now(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "different batch type",
|
||||
main: &entity.BatchMain{
|
||||
BatchType: consts.BatchTypeSimCardStart,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
StartTime: gtime.Now().Add(-warningConfigProcessingRetryAfter - time.Second),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pending warning config batch",
|
||||
main: &entity.BatchMain{
|
||||
BatchType: consts.BatchTypeSimWarningConfig,
|
||||
BatchAsyncStatus: 1,
|
||||
StartTime: gtime.Now().Add(-warningConfigProcessingRetryAfter - time.Second),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
canExecute, err := executor.CanExecuteProcessing(context.Background(), tt.main)
|
||||
if err != nil || canExecute {
|
||||
t.Fatalf("CanExecuteProcessing() = %t, %v; want false, nil", canExecute, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTaskMarksWholeBatchFailed(t *testing.T) {
|
||||
task := &Task{
|
||||
Details: []*Detail{
|
||||
{ICCID: "8986000000000000001"},
|
||||
{ICCID: "8986000000000000002", Reason: "卡不存在"},
|
||||
},
|
||||
}
|
||||
|
||||
validateTask(task)
|
||||
|
||||
for _, detail := range task.Details {
|
||||
if detail.Success() {
|
||||
t.Fatalf("detail %s remained successful after atomic failure", detail.ICCID)
|
||||
}
|
||||
}
|
||||
if task.FailureReason == "" {
|
||||
t.Fatal("expected batch failure reason")
|
||||
}
|
||||
status, result := batchResult(countResult(task))
|
||||
if status != consts.BatchAsyncStatusFailed || result != consts.BatchRunResultFailed {
|
||||
t.Fatalf("batchResult() = %d, %d; want failed, failed", status, result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package warningconfig
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNotificationFieldPresenceSurvivesSnapshot(t *testing.T) {
|
||||
for _, supplied := range []bool{false, true} {
|
||||
data := map[string]any{"itemType": 1, "warnUnit": 1, "firstWarn": 100, "isEnabled": 1}
|
||||
if supplied {
|
||||
data["appNotifyEnabled"] = 0
|
||||
data["wxTemplateId"] = ""
|
||||
}
|
||||
config, err := configFromBatchInfo(gjson.New(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.AppNotifyProvided != supplied || config.WxTemplateProvided != supplied {
|
||||
t.Fatalf("presence lost: %+v", config)
|
||||
}
|
||||
detail := &Detail{Config: config}
|
||||
snapshot := detail.Snapshot(config)
|
||||
restored := configFromDetailSnapshot(snapshot, Config{})
|
||||
if restored.AppNotifyProvided != supplied || restored.WxTemplateProvided != supplied {
|
||||
t.Fatalf("snapshot presence lost: %+v", restored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSnapshotPreservesFallbackWhenAbsent(t *testing.T) {
|
||||
fallback := Config{AppNotifyEnabled: 1, AppNotifyProvided: true, WxTemplateID: "existing", WxTemplateProvided: true}
|
||||
restored := configFromDetailSnapshot(gjson.New(map[string]any{"configSet": true, "warnUnit": 1}), fallback)
|
||||
if restored.AppNotifyEnabled != 1 || restored.WxTemplateID != "existing" {
|
||||
t.Fatalf("fallback overwritten: %+v", restored)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
package warningconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
const packageStatusEffective = consts.PackageStatusEffective
|
||||
|
||||
type Repository struct {
|
||||
db gdb.DB
|
||||
results batchresult.Generator
|
||||
}
|
||||
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{results: batchresult.New()}
|
||||
}
|
||||
|
||||
func (r *Repository) database() gdb.DB {
|
||||
if r.db != nil {
|
||||
return r.db
|
||||
}
|
||||
return g.DB()
|
||||
}
|
||||
|
||||
func (r *Repository) Load(ctx context.Context, batchNo string) (*Task, error) {
|
||||
if batchNo == "" {
|
||||
return nil, gerror.New("预警配置批次号不能为空")
|
||||
}
|
||||
var main *entity.BatchMain
|
||||
if err := r.database().Model(dao.BatchMain.Table()).Safe().Ctx(ctx).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeSimWarningConfig).
|
||||
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
|
||||
Scan(&main); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询预警配置批次主表失败")
|
||||
}
|
||||
if main == nil || main.Id == 0 {
|
||||
return nil, gerror.New("处理中预警配置批次不存在")
|
||||
}
|
||||
config, err := configFromBatchInfo(main.BatchInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var batchDetails []*entity.BatchDetail
|
||||
if err = r.database().Model(dao.BatchDetail.Table()).Safe().Ctx(ctx).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeSimWarningConfig).
|
||||
OrderAsc(dao.BatchDetail.Columns().RowNo).
|
||||
Scan(&batchDetails); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询预警配置批次明细失败")
|
||||
}
|
||||
if len(batchDetails) == 0 {
|
||||
return nil, gerror.New("预警配置批次没有运行中明细")
|
||||
}
|
||||
|
||||
task := &Task{
|
||||
Batch: Batch{
|
||||
ID: main.Id,
|
||||
BatchNo: main.BatchNo,
|
||||
BatchType: main.BatchType,
|
||||
TenantID: main.TenantId,
|
||||
CreatedDept: main.CreatedDept,
|
||||
CreatedBy: main.CreatedBy,
|
||||
StartTime: main.StartTime,
|
||||
BatchInfo: main.BatchInfo,
|
||||
},
|
||||
Config: config,
|
||||
Details: make([]*Detail, 0, len(batchDetails)),
|
||||
}
|
||||
for _, batchDetail := range batchDetails {
|
||||
if batchDetail == nil {
|
||||
continue
|
||||
}
|
||||
detail := &Detail{
|
||||
ID: batchDetail.Id,
|
||||
RowNo: batchDetail.RowNo,
|
||||
DetailStatus: batchDetail.DetailStatus,
|
||||
ItemType: config.ItemType,
|
||||
Reason: strings.TrimSpace(batchDetail.ErrorReason),
|
||||
Config: config,
|
||||
}
|
||||
if detail.DetailStatus != consts.BatchDetailStatusPending &&
|
||||
detail.DetailStatus != consts.BatchDetailStatusRunning &&
|
||||
detail.Reason == "" {
|
||||
detail.Reason = "批次明细已处理,不能重复执行"
|
||||
}
|
||||
if config.ItemType == warnItemTypeFlowPool {
|
||||
detail.FlowPoolCode = strings.TrimSpace(batchDetail.ItemId)
|
||||
} else {
|
||||
detail.ICCID = strings.TrimSpace(batchDetail.ItemId)
|
||||
}
|
||||
if batchDetail.Snapshot != nil {
|
||||
if value := batchDetail.Snapshot.Get("itemType").Int(); value > 0 {
|
||||
detail.ItemType = value
|
||||
}
|
||||
if value := strings.TrimSpace(batchDetail.Snapshot.Get("ICCID").String()); value != "" {
|
||||
detail.ICCID = value
|
||||
}
|
||||
detail.MSISDN = strings.TrimSpace(batchDetail.Snapshot.Get("MSISDN").String())
|
||||
detail.IMSI = strings.TrimSpace(batchDetail.Snapshot.Get("IMSI").String())
|
||||
if value := strings.TrimSpace(batchDetail.Snapshot.Get("flowPoolCode").String()); value != "" {
|
||||
detail.FlowPoolCode = value
|
||||
}
|
||||
detail.Authorized = batchDetail.Snapshot.Get("authorized").Bool()
|
||||
detail.Config = configFromDetailSnapshot(batchDetail.Snapshot, config)
|
||||
}
|
||||
task.Details = append(task.Details, detail)
|
||||
}
|
||||
if len(task.Details) == 0 {
|
||||
return nil, gerror.New("预警配置批次没有可读取明细")
|
||||
}
|
||||
if err = r.loadItems(ctx, task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (r *Repository) loadItems(ctx context.Context, task *Task) error {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
if task.Config.ItemType == warnItemTypeFlowPool {
|
||||
return r.loadFlowPools(ctx, task)
|
||||
}
|
||||
return r.loadCards(ctx, task)
|
||||
}
|
||||
|
||||
func (r *Repository) loadCards(ctx context.Context, task *Task) error {
|
||||
iccids := make([]string, 0, len(task.Details))
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && strings.TrimSpace(detail.ICCID) != "" {
|
||||
iccids = append(iccids, strings.TrimSpace(detail.ICCID))
|
||||
}
|
||||
}
|
||||
if len(iccids) == 0 {
|
||||
return nil
|
||||
}
|
||||
var cards []*entity.TSimCardInfo
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
if err := r.database().Model(dao.TSimCardInfo.Table()).Safe().Ctx(ctx).
|
||||
Where(columns.TenantId, task.Batch.TenantID).
|
||||
WhereIn(columns.Iccid, iccids).
|
||||
Scan(&cards); err != nil {
|
||||
return gerror.Wrap(err, "查询SIM卡档案失败")
|
||||
}
|
||||
byICCID := make(map[string]*entity.TSimCardInfo, len(cards))
|
||||
for _, card := range cards {
|
||||
if card != nil {
|
||||
byICCID[strings.TrimSpace(card.Iccid)] = card
|
||||
}
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
card := byICCID[strings.TrimSpace(detail.ICCID)]
|
||||
if card == nil {
|
||||
continue
|
||||
}
|
||||
detail.Card = &Card{
|
||||
ID: card.Id,
|
||||
ICCID: strings.TrimSpace(card.Iccid),
|
||||
MSISDN: strings.TrimSpace(card.Msisdn),
|
||||
IMSI: strings.TrimSpace(card.Imsi),
|
||||
CurrentPackageCode: strings.TrimSpace(card.CurrentPackageCode),
|
||||
PackageStatus: card.PackageStatus,
|
||||
BossCardStatus: card.BossCardStatus,
|
||||
IsFlowPool: card.IsFlowPool,
|
||||
FlowPkgEndAt: card.FlowPkgEndAt,
|
||||
PackageTotalFlowMb: card.PackageTotalFlowMb,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) loadFlowPools(ctx context.Context, task *Task) error {
|
||||
codes := make([]string, 0, len(task.Details))
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && strings.TrimSpace(detail.FlowPoolCode) != "" {
|
||||
codes = append(codes, strings.TrimSpace(detail.FlowPoolCode))
|
||||
}
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
return nil
|
||||
}
|
||||
var pools []*entity.TSimFlowPool
|
||||
columns := dao.TSimFlowPool.Columns()
|
||||
if err := r.database().Model(dao.TSimFlowPool.Table()).Safe().Ctx(ctx).
|
||||
Fields(
|
||||
columns.Id,
|
||||
columns.FlowPoolCode,
|
||||
columns.TotalFlowMb,
|
||||
columns.UsedFlowMb,
|
||||
columns.RemainFlowMb,
|
||||
columns.PoolStatus,
|
||||
).
|
||||
Where(columns.TenantId, task.Batch.TenantID).
|
||||
WhereIn(columns.FlowPoolCode, codes).
|
||||
Scan(&pools); err != nil {
|
||||
return gerror.Wrap(err, "查询流量池档案失败")
|
||||
}
|
||||
byCode := make(map[string]*entity.TSimFlowPool, len(pools))
|
||||
for _, pool := range pools {
|
||||
if pool != nil {
|
||||
byCode[strings.TrimSpace(pool.FlowPoolCode)] = pool
|
||||
}
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
pool := byCode[strings.TrimSpace(detail.FlowPoolCode)]
|
||||
if pool == nil {
|
||||
continue
|
||||
}
|
||||
detail.Pool = &Pool{
|
||||
ID: pool.Id,
|
||||
FlowPoolCode: strings.TrimSpace(pool.FlowPoolCode),
|
||||
TotalFlowMb: pool.TotalFlowMb,
|
||||
UsedFlowMb: pool.UsedFlowMb,
|
||||
RemainFlowMb: pool.RemainFlowMb,
|
||||
PoolStatus: pool.PoolStatus,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) Commit(ctx context.Context, task *Task) error {
|
||||
if task == nil {
|
||||
return gerror.New("预警配置任务不能为空")
|
||||
}
|
||||
|
||||
if _, failCount := countResult(task); failCount > 0 {
|
||||
markAtomicFailure(task, "预警配置批次存在失败明细,整批未执行")
|
||||
}
|
||||
successCount, failCount := countResult(task)
|
||||
artifact, artifactErr := r.createResultFile(ctx, task)
|
||||
if artifactErr != nil {
|
||||
g.Log().Warningf(ctx, "warning config result file generation failed, batchNo:%s, err:%+v", task.Batch.BatchNo, artifactErr)
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
err := r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
if detail.Success() {
|
||||
if err := r.upsertWarningConfig(ctx, tx, task, detail, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := r.updateDetail(ctx, tx, task, detail, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var ossID, downloadID int64
|
||||
if artifactErr == nil {
|
||||
var err error
|
||||
ossID, downloadID, err = r.persistArtifact(ctx, tx, task, artifact, successCount, failCount, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return r.finalizeBatch(ctx, tx, task, successCount, failCount, ossID, downloadID, now)
|
||||
})
|
||||
if err != nil && artifactErr == nil && artifact.Path != "" {
|
||||
batchresult.RemoveLocalFile(artifact)
|
||||
}
|
||||
return gerror.Wrap(err, "保存预警配置批次结果失败")
|
||||
}
|
||||
|
||||
func (r *Repository) upsertWarningConfig(ctx context.Context, tx gdb.TX, task *Task, detail *Detail, now *gtime.Time) error {
|
||||
itemID, itemCode, err := warningConfigItemSnapshot(task, detail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config := detail.Config
|
||||
columns := dao.TWarnConfig.Columns()
|
||||
excludeFields := "id,tenant_id,item_type,item_id,warn_unit,created_dept,created_by,created_at"
|
||||
if !config.AppNotifyProvided {
|
||||
excludeFields += ",app_notify_enabled"
|
||||
}
|
||||
if !config.WxTemplateProvided {
|
||||
excludeFields += ",wx_template_id"
|
||||
}
|
||||
_, err = tx.Ctx(ctx).Model(dao.TWarnConfig.Table()).Safe().
|
||||
Data(do.TWarnConfig{
|
||||
TenantId: task.Batch.TenantID,
|
||||
BatchNo: task.Batch.BatchNo,
|
||||
ItemType: config.ItemType,
|
||||
ItemId: itemID,
|
||||
ItemCode: itemCode,
|
||||
WarnUnit: config.WarnUnit,
|
||||
FirstWarn: config.FirstWarn,
|
||||
SecondWarn: config.SecondWarn,
|
||||
ThirdWarn: config.ThirdWarn,
|
||||
WarnMethods: config.WarnMethods,
|
||||
WarnPhones: config.WarnPhones,
|
||||
WarnEmails: config.WarnEmails,
|
||||
AutoAction: config.AutoAction,
|
||||
IsEnabled: config.IsEnabled,
|
||||
WxTemplateId: config.WxTemplateID,
|
||||
AppNotifyEnabled: config.AppNotifyEnabled,
|
||||
Remark: config.Remark,
|
||||
CreatedDept: task.Batch.CreatedDept,
|
||||
CreatedBy: task.Batch.CreatedBy,
|
||||
CreatedAt: now,
|
||||
UpdatedBy: task.Batch.CreatedBy,
|
||||
UpdatedAt: now,
|
||||
}).
|
||||
OnConflict(columns.TenantId, columns.ItemType, columns.ItemId, columns.WarnUnit).
|
||||
OnDuplicateEx(excludeFields).
|
||||
Save()
|
||||
return gerror.Wrap(err, "写入预警配置失败")
|
||||
}
|
||||
|
||||
func warningConfigItemSnapshot(task *Task, detail *Detail) (int64, string, error) {
|
||||
if task == nil || detail == nil {
|
||||
return 0, "", gerror.New("预警配置明细为空")
|
||||
}
|
||||
if detail.Config.ItemType == warnItemTypeFlowPool {
|
||||
if detail.Pool == nil {
|
||||
return 0, "", gerror.New("预警配置明细缺少流量池档案")
|
||||
}
|
||||
itemCode := strings.TrimSpace(detail.Pool.FlowPoolCode)
|
||||
if itemCode == "" {
|
||||
itemCode = strings.TrimSpace(detail.FlowPoolCode)
|
||||
}
|
||||
return detail.Pool.ID, itemCode, nil
|
||||
}
|
||||
if detail.Card == nil {
|
||||
return 0, "", gerror.New("预警配置明细缺少SIM卡档案")
|
||||
}
|
||||
itemCode := strings.TrimSpace(detail.Card.ICCID)
|
||||
if itemCode == "" {
|
||||
itemCode = strings.TrimSpace(detail.ICCID)
|
||||
}
|
||||
return detail.Card.ID, itemCode, nil
|
||||
}
|
||||
|
||||
func (r *Repository) updateDetail(ctx context.Context, tx gdb.TX, task *Task, detail *Detail, now *gtime.Time) error {
|
||||
if detail == nil ||
|
||||
(detail.DetailStatus != consts.BatchDetailStatusPending &&
|
||||
detail.DetailStatus != consts.BatchDetailStatusRunning) {
|
||||
return nil
|
||||
}
|
||||
status := consts.BatchDetailStatusFailed
|
||||
if detail.Success() {
|
||||
status = consts.BatchDetailStatusSuccess
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().Id, detail.ID).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeSimWarningConfig).
|
||||
WhereIn(dao.BatchDetail.Columns().DetailStatus, []int{
|
||||
consts.BatchDetailStatusPending,
|
||||
consts.BatchDetailStatusRunning,
|
||||
}).
|
||||
Data(do.BatchDetail{
|
||||
DetailStatus: status,
|
||||
ErrorReason: detail.Reason,
|
||||
Snapshot: detail.Snapshot(detail.Config),
|
||||
UpdatedBy: task.Batch.CreatedBy,
|
||||
UpdatedAt: now,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "更新预警配置批次明细失败")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return gerror.Newf("预警配置批次明细%d已不是运行中状态", detail.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) finalizeBatch(ctx context.Context, tx gdb.TX, task *Task, successCount, failCount int, ossID, downloadID int64, now *gtime.Time) error {
|
||||
batchAsyncStatus, runResult := batchResult(successCount, failCount)
|
||||
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, task.Batch.BatchNo).
|
||||
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeSimWarningConfig).
|
||||
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
|
||||
Data(do.BatchMain{
|
||||
BatchAsyncStatus: batchAsyncStatus,
|
||||
RunResult: runResult,
|
||||
TotalCount: len(task.Details),
|
||||
ValidCount: successCount,
|
||||
SuccessCount: successCount,
|
||||
FailCount: failCount,
|
||||
FailReason: task.FailureReason,
|
||||
BatchInfo: mergeBatchInfo(task.Batch.BatchInfo, ossID, downloadID),
|
||||
EndTime: now,
|
||||
UpdatedBy: task.Batch.CreatedBy,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "更新预警配置批次主表失败")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return gerror.New("预警配置批次已不是处理中状态")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) createResultFile(ctx context.Context, task *Task) (batchresult.Artifact, error) {
|
||||
if r.results == nil {
|
||||
return batchresult.Artifact{}, gerror.New("result generator is not configured")
|
||||
}
|
||||
return r.results.Create(ctx, batchresult.Request{
|
||||
BatchNo: task.Batch.BatchNo,
|
||||
BatchType: task.Batch.BatchType,
|
||||
TenantID: task.Batch.TenantID,
|
||||
Rows: task.ResultRows(),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) persistArtifact(ctx context.Context, tx gdb.TX, task *Task, artifact batchresult.Artifact, successCount, failCount int, now *gtime.Time) (int64, int64, error) {
|
||||
ossID, err := tx.Ctx(ctx).Model(dao.SysOss.Table()).Data(do.SysOss{
|
||||
TenantId: task.Batch.TenantID,
|
||||
FileName: artifact.FileName,
|
||||
OriginalName: artifact.OriginalName,
|
||||
FileSuffix: artifact.FileSuffix,
|
||||
Path: artifact.Path,
|
||||
Url: artifact.URL,
|
||||
CreatedDept: task.Batch.CreatedDept,
|
||||
CreatedBy: task.Batch.CreatedBy,
|
||||
UpdatedBy: task.Batch.CreatedBy,
|
||||
Service: "local",
|
||||
Md5: artifact.MD5,
|
||||
FileSize: artifact.Size,
|
||||
}).InsertAndGetId()
|
||||
if err != nil {
|
||||
return 0, 0, gerror.Wrap(err, "写入预警配置结果文件OSS失败")
|
||||
}
|
||||
downloadID, err := tx.Ctx(ctx).Model(dao.TResourceBatchDownload.Table()).Data(do.TResourceBatchDownload{
|
||||
BatchNo: task.Batch.BatchNo,
|
||||
TaskName: batchresult.TaskName(task.Batch.BatchType),
|
||||
ResourceType: consts.ResourceTypeExcel,
|
||||
ExportScene: consts.BatchTypeSimWarningConfig,
|
||||
TotalCount: len(task.Details),
|
||||
SuccessCount: successCount,
|
||||
FailCount: failCount,
|
||||
DownloadStatus: consts.DownloadStatusGenerated,
|
||||
OssId: ossID,
|
||||
FileName: artifact.FileName,
|
||||
FileExt: artifact.FileSuffix,
|
||||
FileUrl: artifact.URL,
|
||||
FilePath: artifact.Path,
|
||||
FileSize: artifact.Size,
|
||||
FileMd5: artifact.MD5,
|
||||
DownloadCount: 0,
|
||||
MaxDownloadCount: 0,
|
||||
DeptId: task.Batch.CreatedDept,
|
||||
StartTime: task.Batch.StartTime,
|
||||
FinishTime: now,
|
||||
TenantId: task.Batch.TenantID,
|
||||
CreatedDept: task.Batch.CreatedDept,
|
||||
CreatedBy: task.Batch.CreatedBy,
|
||||
UpdatedBy: task.Batch.CreatedBy,
|
||||
}).InsertAndGetId()
|
||||
if err != nil {
|
||||
return 0, 0, gerror.Wrap(err, "写入预警配置结果下载记录失败")
|
||||
}
|
||||
return ossID, downloadID, nil
|
||||
}
|
||||
|
||||
func configFromBatchInfo(info *gjson.Json) (Config, error) {
|
||||
if info == nil {
|
||||
return Config{}, gerror.New("预警配置批次缺少批次参数")
|
||||
}
|
||||
config := Config{
|
||||
ItemType: info.Get("itemType").Int(),
|
||||
WarnUnit: info.Get("warnUnit").Int(),
|
||||
FirstWarn: info.Get("firstWarn").Float64(),
|
||||
SecondWarn: info.Get("secondWarn").Float64(),
|
||||
ThirdWarn: info.Get("thirdWarn").Float64(),
|
||||
WarnMethods: strings.TrimSpace(info.Get("warnMethods").String()),
|
||||
WarnPhones: strings.TrimSpace(info.Get("warnPhones").String()),
|
||||
WarnEmails: strings.TrimSpace(info.Get("warnEmails").String()),
|
||||
AutoAction: info.Get("autoAction").Int(),
|
||||
IsEnabled: info.Get("isEnabled").Int(),
|
||||
WxTemplateID: strings.TrimSpace(info.Get("wxTemplateId").String()),
|
||||
AppNotifyEnabled: info.Get("appNotifyEnabled").Int(),
|
||||
AppNotifyProvided: !info.Get("appNotifyEnabled").IsNil(),
|
||||
WxTemplateProvided: !info.Get("wxTemplateId").IsNil(),
|
||||
Remark: strings.TrimSpace(info.Get("remark").String()),
|
||||
}
|
||||
if config.WarnMethods == "" {
|
||||
config.WarnMethods = warnDefaultMethod
|
||||
}
|
||||
if config.ItemType == 0 {
|
||||
config.ItemType = warnItemTypeCard
|
||||
}
|
||||
config.WarnType = deriveWarnType(config.ItemType, config.WarnUnit)
|
||||
if config.ItemType != warnItemTypeCard && config.ItemType != warnItemTypeFlowPool {
|
||||
return Config{}, gerror.New("预警配置对象类型无效")
|
||||
}
|
||||
if config.WarnUnit == warnUnitDay || config.WarnUnit == warnUnitMonth {
|
||||
return Config{}, gerror.New("日期预警由系统默认规则处理,不支持手动配置")
|
||||
}
|
||||
if config.AppNotifyEnabled != 0 && config.AppNotifyEnabled != 1 {
|
||||
return Config{}, gerror.New("微信提醒启停状态无效")
|
||||
}
|
||||
if info.Get("rowConfig").Bool() {
|
||||
if err := validateWarnTypeUnit(config.ItemType, config.WarnType, config.WarnUnit); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
if config.IsEnabled != 0 && config.IsEnabled != 1 {
|
||||
return Config{}, gerror.New("预警配置启停状态无效")
|
||||
}
|
||||
if config.AutoAction != 0 && config.AutoAction != warnAutoActionSuspend {
|
||||
return Config{}, gerror.New("预警配置自动动作无效")
|
||||
}
|
||||
if err := validateWarnTypeUnit(config.ItemType, config.WarnType, config.WarnUnit); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if config.ItemType == warnItemTypeFlowPool && config.AutoAction != 0 {
|
||||
return Config{}, gerror.New("流量池预警暂不支持第三次关停")
|
||||
}
|
||||
if err := validateConfigThresholds(&config); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func configFromDetailSnapshot(snapshot *gjson.Json, fallback Config) Config {
|
||||
if snapshot == nil {
|
||||
return fallback
|
||||
}
|
||||
if !snapshot.Get("configSet").Bool() &&
|
||||
snapshot.Get("configItemType").Int() == 0 &&
|
||||
snapshot.Get("warnUnit").Int() == 0 {
|
||||
return fallback
|
||||
}
|
||||
config := fallback
|
||||
if value := snapshot.Get("configItemType").Int(); value > 0 {
|
||||
config.ItemType = value
|
||||
}
|
||||
if value := snapshot.Get("warnUnit").Int(); value > 0 {
|
||||
config.WarnUnit = value
|
||||
}
|
||||
config.FirstWarn = snapshot.Get("firstWarn").Float64()
|
||||
config.SecondWarn = snapshot.Get("secondWarn").Float64()
|
||||
config.ThirdWarn = snapshot.Get("thirdWarn").Float64()
|
||||
if value := strings.TrimSpace(snapshot.Get("warnMethods").String()); value != "" {
|
||||
config.WarnMethods = value
|
||||
}
|
||||
config.WarnPhones = strings.TrimSpace(snapshot.Get("warnPhones").String())
|
||||
config.WarnEmails = strings.TrimSpace(snapshot.Get("warnEmails").String())
|
||||
config.AutoAction = snapshot.Get("autoAction").Int()
|
||||
if snapshot.Get("isEnabled").Val() != nil {
|
||||
config.IsEnabled = snapshot.Get("isEnabled").Int()
|
||||
}
|
||||
if !snapshot.Get("wxTemplateId").IsNil() {
|
||||
config.WxTemplateID = strings.TrimSpace(snapshot.Get("wxTemplateId").String())
|
||||
config.WxTemplateProvided = true
|
||||
}
|
||||
if snapshot.Get("appNotifyEnabled").Val() != nil {
|
||||
config.AppNotifyEnabled = snapshot.Get("appNotifyEnabled").Int()
|
||||
config.AppNotifyProvided = true
|
||||
}
|
||||
config.Remark = strings.TrimSpace(snapshot.Get("remark").String())
|
||||
config.WarnType = deriveWarnType(config.ItemType, config.WarnUnit)
|
||||
return config
|
||||
}
|
||||
|
||||
func validateConfigThresholds(config *Config) error {
|
||||
if config == nil {
|
||||
return gerror.New("预警配置参数不能为空")
|
||||
}
|
||||
if config.FirstWarn <= 0 {
|
||||
return gerror.New("第一档阈值必须大于0")
|
||||
}
|
||||
if config.SecondWarn < 0 || config.ThirdWarn < 0 {
|
||||
return gerror.New("预警阈值不能小于0")
|
||||
}
|
||||
if config.AutoAction == warnAutoActionSuspend && config.ThirdWarn <= 0 {
|
||||
return gerror.New("开启第三次自动停机时必须配置第三档阈值")
|
||||
}
|
||||
switch config.WarnType {
|
||||
case warnTypeCardTraffic:
|
||||
if config.WarnUnit == warnUnitPercent {
|
||||
config.FirstWarn = normalizePercentThreshold(config.FirstWarn)
|
||||
config.SecondWarn = normalizePercentThreshold(config.SecondWarn)
|
||||
config.ThirdWarn = normalizePercentThreshold(config.ThirdWarn)
|
||||
if config.FirstWarn > 100 || config.SecondWarn > 100 || config.ThirdWarn > 100 {
|
||||
return gerror.New("百分比阈值不能大于100")
|
||||
}
|
||||
}
|
||||
return validateGreaterThresholds(config)
|
||||
case warnTypeFlowPoolPercent:
|
||||
config.FirstWarn = normalizePercentThreshold(config.FirstWarn)
|
||||
config.SecondWarn = normalizePercentThreshold(config.SecondWarn)
|
||||
config.ThirdWarn = normalizePercentThreshold(config.ThirdWarn)
|
||||
if config.FirstWarn > 100 || config.SecondWarn > 100 || config.ThirdWarn > 100 {
|
||||
return gerror.New("百分比阈值不能大于100")
|
||||
}
|
||||
return validateGreaterThresholds(config)
|
||||
default:
|
||||
return gerror.New("预警类型无效")
|
||||
}
|
||||
}
|
||||
|
||||
func validateGreaterThresholds(config *Config) error {
|
||||
if config.SecondWarn > 0 && config.SecondWarn <= config.FirstWarn {
|
||||
return gerror.New("流量预警第二档阈值必须大于第一档")
|
||||
}
|
||||
if config.ThirdWarn > 0 {
|
||||
if config.SecondWarn <= 0 {
|
||||
return gerror.New("配置第三档时必须先配置第二档")
|
||||
}
|
||||
if config.ThirdWarn <= config.SecondWarn {
|
||||
return gerror.New("流量预警第三档阈值必须大于第二档")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizePercentThreshold(value float64) float64 {
|
||||
if value > 0 && value <= 1 {
|
||||
return value * 100
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func deriveWarnType(itemType, warnUnit int) int {
|
||||
if itemType == warnItemTypeFlowPool {
|
||||
return warnTypeFlowPoolPercent
|
||||
}
|
||||
switch warnUnit {
|
||||
case warnUnitMb, warnUnitPercent:
|
||||
return warnTypeCardTraffic
|
||||
case warnUnitDay:
|
||||
return warnTypeExpireDay
|
||||
case warnUnitMonth:
|
||||
return warnTypeExpireMonth
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func validateWarnTypeUnit(itemType, warnType, warnUnit int) error {
|
||||
switch warnType {
|
||||
case warnTypeCardTraffic:
|
||||
if itemType != warnItemTypeCard {
|
||||
return gerror.New("单卡流量预警只能配置单卡对象")
|
||||
}
|
||||
if warnUnit != warnUnitMb && warnUnit != warnUnitPercent {
|
||||
return gerror.New("单卡流量预警仅支持MB或百分比单位")
|
||||
}
|
||||
case warnTypeExpireDay:
|
||||
return gerror.New("日期预警由系统默认规则处理,不支持手动配置")
|
||||
case warnTypeExpireMonth:
|
||||
return gerror.New("日期预警由系统默认规则处理,不支持手动配置")
|
||||
case warnTypeFlowPoolPercent:
|
||||
if itemType != warnItemTypeFlowPool {
|
||||
return gerror.New("流量池预警只能配置流量池对象")
|
||||
}
|
||||
if warnUnit != warnUnitPercent {
|
||||
return gerror.New("流量池预警仅支持百分比单位")
|
||||
}
|
||||
default:
|
||||
return gerror.New("预警类型无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeBatchInfo(info *gjson.Json, ossID, downloadID int64) *gjson.Json {
|
||||
data := make(map[string]any)
|
||||
if info != nil {
|
||||
for key, value := range info.Map() {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
if ossID > 0 {
|
||||
data["resultFileOssId"] = ossID
|
||||
}
|
||||
if downloadID > 0 {
|
||||
data["resultDownloadId"] = downloadID
|
||||
data["resultFile"] = map[string]any{"status": "generated"}
|
||||
} else {
|
||||
data["resultFile"] = map[string]any{"status": "skipped"}
|
||||
}
|
||||
return gjson.New(data)
|
||||
}
|
||||
|
||||
func batchResult(successCount, failCount int) (int, int) {
|
||||
if failCount > 0 || successCount == 0 {
|
||||
return consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed
|
||||
}
|
||||
return consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package warningconfig
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
const (
|
||||
warnItemTypeCard = 1
|
||||
warnItemTypeFlowPool = 2
|
||||
|
||||
warnUnitMb = 1
|
||||
warnUnitPercent = 2
|
||||
warnUnitDay = 3
|
||||
warnUnitMonth = 4
|
||||
|
||||
warnTypeCardTraffic = 1
|
||||
warnTypeExpireDay = 2
|
||||
warnTypeExpireMonth = 3
|
||||
warnTypeFlowPoolPercent = 4
|
||||
|
||||
warnAutoActionSuspend = 3
|
||||
warnDefaultMethod = "3"
|
||||
|
||||
flowPoolStatusNormal = 0
|
||||
)
|
||||
|
||||
type Batch struct {
|
||||
ID int64
|
||||
BatchNo string
|
||||
BatchType int
|
||||
TenantID string
|
||||
CreatedDept int64
|
||||
CreatedBy int64
|
||||
StartTime *gtime.Time
|
||||
BatchInfo *gjson.Json
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ItemType int
|
||||
WarnType int
|
||||
WarnUnit int
|
||||
FirstWarn float64
|
||||
SecondWarn float64
|
||||
ThirdWarn float64
|
||||
WarnMethods string
|
||||
WarnPhones string
|
||||
WarnEmails string
|
||||
AutoAction int
|
||||
IsEnabled int
|
||||
WxTemplateID string
|
||||
AppNotifyEnabled int
|
||||
AppNotifyProvided bool
|
||||
WxTemplateProvided bool
|
||||
Remark string
|
||||
}
|
||||
|
||||
type Card struct {
|
||||
ID int64
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
CurrentPackageCode string
|
||||
PackageStatus int
|
||||
BossCardStatus int
|
||||
IsFlowPool int
|
||||
FlowPkgEndAt *gtime.Time
|
||||
PackageTotalFlowMb float64
|
||||
}
|
||||
|
||||
type Pool struct {
|
||||
ID int64
|
||||
FlowPoolCode string
|
||||
TotalFlowMb float64
|
||||
UsedFlowMb float64
|
||||
RemainFlowMb float64
|
||||
PoolStatus int
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
ID int64
|
||||
RowNo int
|
||||
DetailStatus int
|
||||
ItemType int
|
||||
ICCID string
|
||||
MSISDN string
|
||||
IMSI string
|
||||
FlowPoolCode string
|
||||
Authorized bool
|
||||
Card *Card
|
||||
Pool *Pool
|
||||
Reason string
|
||||
Config Config
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Batch Batch
|
||||
Config Config
|
||||
Details []*Detail
|
||||
FailureReason string
|
||||
}
|
||||
|
||||
func (d *Detail) Success() bool {
|
||||
return d != nil && strings.TrimSpace(d.Reason) == ""
|
||||
}
|
||||
|
||||
func (d *Detail) DisplayCode() string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
if d.ItemType == warnItemTypeFlowPool {
|
||||
if d.Pool != nil && strings.TrimSpace(d.Pool.FlowPoolCode) != "" {
|
||||
return strings.TrimSpace(d.Pool.FlowPoolCode)
|
||||
}
|
||||
return strings.TrimSpace(d.FlowPoolCode)
|
||||
}
|
||||
if d.Card != nil && strings.TrimSpace(d.Card.ICCID) != "" {
|
||||
return strings.TrimSpace(d.Card.ICCID)
|
||||
}
|
||||
return strings.TrimSpace(d.ICCID)
|
||||
}
|
||||
|
||||
func (d *Detail) Snapshot(config Config) *gjson.Json {
|
||||
if d == nil {
|
||||
return gjson.New(nil)
|
||||
}
|
||||
data := map[string]any{
|
||||
"rowNo": d.RowNo,
|
||||
"itemType": d.ItemType,
|
||||
"ICCID": d.ICCID,
|
||||
"MSISDN": d.MSISDN,
|
||||
"IMSI": d.IMSI,
|
||||
"flowPoolCode": d.FlowPoolCode,
|
||||
"authorized": d.Authorized,
|
||||
"configItemType": config.ItemType,
|
||||
"configSet": true,
|
||||
"warnUnit": config.WarnUnit,
|
||||
"firstWarn": config.FirstWarn,
|
||||
"secondWarn": config.SecondWarn,
|
||||
"thirdWarn": config.ThirdWarn,
|
||||
"warnMethods": config.WarnMethods,
|
||||
"warnPhones": config.WarnPhones,
|
||||
"warnEmails": config.WarnEmails,
|
||||
"autoAction": config.AutoAction,
|
||||
"isEnabled": config.IsEnabled,
|
||||
"remark": config.Remark,
|
||||
"errorReason": d.Reason,
|
||||
}
|
||||
if config.WxTemplateProvided {
|
||||
data["wxTemplateId"] = config.WxTemplateID
|
||||
}
|
||||
if config.AppNotifyProvided {
|
||||
data["appNotifyEnabled"] = config.AppNotifyEnabled
|
||||
}
|
||||
if d.Card != nil {
|
||||
data["cardId"] = d.Card.ID
|
||||
data["cardIccid"] = d.Card.ICCID
|
||||
data["cardMsisdn"] = d.Card.MSISDN
|
||||
data["cardImsi"] = d.Card.IMSI
|
||||
data["currentPackageCode"] = d.Card.CurrentPackageCode
|
||||
data["packageStatus"] = d.Card.PackageStatus
|
||||
data["bossCardStatus"] = d.Card.BossCardStatus
|
||||
data["isFlowPool"] = d.Card.IsFlowPool
|
||||
data["packageTotalFlowMb"] = d.Card.PackageTotalFlowMb
|
||||
if d.Card.FlowPkgEndAt != nil {
|
||||
data["flowPkgEndAt"] = d.Card.FlowPkgEndAt.String()
|
||||
}
|
||||
}
|
||||
if d.Pool != nil {
|
||||
data["poolId"] = d.Pool.ID
|
||||
data["poolFlowPoolCode"] = d.Pool.FlowPoolCode
|
||||
data["poolTotalFlowMb"] = d.Pool.TotalFlowMb
|
||||
data["poolUsedFlowMb"] = d.Pool.UsedFlowMb
|
||||
data["poolRemainFlowMb"] = d.Pool.RemainFlowMb
|
||||
data["poolStatus"] = d.Pool.PoolStatus
|
||||
}
|
||||
return gjson.New(data)
|
||||
}
|
||||
|
||||
func (t *Task) ResultRows() []batchresult.Row {
|
||||
rows := make([]batchresult.Row, 0, len(t.Details))
|
||||
for _, detail := range t.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, batchresult.Row{
|
||||
RowNo: detail.RowNo,
|
||||
ObjectCode: detail.DisplayCode(),
|
||||
ICCID: detail.ICCID,
|
||||
MSISDN: detail.MSISDN,
|
||||
IMSI: detail.IMSI,
|
||||
Success: detail.Success(),
|
||||
Reason: detail.Reason,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package workorder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func saveAutoCloseNotice(ctx context.Context, tx gdb.TX, current *entity.TWoMain) error {
|
||||
if current.CreatorUserId <= 0 {
|
||||
return nil
|
||||
}
|
||||
id, err := dao.TMessageLog.Ctx(ctx).TX(tx).Data(do.TMessageLog{MessageType: 1, SendUserType: current.CreatorType, UserIds: gjson.New([]int64{current.CreatorUserId}), MessageTitle: "工单已自动完结", MessageContent: "工单解决后72小时未收到未解决反馈,现已自动完结。您可以查看记录并评价服务。", MessageLink: "/pages/business/views/workOrderDetail/index?woNo=" + url.QueryEscape(current.WoNo), SendTime: gtime.Now(), IsRecall: 0, TenantId: current.TenantId, CreatedDept: current.CreatorDeptId, CreatedBy: 0}).InsertAndGetId()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "保存自动完结通知失败")
|
||||
}
|
||||
_, err = dao.TUserMessage.Ctx(ctx).TX(tx).Data(do.TUserMessage{MessageLogId: id, UserId: current.CreatorUserId, IsRead: 0, TenantId: current.TenantId, CreatedDept: current.CreatorDeptId, CreatedBy: 0}).Insert()
|
||||
return gerror.Wrap(err, "保存自动完结用户通知失败")
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package workorder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
const (
|
||||
statusResolved = 3
|
||||
statusClosed = 4
|
||||
operationClose = 4
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func New() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) AutoCloseResolved(ctx context.Context, message model.WorkOrderAutoCloseMessage) error {
|
||||
if message.WoNo == "" {
|
||||
return nil
|
||||
}
|
||||
g.Log().Infof(ctx, "work order auto close received, woNo:%s", message.WoNo)
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
current := entity.TWoMain{}
|
||||
if err := tx.Ctx(ctx).Model(dao.TWoMain.Table()).
|
||||
Where(dao.TWoMain.Columns().WoNo, message.WoNo).
|
||||
Scan(¤t); err != nil {
|
||||
return gerror.Wrap(err, "query work order failed")
|
||||
}
|
||||
if current.WoNo == "" || current.CurrentStatus != statusResolved {
|
||||
return nil
|
||||
}
|
||||
if current.ResolveTime == nil || current.ResolveTime.Add(72*time.Hour).After(gtime.Now()) {
|
||||
return nil
|
||||
}
|
||||
if message.ResolveTimeTs > 0 && (current.ResolveTime == nil || current.ResolveTime.Timestamp() != message.ResolveTimeTs) {
|
||||
g.Log().Infof(ctx, "work order auto close skipped, resolve time changed, woNo:%s, messageResolveTime:%d", message.WoNo, message.ResolveTimeTs)
|
||||
return nil
|
||||
}
|
||||
|
||||
operatorID := int64(0)
|
||||
now := gtime.Now()
|
||||
result, err := tx.Ctx(ctx).Model(dao.TWoMain.Table()).
|
||||
Where(dao.TWoMain.Columns().Id, current.Id).
|
||||
Where(dao.TWoMain.Columns().TenantId, current.TenantId).
|
||||
Where(dao.TWoMain.Columns().ResolveTime, current.ResolveTime).
|
||||
Where(dao.TWoMain.Columns().CurrentStatus, statusResolved).
|
||||
Data(do.TWoMain{
|
||||
CurrentStatus: statusClosed,
|
||||
CloseTime: now,
|
||||
UpdatedBy: operatorID,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "auto close work order failed")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil || affected == 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Ctx(ctx).Model(dao.TWoRecord.Table()).Data(do.TWoRecord{
|
||||
WoNo: message.WoNo,
|
||||
OperationType: operationClose,
|
||||
OperationDeptId: current.HandlerDeptId,
|
||||
OperationUserId: operatorID,
|
||||
BeforeStatus: statusResolved,
|
||||
AfterStatus: statusClosed,
|
||||
TenantId: current.TenantId,
|
||||
CreatedDept: current.CreatedDept,
|
||||
CreatedBy: operatorID,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "保存自动完结记录失败")
|
||||
}
|
||||
return saveAutoCloseNotice(ctx, tx, ¤t)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) CloseOverdue(ctx context.Context) error {
|
||||
var rows []*entity.TWoMain
|
||||
if err := dao.TWoMain.Ctx(ctx).Where(dao.TWoMain.Columns().CurrentStatus, statusResolved).WhereLTE(dao.TWoMain.Columns().ResolveTime, gtime.Now().Add(-72*time.Hour)).OrderAsc(dao.TWoMain.Columns().ResolveTime).Limit(100).Scan(&rows); err != nil {
|
||||
return gerror.Wrap(err, "查询到期工单失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row == nil || row.ResolveTime == nil {
|
||||
continue
|
||||
}
|
||||
if err := s.AutoCloseResolved(ctx, model.WorkOrderAutoCloseMessage{WoNo: row.WoNo, ResolveTimeTs: row.ResolveTime.Timestamp()}); err != nil {
|
||||
g.Log().Errorf(ctx, "工单补偿关闭失败 woNo:%s err:%v", row.WoNo, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package workorder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
g.Log().SetWriter(io.Discard)
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll("resource")
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestAutoCloseResolved(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := gdb.SetConfig(gdb.Config{
|
||||
"default": gdb.ConfigGroup{
|
||||
{
|
||||
Type: "sqlite",
|
||||
Name: filepath.Join(t.TempDir(), "workorder.db"),
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("configure test database: %v", err)
|
||||
}
|
||||
defer g.DB().Close(ctx)
|
||||
|
||||
createWorkOrderTables(t, ctx)
|
||||
service := New()
|
||||
|
||||
t.Run("resolved order is closed once", func(t *testing.T) {
|
||||
insertWorkOrder(t, ctx, "WO-RESOLVED", statusResolved)
|
||||
message := model.WorkOrderAutoCloseMessage{WoNo: "WO-RESOLVED"}
|
||||
if err := service.AutoCloseResolved(ctx, message); err != nil {
|
||||
t.Fatalf("AutoCloseResolved returned error: %v", err)
|
||||
}
|
||||
if status := workOrderStatus(t, ctx, message.WoNo); status != statusClosed {
|
||||
t.Fatalf("expected closed status, got %d", status)
|
||||
}
|
||||
if count := workOrderRecordCount(t, ctx, message.WoNo); count != 1 {
|
||||
t.Fatalf("expected one close record, got %d", count)
|
||||
}
|
||||
|
||||
if err := service.AutoCloseResolved(ctx, message); err != nil {
|
||||
t.Fatalf("duplicate AutoCloseResolved returned error: %v", err)
|
||||
}
|
||||
if count := workOrderRecordCount(t, ctx, message.WoNo); count != 1 {
|
||||
t.Fatalf("duplicate message added records, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-resolved order is ignored", func(t *testing.T) {
|
||||
insertWorkOrder(t, ctx, "WO-PROCESSING", 2)
|
||||
if err := service.AutoCloseResolved(ctx, model.WorkOrderAutoCloseMessage{WoNo: "WO-PROCESSING"}); err != nil {
|
||||
t.Fatalf("AutoCloseResolved returned error: %v", err)
|
||||
}
|
||||
if status := workOrderStatus(t, ctx, "WO-PROCESSING"); status != 2 {
|
||||
t.Fatalf("non-resolved order changed status to %d", status)
|
||||
}
|
||||
if count := workOrderRecordCount(t, ctx, "WO-PROCESSING"); count != 0 {
|
||||
t.Fatalf("non-resolved order added %d records", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale resolve message is ignored", func(t *testing.T) {
|
||||
const woNo = "WO-STALE-MESSAGE"
|
||||
insertWorkOrder(t, ctx, woNo, statusResolved)
|
||||
resolveTime := gtime.Now().Add(-73 * time.Hour)
|
||||
if _, err := g.DB().Model("t_wo_main").Ctx(ctx).Where("wo_no", woNo).Data(g.Map{"resolve_time": resolveTime}).Update(); err != nil {
|
||||
t.Fatalf("set resolve time: %v", err)
|
||||
}
|
||||
if err := service.AutoCloseResolved(ctx, model.WorkOrderAutoCloseMessage{WoNo: woNo, ResolveTimeTs: resolveTime.Timestamp() - 1}); err != nil {
|
||||
t.Fatalf("AutoCloseResolved returned error: %v", err)
|
||||
}
|
||||
if status := workOrderStatus(t, ctx, woNo); status != statusResolved {
|
||||
t.Fatalf("stale message changed status to %d", status)
|
||||
}
|
||||
if count := workOrderRecordCount(t, ctx, woNo); count != 0 {
|
||||
t.Fatalf("stale message added %d records", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("record failure rolls back status update", func(t *testing.T) {
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE t_message_log (message_log_id INTEGER PRIMARY KEY AUTOINCREMENT, message_type INTEGER, send_user_type INTEGER, user_ids TEXT, message_title TEXT, message_content TEXT, message_link TEXT, send_time DATETIME, is_recall INTEGER, tenant_id TEXT, created_dept INTEGER, created_by INTEGER, created_at DATETIME, updated_at DATETIME)`,
|
||||
`CREATE TABLE t_user_message (message_id INTEGER PRIMARY KEY AUTOINCREMENT, message_log_id INTEGER, user_id INTEGER, is_read INTEGER, tenant_id TEXT, created_dept INTEGER, created_by INTEGER, created_at DATETIME, updated_at DATETIME)`,
|
||||
} {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
insertWorkOrder(t, ctx, "WO-NOTICE", statusResolved)
|
||||
if _, err := g.DB().Exec(ctx, `UPDATE t_wo_main SET creator_user_id = 88, creator_dept_id = 30, creator_type = 1 WHERE wo_no = ?`, "WO-NOTICE"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err := service.AutoCloseResolved(ctx, model.WorkOrderAutoCloseMessage{WoNo: "WO-NOTICE"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
count, err := g.DB().Model("t_user_message").Ctx(ctx).Where("user_id", 88).Where("tenant_id", "tenant").Count()
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("expected one tenant-scoped notice, count=%d err=%v", count, err)
|
||||
}
|
||||
insertWorkOrder(t, ctx, "WO-NOTICE-ROLLBACK", statusResolved)
|
||||
if _, err := g.DB().Exec(ctx, `UPDATE t_wo_main SET creator_user_id = 88 WHERE wo_no = ?`, "WO-NOTICE-ROLLBACK"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, `DROP TABLE t_user_message`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.AutoCloseResolved(ctx, model.WorkOrderAutoCloseMessage{WoNo: "WO-NOTICE-ROLLBACK"}); err == nil {
|
||||
t.Fatal("expected notice failure")
|
||||
}
|
||||
if status := workOrderStatus(t, ctx, "WO-NOTICE-ROLLBACK"); status != statusResolved {
|
||||
t.Fatalf("notice failure did not roll back: %d", status)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, `UPDATE t_wo_main SET creator_user_id = 0 WHERE wo_no = ?`, "WO-NOTICE-ROLLBACK"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"WO-NO-TIME", "WO-EARLY"} {
|
||||
insertWorkOrder(t, ctx, name, statusResolved)
|
||||
var resolveTime *gtime.Time
|
||||
if name == "WO-EARLY" {
|
||||
resolveTime = gtime.Now()
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, `UPDATE t_wo_main SET resolve_time = ? WHERE wo_no = ?`, resolveTime, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.AutoCloseResolved(ctx, model.WorkOrderAutoCloseMessage{WoNo: name}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status := workOrderStatus(t, ctx, name); status != statusResolved {
|
||||
t.Fatalf("premature close: %s status=%d", name, status)
|
||||
}
|
||||
}
|
||||
insertWorkOrder(t, ctx, "WO-SCAN", statusResolved)
|
||||
if err := service.CloseOverdue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status := workOrderStatus(t, ctx, "WO-SCAN"); status != statusClosed {
|
||||
t.Fatalf("overdue scan did not close order: %d", status)
|
||||
}
|
||||
insertWorkOrder(t, ctx, "WO-ROLLBACK", statusResolved)
|
||||
if _, err := g.DB().Exec(ctx, `DROP TABLE t_wo_record`); err != nil {
|
||||
t.Fatalf("drop record table: %v", err)
|
||||
}
|
||||
if err := service.AutoCloseResolved(ctx, model.WorkOrderAutoCloseMessage{WoNo: "WO-ROLLBACK"}); err == nil {
|
||||
t.Fatal("expected record insert error")
|
||||
}
|
||||
if status := workOrderStatus(t, ctx, "WO-ROLLBACK"); status != statusResolved {
|
||||
t.Fatalf("transaction did not roll back, status=%d", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func createWorkOrderTables(t *testing.T, ctx context.Context) {
|
||||
t.Helper()
|
||||
statements := []string{
|
||||
`CREATE TABLE t_wo_main (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wo_no TEXT NOT NULL UNIQUE,
|
||||
creator_user_id INTEGER,
|
||||
creator_dept_id INTEGER,
|
||||
creator_type INTEGER,
|
||||
current_status INTEGER NOT NULL,
|
||||
resolve_time DATETIME,
|
||||
handler_dept_id INTEGER,
|
||||
handler_user_id INTEGER,
|
||||
tenant_id TEXT,
|
||||
created_dept INTEGER,
|
||||
created_by INTEGER,
|
||||
updated_by INTEGER,
|
||||
close_time DATETIME,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE t_wo_record (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wo_no TEXT NOT NULL,
|
||||
operation_type INTEGER,
|
||||
operation_dept_id INTEGER,
|
||||
operation_user_id INTEGER,
|
||||
before_status INTEGER,
|
||||
after_status INTEGER,
|
||||
tenant_id TEXT,
|
||||
created_dept INTEGER,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatalf("create test table: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func insertWorkOrder(t *testing.T, ctx context.Context, woNo string, status int) {
|
||||
t.Helper()
|
||||
_, err := g.DB().Exec(ctx, `
|
||||
INSERT INTO t_wo_main (
|
||||
wo_no, current_status, handler_dept_id, handler_user_id,
|
||||
tenant_id, created_dept, created_by, updated_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, woNo, status, 10, 20, "tenant", 30, 40, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("insert work order: %v", err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, `UPDATE t_wo_main SET resolve_time = ? WHERE wo_no = ?`, gtime.Now().Add(-73*time.Hour), woNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func workOrderStatus(t *testing.T, ctx context.Context, woNo string) int {
|
||||
t.Helper()
|
||||
value, err := g.DB().Model("t_wo_main").Ctx(ctx).Where("wo_no", woNo).Value("current_status")
|
||||
if err != nil {
|
||||
t.Fatalf("query work order status: %v", err)
|
||||
}
|
||||
return value.Int()
|
||||
}
|
||||
|
||||
func workOrderRecordCount(t *testing.T, ctx context.Context, woNo string) int {
|
||||
t.Helper()
|
||||
count, err := g.DB().Model("t_wo_record").Ctx(ctx).Where("wo_no", woNo).Count()
|
||||
if err != nil {
|
||||
t.Fatalf("count work order records: %v", err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
Reference in New Issue
Block a user