chore: 初始开源发布

iotlcm-worker-svr:异步执行中枢(RabbitMQ 消费者 + 全量同步编排)。

- Go module:iotlcm-worker-svr → git.5iot.com/iotlcm-public/iotlcm-worker-svr(214 文件)
- 凭据与内部标识脱敏:MySQL/Redis 凭据、私网与生产 IP、内网域名、
  库名 xiujie_admin → iotlcm、哨兵名占位
- 159 处真实手机号 → 合法 11 位编造号(保留号段与后 4 位,维持互异,
  不破坏「校验 MSISDN 格式」等测试断言)
- 移除内部资产:CI 触发器、Jenkins 触发测试文档、预演门禁与镜像
  digest 记录
- Dockerfile 改用公共基础镜像(golang:1.25-alpine → alpine:3.23),
  移除写死的 git 凭据
- README 重写(架构/全量同步模型/配置/已知失败),补 Apache-2.0
  LICENSE,LF 行尾 + .gitattributes

已知:3 个测试包在源仓同一提交上同样失败(client/upstream、
service/allocate、service/inbound),属既有缺陷,本仓未引入亦未修复。
This commit is contained in:
MiFaZhan
2026-10-22 16:37:56 +08:00
commit 2bec257289
361 changed files with 71524 additions and 0 deletions
+398
View File
@@ -0,0 +1,398 @@
package data
import (
"context"
"sort"
"strconv"
"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 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")
}
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")
}
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 err := e.saveIdempotentCutoffSkips(ctx, task); err != nil {
return err
}
return e.executeValidated(ctx, task)
}
func (e *executor) executeValidated(ctx context.Context, task *Task) error {
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: "data service is already disabled; upstream request was skipped",
})
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 {
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 = "same batch contains validation failure; upstream request was skipped"
}
}
task.FailureReason = "batch validation failed; upstream request was skipped"
return false
}
func validateTrafficCutoffTask(task *Task) bool {
if task == nil || len(task.Details) == 0 {
if task != nil {
task.FailureReason = "traffic cutoff batch has no valid details"
}
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) + " SIM cards failed validation"
} else {
task.FailureReason = ""
}
return true
}
task.FailureReason = "traffic cutoff batch validation failed; upstream request was skipped"
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 does not exist in SIM card records"
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 is outside the current operator authorization scope"
return
}
if detail.ChanKey == "" {
detail.Reason = "ICCID is missing chanKey"
return
}
if detail.InputMSISDN != "" && detail.InputMSISDN != detail.MSISDN {
detail.Reason = "MSISDN does not match the SIM card record"
return
}
if detail.InputIMSI != "" && detail.InputIMSI != detail.IMSI {
detail.Reason = "IMSI does not match the SIM card record"
return
}
}
func groupByChanKey(operation Operation, details []*Detail) map[string][]*Detail {
groups := make(map[string][]*Detail)
for _, detail := range details {
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 = "upstream request failed: " + 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: "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.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)
}
}
+524
View File
@@ -0,0 +1,524 @@
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 is outside the current operator authorization scope" {
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
}
+147
View File
@@ -0,0 +1,147 @@
package data
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
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
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 {
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)
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(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
}
type Upstream interface {
ChangeSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
}
type Executor interface {
Execute(ctx context.Context, batchNo string) error
}