chore: 并入 iotlcm-worker-svr 历史到 worker-svr/(原提交 96f0f4e)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:03 +08:00
422 changed files with 80993 additions and 0 deletions
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
}
+117
View File
@@ -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)
}
}
+431
View File
@@ -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, &notSent) && 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("原始原因被覆盖")
}
}
+211
View File
@@ -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)
}
+360
View File
@@ -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, &copy)
}
}
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(&current); 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
}
+142
View File
@@ -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(&current); 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, &current)
})
}
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
}