sync: 同步 v1.0.4 并脱敏配置与镜像

This commit is contained in:
MiFaZhan
2026-10-29 14:36:49 +08:00
parent 743e8e6e6e
commit 2206a393f7
516 changed files with 18119 additions and 2253 deletions
@@ -5,6 +5,7 @@ import (
"sort"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
@@ -135,7 +136,7 @@ func validateTask(task *Task, cards map[string]*entity.TSimCardInfo) bool {
if hasFailure {
for _, row := range task.Rows {
if row != nil && row.Reason == "" {
row.AddErrorReason("同批次存在校验失败卡,本卡未请求上游")
row.AddErrorReason(batchimport.NotExecutedReasonPrefix + "本卡校验通过")
}
}
task.FailureReason = "整批业务校验失败,未请求上游"
@@ -0,0 +1,290 @@
package lifecycle
import (
"context"
"sync"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
)
type fakeExecutorRepository struct {
mu sync.Mutex
claims []bool
claimCalls int
loadedBatchNos []string
task *Task
cards map[string]*entity.TSimCardInfo
validationFailureCalls int
outcomes []Outcome
finalizeCalls int
markFailedCalls int
}
func (r *fakeExecutorRepository) ClaimBatch(context.Context, string) (bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
index := r.claimCalls
r.claimCalls++
if index >= len(r.claims) {
return false, nil
}
return r.claims[index], nil
}
func (r *fakeExecutorRepository) LoadTask(_ context.Context, batchNo string) (*Task, error) {
r.loadedBatchNos = append(r.loadedBatchNos, batchNo)
return r.task, nil
}
func (r *fakeExecutorRepository) LoadCards(context.Context, *Task) (map[string]*entity.TSimCardInfo, error) {
return r.cards, nil
}
func (r *fakeExecutorRepository) SaveValidationFailure(context.Context, *Task) error {
r.validationFailureCalls++
return nil
}
func (r *fakeExecutorRepository) SaveOutcome(_ context.Context, _ *Task, _ *Row, _ *entity.TSimCardInfo, outcome Outcome) error {
r.outcomes = append(r.outcomes, outcome)
return nil
}
func (r *fakeExecutorRepository) SaveDetailFailure(context.Context, *Task, *Row) error {
return nil
}
func (r *fakeExecutorRepository) FinalizeBatch(context.Context, *Task) error {
r.finalizeCalls++
return nil
}
func (r *fakeExecutorRepository) MarkBatchFailed(context.Context, string, string) error {
r.markFailedCalls++
return nil
}
type fakeStatusChanger struct {
calls int
requests []*model.SimCardStatusChangeInput
response []model.SimBaseRes[model.SimCardStatusChangeItem]
err error
}
func (c *fakeStatusChanger) ChangeSimStatus(_ context.Context, input *model.SimCardStatusChangeInput) ([]model.SimBaseRes[model.SimCardStatusChangeItem], error) {
c.calls++
c.requests = append(c.requests, input)
return c.response, c.err
}
func TestActionOperTypeMapping(t *testing.T) {
stop, err := OperationSuspend.Config()
if err != nil {
t.Fatal(err)
}
start, err := OperationResume.Config()
if err != nil {
t.Fatal(err)
}
if stop.OperType != "0" || stop.BatchType != consts.BatchTypeStop {
t.Fatalf("unexpected stop mapping: %+v", stop)
}
if start.OperType != "1" || start.BatchType != consts.BatchTypeStart {
t.Fatalf("unexpected start mapping: %+v", start)
}
}
func TestExecuteValidationFailureSkipsUpstream(t *testing.T) {
task := newTestTask(t, OperationSuspend, []*Row{
{RowNo: 1, Iccid: "missing", Authorized: true},
{RowNo: 2, Iccid: "valid", Authorized: true},
})
repo := &fakeExecutorRepository{
claims: []bool{true},
task: task,
cards: map[string]*entity.TSimCardInfo{
"valid": {Id: 2, Iccid: "valid", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
},
}
upstream := &fakeStatusChanger{}
if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-test"); err != nil {
t.Fatal(err)
}
if upstream.calls != 0 {
t.Fatalf("upstream called %d times for invalid batch", upstream.calls)
}
if repo.validationFailureCalls != 1 || repo.finalizeCalls != 1 {
t.Fatalf("validation/finalize calls = %d/%d", repo.validationFailureCalls, repo.finalizeCalls)
}
if task.Rows[0].Reason == "" || task.Rows[1].Reason == "" {
t.Fatalf("expected all rows to retain failure reasons: %+v", task.Rows)
}
}
func TestValidateStopStatus(t *testing.T) {
t.Skip("TODO: 恢复卡状态限制后启用此测试")
tests := []struct {
name string
status int
valid bool
}{
{name: "activated", status: consts.V3ApiSimBossCardStatusActivated, valid: true},
{name: "pending", status: consts.V3ApiSimBossCardStatusPendingActivate},
{name: "testable", status: consts.V3ApiSimBossCardStatusTestable},
{name: "stock", status: consts.V3ApiSimBossCardStatusStock},
{name: "stopped", status: consts.V3ApiSimBossCardStatusStopped},
{name: "pre-cancel", status: consts.V3ApiSimBossCardStatusPreCancel},
{name: "canceled", status: consts.V3ApiSimBossCardStatusCanceled},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
row := &Row{Iccid: "card-1"}
validateCardStatus(OperationSuspend, row, &entity.TSimCardInfo{BossCardStatus: tt.status})
if got := row.Reason == ""; got != tt.valid {
t.Fatalf("status %d valid=%v, reason=%q", tt.status, got, row.Reason)
}
})
}
}
func TestExecuteMixedStopStatusSkipsUpstream(t *testing.T) {
t.Skip("TODO: 恢复卡状态限制后启用此测试")
task := newTestTask(t, OperationSuspend, []*Row{
{RowNo: 1, Iccid: "activated", Authorized: true},
{RowNo: 2, Iccid: "stock", Authorized: true},
})
repo := &fakeExecutorRepository{
claims: []bool{true},
task: task,
cards: map[string]*entity.TSimCardInfo{
"activated": {Id: 1, Iccid: "activated", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
"stock": {Id: 2, Iccid: "stock", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusStock},
},
}
upstream := &fakeStatusChanger{}
if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-mixed"); err != nil {
t.Fatal(err)
}
if upstream.calls != 0 {
t.Fatalf("upstream called %d times for mixed status batch", upstream.calls)
}
if repo.validationFailureCalls != 1 || repo.finalizeCalls != 1 {
t.Fatalf("validation/finalize calls = %d/%d", repo.validationFailureCalls, repo.finalizeCalls)
}
if task.Rows[0].Reason == "" || task.Rows[1].Reason == "" {
t.Fatalf("expected both rows to be failed without upstream call: %+v", task.Rows)
}
}
func TestExecuteRestoresByBatchNoAndMapsPerCardResults(t *testing.T) {
task := newTestTask(t, OperationSuspend, []*Row{
{RowNo: 1, Iccid: "card-1", Authorized: true},
{RowNo: 2, Iccid: "card-2", Authorized: true},
})
repo := &fakeExecutorRepository{
claims: []bool{true},
task: task,
cards: map[string]*entity.TSimCardInfo{
"card-1": {Id: 1, Iccid: "card-1", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
"card-2": {Id: 2, Iccid: "card-2", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
},
}
upstream := &fakeStatusChanger{response: []model.SimBaseRes[model.SimCardStatusChangeItem]{
{Code: 0, SimKey: &model.SimKey{ICCID: "card-1"}, ResData: model.SimCardStatusChangeItem{OrderNum: "order-1"}},
{Code: 500, Message: "operator rejected", SimKey: &model.SimKey{ICCID: "card-2"}},
}}
if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-restore"); err != nil {
t.Fatal(err)
}
if len(repo.loadedBatchNos) != 1 || repo.loadedBatchNos[0] != "STP-restore" {
t.Fatalf("executor did not restore by batchNo: %+v", repo.loadedBatchNos)
}
if upstream.calls != 1 || len(upstream.requests) != 1 {
t.Fatalf("unexpected upstream calls: %d", upstream.calls)
}
if upstream.requests[0].ReqData.OperType != "0" {
t.Fatalf("unexpected operType: %s", upstream.requests[0].ReqData.OperType)
}
if len(repo.outcomes) != 2 || !repo.outcomes[0].Success || repo.outcomes[1].Success {
t.Fatalf("unexpected per-card outcomes: %+v", repo.outcomes)
}
if repo.outcomes[0].OrderNum != "order-1" || repo.outcomes[1].Message != "operator rejected" {
t.Fatalf("upstream details were not preserved: %+v", repo.outcomes)
}
}
func TestExecuteDuplicateClaimRunsOnlyOnce(t *testing.T) {
task := newTestTask(t, OperationResume, []*Row{{RowNo: 1, Iccid: "card-1", Authorized: true}})
repo := &fakeExecutorRepository{
claims: []bool{true, false},
task: task,
cards: map[string]*entity.TSimCardInfo{
"card-1": {Id: 1, Iccid: "card-1", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusStopped},
},
}
upstream := &fakeStatusChanger{response: []model.SimBaseRes[model.SimCardStatusChangeItem]{
{Code: 0, SimKey: &model.SimKey{ICCID: "card-1"}},
}}
executor := newExecutor(repo, upstream)
if err := executor.Execute(context.Background(), "STR-once"); err != nil {
t.Fatal(err)
}
if err := executor.Execute(context.Background(), "STR-once"); err != nil {
t.Fatal(err)
}
if upstream.calls != 1 || len(repo.loadedBatchNos) != 1 || repo.finalizeCalls != 1 {
t.Fatalf("duplicate claim executed twice: upstream=%d load=%d finalize=%d", upstream.calls, len(repo.loadedBatchNos), repo.finalizeCalls)
}
if upstream.requests[0].ReqData.OperType != "1" {
t.Fatalf("unexpected start operType: %s", upstream.requests[0].ReqData.OperType)
}
}
type blockingExecutor struct {
started chan struct{}
release chan struct{}
}
func (e *blockingExecutor) Execute(context.Context, string) error {
close(e.started)
<-e.release
return nil
}
func (e *blockingExecutor) MarkFailed(context.Context, string, string) error {
return nil
}
func TestDispatchReturnsBeforeExecutorCompletes(t *testing.T) {
executor := &blockingExecutor{started: make(chan struct{}), release: make(chan struct{})}
Dispatch(executor, "STP-async")
select {
case <-executor.started:
case <-time.After(time.Second):
t.Fatal("background executor did not start")
}
select {
case <-executor.release:
t.Fatal("release channel should still be blocked")
default:
}
close(executor.release)
}
func newTestTask(t *testing.T, operation Operation, rows []*Row) *Task {
t.Helper()
config, err := operation.Config()
if err != nil {
t.Fatal(err)
}
return &Task{
Batch: &entity.BatchMain{BatchNo: "batch-test", BatchType: config.BatchType, TenantId: "tenant-1"},
Operation: operation,
Config: config,
Rows: rows,
}
}
@@ -0,0 +1,20 @@
package lifecycle
import (
"encoding/json"
"testing"
)
func TestBatchExecuteMessageJSONContract(t *testing.T) {
body, err := json.Marshal(BatchExecuteMessage{
BatchNo: "STP202607170001",
BatchType: 104,
})
if err != nil {
t.Fatalf("marshal batch execute message: %v", err)
}
const expected = `{"batchNo":"STP202607170001","batchType":104}`
if string(body) != expected {
t.Fatalf("unexpected batch execute message: %s", body)
}
}
@@ -0,0 +1,92 @@
package lifecycle
import (
"context"
"strings"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
)
func TestValidateBasicRowsRejectsDuplicateIccid(t *testing.T) {
rows := []batchimport.Row{
&Row{RowNo: 2, Iccid: " 8986001 "},
&Row{RowNo: 3, Iccid: "8986001"},
}
err := validateBasicRows(rows)
if err == nil || !strings.Contains(err.Error(), "ICCID") || !strings.Contains(err.Error(), "重复") {
t.Fatalf("expected readable duplicate ICCID error, got %v", err)
}
}
func TestValidateBasicRowsRejectsEveryEmptyIccid(t *testing.T) {
rows := []batchimport.Row{
&Row{RowNo: 2, Iccid: ""},
&Row{RowNo: 5, Iccid: " "},
}
err := validateBasicRows(rows)
if err == nil {
t.Fatal("expected empty ICCID validation error")
}
if !strings.Contains(err.Error(), "第2行") || !strings.Contains(err.Error(), "第5行") {
t.Fatalf("expected all empty ICCID rows in error, got %v", err)
}
}
func TestValidateBasicRowsRejectsWrongFieldCount(t *testing.T) {
rows, err := ParseInputRows(context.Background(), "8986001 13800008000")
if err != nil {
t.Fatal(err)
}
err = validateBasicRows(rows)
if err == nil || !strings.Contains(err.Error(), "格式") {
t.Fatalf("expected wrong field count error, got %v", err)
}
}
func TestInvalidBasicRowsStopBeforePendingPreparation(t *testing.T) {
tests := []struct {
name string
rows []batchimport.Row
}{
{
name: "duplicate",
rows: []batchimport.Row{
&Row{RowNo: 2, Iccid: "8986001"},
&Row{RowNo: 3, Iccid: "8986001"},
},
},
{
name: "multiple empty iccid",
rows: []batchimport.Row{
&Row{RowNo: 2},
&Row{RowNo: 3, Iccid: " "},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
processor, err := NewProcessor(OperationSuspend)
if err != nil {
t.Fatal(err)
}
prepared := false
result, err := batchimport.CreatePendingRows(
context.Background(),
batchimport.SubmitRequest{},
processor,
tt.rows,
batchimport.PendingOptions{PrepareRows: func(context.Context, []batchimport.Row) error {
prepared = true
return nil
}},
)
if err == nil || result != nil {
t.Fatalf("expected pre-persistence validation error, result=%+v err=%v", result, err)
}
if prepared {
t.Fatal("invalid rows reached pending preparation")
}
})
}
}
@@ -155,11 +155,15 @@ func (r *Repository) SaveValidationFailure(ctx context.Context, task *Task) erro
if row == nil {
continue
}
status := consts.BatchDetailStatusFailed
if batchimport.IsNotExecuted(row) {
status = consts.BatchDetailStatusNotExecuted
}
if _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().Id, row.DetailID).
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
Data(do.BatchDetail{
DetailStatus: consts.BatchDetailStatusFailed,
DetailStatus: status,
ErrorReason: row.Reason,
Snapshot: row.Snapshot(),
UpdatedBy: task.Operator.UserId,
@@ -255,21 +259,23 @@ func (r *Repository) FinalizeBatch(ctx context.Context, task *Task) error {
}
rows := make([]batchimport.Row, 0, len(task.Rows))
successCount := 0
failCount := 0
for _, row := range task.Rows {
rows = append(rows, row)
if row != nil && row.Reason == "" {
if row != nil && row.Reason == "" && task.ValidationOK {
successCount++
} else if row == nil || !batchimport.IsNotExecuted(row) {
failCount++
}
}
failCount := len(task.Rows) - successCount
validCount := 0
validCount := len(task.Rows) - failCount
if task.ValidationOK {
validCount = len(task.Rows)
}
importStatus := consts.ImportStatusSuccess
batchStatus := consts.BatchAsyncStatusSuccess
runResult := consts.BatchRunResultSuccess
if failCount > 0 {
if failCount > 0 || !task.ValidationOK {
importStatus = consts.ImportStatusFailed
batchStatus = consts.BatchAsyncStatusFailed
runResult = consts.BatchRunResultFailed
@@ -0,0 +1,101 @@
package lifecycle
import (
"context"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
func TestRepositorySaveOutcomeUpdatesOnlySuccessfulCard(t *testing.T) {
ctx := context.Background()
if err := gdb.SetConfig(gdb.Config{
"default": gdb.ConfigGroup{{
Type: "sqlite",
Link: "sqlite::@file(:memory:)",
MaxOpenConnCount: 1,
}},
}); err != nil {
t.Fatal(err)
}
defer g.DB().Close(ctx)
statements := []string{
`CREATE TABLE t_sim_card_info (
id INTEGER PRIMARY KEY,
iccid TEXT NOT NULL,
boss_card_status INTEGER NOT NULL,
status_sync_time DATETIME,
tenant_id TEXT NOT NULL,
updated_by INTEGER,
updated_at DATETIME
)`,
`CREATE TABLE batch_detail (
id INTEGER PRIMARY KEY,
batch_no TEXT NOT NULL,
detail_status INTEGER NOT NULL,
error_reason TEXT,
snapshot TEXT,
updated_by INTEGER,
updated_at DATETIME
)`,
`INSERT INTO t_sim_card_info(id, iccid, boss_card_status, tenant_id) VALUES
(1, 'card-success', 2, 'tenant-1'),
(2, 'card-failed', 2, 'tenant-1')`,
`INSERT INTO batch_detail(id, batch_no, detail_status) VALUES
(1, 'STP-test', 1),
(2, 'STP-test', 1)`,
}
for _, statement := range statements {
if _, err := g.DB().Exec(ctx, statement); err != nil {
t.Fatal(err)
}
}
config, err := OperationSuspend.Config()
if err != nil {
t.Fatal(err)
}
task := &Task{
Batch: &entity.BatchMain{BatchNo: "STP-test", TenantId: "tenant-1"},
Config: config,
Operator: batchimport.OperatorContext{TenantId: "tenant-1", UserId: 10},
}
repository := NewRepository()
successRow := &Row{DetailID: 1, Iccid: "card-success", StatusBefore: consts.V3ApiSimBossCardStatusActivated}
failedRow := &Row{DetailID: 2, Iccid: "card-failed", StatusBefore: consts.V3ApiSimBossCardStatusActivated}
if err = repository.SaveOutcome(ctx, task, successRow, &entity.TSimCardInfo{Id: 1}, Outcome{Success: true, OrderNum: "order-1"}); err != nil {
t.Fatal(err)
}
if err = repository.SaveOutcome(ctx, task, failedRow, &entity.TSimCardInfo{Id: 2}, Outcome{Code: 500, Message: "rejected"}); err != nil {
t.Fatal(err)
}
var cards []struct {
Iccid string `orm:"iccid"`
BossCardStatus int `orm:"boss_card_status"`
}
if err = g.DB().Model("t_sim_card_info").OrderAsc("id").Scan(&cards); err != nil {
t.Fatal(err)
}
if len(cards) != 2 || cards[0].BossCardStatus != consts.V3ApiSimBossCardStatusStopped || cards[1].BossCardStatus != consts.V3ApiSimBossCardStatusActivated {
t.Fatalf("unexpected local card statuses: %+v", cards)
}
var details []struct {
DetailStatus int `orm:"detail_status"`
ErrorReason string `orm:"error_reason"`
}
if err = g.DB().Model("batch_detail").OrderAsc("id").Scan(&details); err != nil {
t.Fatal(err)
}
if len(details) != 2 || details[0].DetailStatus != consts.BatchDetailStatusSuccess || details[1].DetailStatus != consts.BatchDetailStatusFailed || details[1].ErrorReason != "rejected" {
t.Fatalf("unexpected detail results: %+v", details)
}
}