chore: 并入 iotlcm-admin-svr 历史到 admin-svr/(原提交 49818c6)

This commit is contained in:
MiFaZhan
2026-11-02 13:27:59 +08:00
1250 changed files with 154111 additions and 0 deletions
@@ -0,0 +1,34 @@
package lifecycle
import (
"context"
"fmt"
"github.com/gogf/gf/v2/frame/g"
)
type BatchExecutor interface {
Execute(ctx context.Context, batchNo string) error
MarkFailed(ctx context.Context, batchNo, reason string) error
}
func Dispatch(executor BatchExecutor, batchNo string) {
go func() {
ctx := context.Background()
defer func() {
if recovered := recover(); recovered != nil {
reason := fmt.Sprintf("后台执行异常: %v", recovered)
g.Log().Errorf(ctx, "SIM lifecycle batch panic, batchNo=%s, reason=%s", batchNo, reason)
if err := executor.MarkFailed(ctx, batchNo, reason); err != nil {
g.Log().Errorf(ctx, "mark SIM lifecycle batch failed after panic, batchNo=%s, error=%+v", batchNo, err)
}
}
}()
if err := executor.Execute(ctx, batchNo); err != nil {
g.Log().Errorf(ctx, "execute SIM lifecycle batch failed, batchNo=%s, error=%+v", batchNo, err)
if markErr := executor.MarkFailed(ctx, batchNo, err.Error()); markErr != nil {
g.Log().Errorf(ctx, "mark SIM lifecycle batch failed, batchNo=%s, error=%+v", batchNo, markErr)
}
}
}()
}
@@ -0,0 +1,247 @@
package lifecycle
import (
"context"
"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"
"github.com/gogf/gf/v2/errors/gerror"
)
type executorRepository interface {
ClaimBatch(ctx context.Context, batchNo string) (bool, error)
LoadTask(ctx context.Context, batchNo string) (*Task, error)
LoadCards(ctx context.Context, task *Task) (map[string]*entity.TSimCardInfo, error)
SaveValidationFailure(ctx context.Context, task *Task) error
SaveOutcome(ctx context.Context, task *Task, row *Row, card *entity.TSimCardInfo, outcome Outcome) error
SaveDetailFailure(ctx context.Context, task *Task, row *Row) error
FinalizeBatch(ctx context.Context, task *Task) error
MarkBatchFailed(ctx context.Context, batchNo, reason string) error
}
type statusChanger interface {
ChangeSimStatus(ctx context.Context, input *model.SimCardStatusChangeInput) ([]model.SimBaseRes[model.SimCardStatusChangeItem], error)
}
type Executor struct {
repo executorRepository
upstream statusChanger
}
func NewExecutor() *Executor {
return &Executor{repo: NewRepository(), upstream: service.UpstreamChan()}
}
func newExecutor(repo executorRepository, upstream statusChanger) *Executor {
return &Executor{repo: repo, upstream: upstream}
}
func (e *Executor) Execute(ctx context.Context, batchNo string) error {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return gerror.New("批次号不能为空")
}
claimed, err := e.repo.ClaimBatch(ctx, batchNo)
if err != nil {
return gerror.Wrap(err, "抢占停复机批次失败")
}
if !claimed {
return nil
}
task, err := e.repo.LoadTask(ctx, batchNo)
if err != nil {
return err
}
cards, err := e.repo.LoadCards(ctx, task)
if err != nil {
return gerror.Wrap(err, "读取SIM卡业务数据失败")
}
if !validateTask(task, cards) {
if err = e.repo.SaveValidationFailure(ctx, task); err != nil {
return err
}
return e.repo.FinalizeBatch(ctx, task)
}
groups := groupRowsByChannel(task.Rows)
channelKeys := make([]string, 0, len(groups))
for chanKey := range groups {
channelKeys = append(channelKeys, chanKey)
}
sort.Strings(channelKeys)
for _, chanKey := range channelKeys {
rows := groups[chanKey]
outcomes := e.changeGroup(ctx, task, rows)
for i, row := range rows {
outcome := outcomes[i]
card := cards[row.Iccid]
if err = e.repo.SaveOutcome(ctx, task, row, card, outcome); err == nil {
continue
}
row.OrderNum = outcome.OrderNum
row.UpstreamCode = outcome.Code
row.UpstreamReason = outcome.Message
row.Reason = "上游已返回,但本地状态落库失败: " + err.Error()
if saveErr := e.repo.SaveDetailFailure(ctx, task, row); saveErr != nil {
return gerror.Wrap(saveErr, "记录本地状态落库失败结果失败")
}
}
}
return e.repo.FinalizeBatch(ctx, task)
}
func (e *Executor) MarkFailed(ctx context.Context, batchNo, reason string) error {
return e.repo.MarkBatchFailed(ctx, strings.TrimSpace(batchNo), strings.TrimSpace(reason))
}
func validateTask(task *Task, cards map[string]*entity.TSimCardInfo) bool {
hasFailure := false
for _, row := range task.Rows {
if row == nil {
hasFailure = true
continue
}
if row.Reason != "" {
hasFailure = true
continue
}
card := cards[row.Iccid]
if card == nil {
row.AddErrorReason("ICCID不存在于正式卡记录")
} else if !row.Authorized {
row.AddErrorReason("ICCID不在当前操作人的数据权限范围内")
} else if strings.TrimSpace(card.ChanKey) == "" {
row.AddErrorReason("ICCID缺少通道标识chanKey")
} else if row.Msisdn != "" && row.Msisdn != strings.TrimSpace(card.Msisdn) {
row.AddErrorReason("MSISDN与正式卡记录不一致")
} else if row.Imsi != "" && row.Imsi != strings.TrimSpace(card.Imsi) {
row.AddErrorReason("IMSI与正式卡记录不一致")
} else {
validateCardStatus(task.Operation, row, card)
}
if row.Reason != "" {
hasFailure = true
continue
}
row.Msisdn = strings.TrimSpace(card.Msisdn)
row.Imsi = strings.TrimSpace(card.Imsi)
row.ChanKey = strings.TrimSpace(card.ChanKey)
row.StatusBefore = card.BossCardStatus
}
if hasFailure {
for _, row := range task.Rows {
if row != nil && row.Reason == "" {
row.AddErrorReason(batchimport.NotExecutedReasonPrefix + "本卡校验通过")
}
}
task.FailureReason = "整批业务校验失败,未请求上游"
return false
}
task.ValidationOK = true
return true
}
func validateCardStatus(operation Operation, row *Row, card *entity.TSimCardInfo) {
// TODO: 正常系统开发完成后恢复卡状态限制
// 注释原因:开发环境经常出现卡状态不在预期可操作范围内的情况,先放行
_ = operation
_ = row
_ = card
// switch operation {
// case OperationSuspend:
// if card.BossCardStatus != consts.V3ApiSimBossCardStatusActivated {
// row.AddErrorReason("仅已激活状态的卡可以自主停机")
// }
// case OperationResume:
// if card.BossCardStatus != consts.V3ApiSimBossCardStatusStopped {
// row.AddErrorReason("仅停机状态的卡可以自主复机")
// }
// }
}
func groupRowsByChannel(rows []*Row) map[string][]*Row {
groups := make(map[string][]*Row)
for _, row := range rows {
groups[row.ChanKey] = append(groups[row.ChanKey], row)
}
return groups
}
func (e *Executor) changeGroup(ctx context.Context, task *Task, rows []*Row) []Outcome {
simKeys := make([]*model.SimKey, 0, len(rows))
for _, row := range rows {
simKeys = append(simKeys, &model.SimKey{
ChanKey: row.ChanKey,
ICCID: row.Iccid,
MSISDN: row.Msisdn,
IMSI: row.Imsi,
})
}
response, err := e.upstream.ChangeSimStatus(ctx, &model.SimCardStatusChangeInput{
SimKeys: simKeys,
ReqData: model.SimCardStatusChangeInputItem{OperType: task.Config.OperType},
})
if err != nil {
outcomes := make([]Outcome, len(rows))
for i := range outcomes {
outcomes[i] = Outcome{Message: "上游请求失败: " + err.Error()}
}
return outcomes
}
return mapOutcomes(rows, response)
}
func mapOutcomes(rows []*Row, response []model.SimBaseRes[model.SimCardStatusChangeItem]) []Outcome {
outcomes := make([]Outcome, len(rows))
used := make([]bool, len(response))
for rowIndex, row := range rows {
matched := -1
for responseIndex, item := range response {
if !used[responseIndex] && sameSim(row, item.SimKey) {
matched = responseIndex
break
}
}
if matched < 0 && rowIndex < len(response) && !used[rowIndex] && response[rowIndex].SimKey == nil {
matched = rowIndex
}
if matched < 0 {
outcomes[rowIndex] = Outcome{Message: "上游未返回该卡处理结果"}
continue
}
used[matched] = true
item := response[matched]
message := strings.TrimSpace(item.Message)
if item.Code != 0 && message == "" {
message = "上游处理失败"
}
outcomes[rowIndex] = Outcome{
Success: item.Code == 0,
Code: item.Code,
Message: message,
OrderNum: strings.TrimSpace(item.ResData.OrderNum),
}
}
return outcomes
}
func sameSim(row *Row, key *model.SimKey) bool {
if row == nil || key == nil {
return false
}
if value := strings.TrimSpace(key.ICCID); value != "" {
return value == row.Iccid
}
if value := strings.TrimSpace(key.MSISDN); value != "" {
return value == row.Msisdn
}
if value := strings.TrimSpace(key.IMSI); value != "" {
return value == row.Imsi
}
return false
}
@@ -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,87 @@
package lifecycle
import (
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"github.com/gogf/gf/v2/errors/gerror"
)
type Operation int
const (
OperationSuspend Operation = iota + 1
OperationResume
OperationRestart
)
type OperationConfig struct {
Operation Operation
Code string
Name string
BatchType int
BatchPrefix string
OperType string
TargetStatus int
TaskName string
ExportScene int
ImportDir string
ResultDir string
}
func (operation Operation) Config() (OperationConfig, error) {
switch operation {
case OperationSuspend:
return OperationConfig{
Operation: operation,
Code: "stop",
Name: "自主停机",
BatchType: consts.BatchTypeStop,
BatchPrefix: consts.BatchNoPrefixStop,
OperType: consts.SimCardStatusOperTypeStop,
TargetStatus: consts.V3ApiSimBossCardStatusStopped,
TaskName: consts.ResourceBatchDownloadTaskSimCardStop,
ExportScene: consts.ResourceExportSceneStop,
ImportDir: consts.SimCardStopSubDirImportExcel,
ResultDir: consts.SimCardStopSubDirResultExcel,
}, nil
case OperationResume:
return OperationConfig{
Operation: operation,
Code: "start",
Name: "自主复机",
BatchType: consts.BatchTypeStart,
BatchPrefix: consts.BatchNoPrefixStart,
OperType: consts.SimCardStatusOperTypeStart,
TargetStatus: consts.V3ApiSimBossCardStatusActivated,
TaskName: consts.ResourceBatchDownloadTaskSimCardStart,
ExportScene: consts.ResourceExportSceneStart,
ImportDir: consts.SimCardStartSubDirImportExcel,
ResultDir: consts.SimCardStartSubDirResultExcel,
}, nil
case OperationRestart:
return OperationConfig{
Operation: operation,
Code: "restart",
Name: "自主重启",
BatchType: consts.BatchTypeRestart,
BatchPrefix: consts.BatchNoPrefixRestart,
TaskName: consts.ResourceBatchDownloadTaskSimCardRestart,
ExportScene: consts.ResourceExportSceneRestart,
ImportDir: consts.SimCardRestartSubDirImportExcel,
ResultDir: consts.SimCardRestartSubDirResultExcel,
}, nil
default:
return OperationConfig{}, gerror.New("不支持的SIM卡状态操作")
}
}
func OperationByBatchType(batchType int) (Operation, error) {
switch batchType {
case consts.BatchTypeStop:
return OperationSuspend, nil
case consts.BatchTypeStart:
return OperationResume, nil
default:
return 0, gerror.Newf("批次类型%d不是停复机执行批次", batchType)
}
}
@@ -0,0 +1,92 @@
package lifecycle
import (
"context"
"errors"
"fmt"
"strings"
"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/library/batchinput/excel"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
"github.com/gogf/gf/v2/errors/gerror"
)
func ParseExcelFileRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
parsed, err := excel.ParseFile(ctx, filePath, excel.ParseOptions{RequiredHeaders: []string{"ICCID"}, MaxDataRows: consts.SimCardBatchMaxRows})
if err != nil {
return nil, err
}
rows := make([]batchimport.Row, 0, len(parsed))
for _, item := range parsed {
rows = append(rows, &Row{
RowNo: item.RowNo,
Iccid: excel.Field(item, "ICCID"),
Msisdn: excel.Field(item, "MSISDN"),
Imsi: excel.Field(item, "IMSI"),
})
}
return rows, nil
}
func ParseInputRows(ctx context.Context, input string) ([]batchimport.Row, error) {
lines, err := text.ParseWhitespaceFields(ctx, input)
if err != nil {
if errors.Is(err, text.ErrEmptyInput) {
return nil, gerror.New("批量输入内容不能为空")
}
return nil, err
}
rows := make([]batchimport.Row, 0, len(lines))
for _, line := range lines {
row := &Row{RowNo: line.RowNo}
switch len(line.Fields) {
case 1:
row.Iccid = line.Fields[0]
case 3:
row.Iccid = line.Fields[0]
row.Msisdn = line.Fields[1]
row.Imsi = line.Fields[2]
default:
row.AddErrorReason("格式错误,应为 ICCID 或 ICCID MSISDN IMSI")
}
rows = append(rows, row)
}
return rows, nil
}
func validateBasicRows(rows []batchimport.Row) error {
firstByIccid := make(map[string]*Row, len(rows))
issues := make([]string, 0)
for _, item := range rows {
row, ok := item.(*Row)
if !ok || row == nil {
return gerror.New("停复机批次行类型错误")
}
row.Iccid = strings.TrimSpace(row.Iccid)
row.Msisdn = strings.TrimSpace(row.Msisdn)
row.Imsi = strings.TrimSpace(row.Imsi)
if row.Reason != "" {
issues = append(issues, fmt.Sprintf("第%d行:%s", row.RowNo, row.Reason))
continue
}
if row.Iccid == "" {
row.AddErrorReason("ICCID不能为空")
issues = append(issues, fmt.Sprintf("第%d行:ICCID不能为空", row.RowNo))
continue
}
if first, exists := firstByIccid[row.Iccid]; exists {
row.AddErrorReason(fmt.Sprintf("ICCID与第%d行重复", first.RowNo))
first.AddErrorReason("ICCID重复")
issues = append(issues, fmt.Sprintf("第%d行:ICCID与第%d行重复", row.RowNo, first.RowNo))
continue
}
firstByIccid[row.Iccid] = row
}
if len(issues) > 0 {
return gerror.New("停复机批次基础格式校验失败:" + strings.Join(issues, ";"))
}
return nil
}
@@ -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")
}
})
}
}
@@ -0,0 +1,87 @@
package lifecycle
import (
"context"
"strings"
"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"
"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"
)
type Processor struct {
config OperationConfig
}
func NewProcessor(operation Operation) (*Processor, error) {
config, err := operation.Config()
if err != nil {
return nil, err
}
return &Processor{config: config}, nil
}
func (p *Processor) Config() batchimport.ProcessorConfig {
return batchimport.ProcessorConfig{
Code: "sim_card_" + p.config.Code,
BatchType: p.config.BatchType,
BatchNoPrefix: p.config.BatchPrefix,
TaskName: p.config.TaskName,
ExportScene: p.config.ExportScene,
ImportDir: p.config.ImportDir,
ResultDir: p.config.ResultDir,
MaxFileSize: consts.SimCardLifecycleMaxFileSize,
MaxRows: consts.SimCardBatchMaxRows,
TemplateHeaders: []string{"ICCID", "MSISDN", "IMSI"},
RequiredHeaders: []string{"ICCID"},
}
}
func (p *Processor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
return ParseExcelFileRows(ctx, filePath)
}
func (p *Processor) ValidateRows(_ context.Context, rows []batchimport.Row) error {
return validateBasicRows(rows)
}
func (p *Processor) Execute(context.Context, gdb.TX, *batchimport.SubmitRequest, []batchimport.Row) error {
return gerror.New("停复机批次只能由异步执行器执行")
}
func (p *Processor) BuildBatchInfo(_ context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadID int64) *gjson.Json {
info := g.Map{
"operation": p.config.Code,
}
if p.config.OperType != "" {
info["operType"] = p.config.OperType
}
if p.config.Operation == OperationRestart {
info["stopOperType"] = consts.SimCardStatusOperTypeStop
info["resumeOperType"] = consts.SimCardStatusOperTypeStart
info["resumeDelaySeconds"] = 300
info["restart"] = g.Map{"phase": "created"}
}
if options, ok := req.BizParams.(*SubmitOptions); ok && options != nil {
if remark := strings.TrimSpace(options.Remark); remark != "" {
info["remark"] = remark
}
}
if result != nil && result.ImportFileOssId > 0 {
info["importFileOssId"] = result.ImportFileOssId
} else if importOss != nil && importOss.OssId > 0 {
info["importFileOssId"] = importOss.OssId
}
if resultOss != nil {
info["resultFileOssId"] = resultOss.OssId
}
if downloadID > 0 {
info["resultDownloadId"] = downloadID
}
return gjson.New(info)
}
@@ -0,0 +1,424 @@
package lifecycle
import (
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"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"
)
type Repository struct{}
func NewRepository() *Repository {
return &Repository{}
}
func (r *Repository) ClaimBatch(ctx context.Context, batchNo string) (bool, error) {
claimed := false
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
WhereIn(dao.BatchMain.Columns().BatchType, []int{consts.BatchTypeStart, consts.BatchTypeStop}).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusPending).
Data(do.BatchMain{
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
RunResult: consts.BatchRunResultRunning,
StartTime: gtime.Now(),
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return nil
}
claimed = true
_, err = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusPending).
Data(do.BatchDetail{DetailStatus: consts.BatchDetailStatusRunning}).
Update()
return err
})
return claimed, err
}
func (r *Repository) LoadTask(ctx context.Context, batchNo string) (*Task, error) {
batch := &entity.BatchMain{}
if err := dao.BatchMain.Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Scan(batch); err != nil {
return nil, gerror.Wrap(err, "读取停复机批次失败")
}
if batch.Id == 0 {
return nil, gerror.New("停复机批次不存在")
}
operation, err := OperationByBatchType(batch.BatchType)
if err != nil {
return nil, err
}
config, err := operation.Config()
if err != nil {
return nil, err
}
var details []*entity.BatchDetail
if err = dao.BatchDetail.Ctx(ctx).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().BatchType, batch.BatchType).
OrderAsc(dao.BatchDetail.Columns().RowNo).
Scan(&details); err != nil {
return nil, gerror.Wrap(err, "读取停复机批次明细失败")
}
if len(details) == 0 {
return nil, gerror.New("停复机批次没有明细")
}
task := &Task{
Batch: batch,
Operation: operation,
Config: config,
Rows: make([]*Row, 0, len(details)),
Operator: batchimport.OperatorContext{TenantId: batch.TenantId, DeptId: batch.CreatedDept, UserId: batch.CreatedBy},
StartTime: batch.StartTime,
}
if batch.BatchInfo != nil {
task.Remark = strings.TrimSpace(batch.BatchInfo.Get("remark").String())
}
task.ImportOssID = sourceOssIDFromBatch(batch)
for _, detail := range details {
row := &Row{
DetailID: detail.Id,
RowNo: detail.RowNo,
Iccid: strings.TrimSpace(detail.ItemId),
Reason: strings.TrimSpace(detail.ErrorReason),
}
if detail.Snapshot != nil {
if value := strings.TrimSpace(detail.Snapshot.Get("ICCID").String()); value != "" {
row.Iccid = value
}
row.Msisdn = strings.TrimSpace(detail.Snapshot.Get("MSISDN").String())
row.Imsi = strings.TrimSpace(detail.Snapshot.Get("IMSI").String())
row.Authorized = detail.Snapshot.Get("authorized").Bool()
}
task.Rows = append(task.Rows, row)
}
return task, nil
}
func (r *Repository) LoadCards(ctx context.Context, task *Task) (map[string]*entity.TSimCardInfo, error) {
result := make(map[string]*entity.TSimCardInfo, len(task.Rows))
iccids := make([]string, 0, len(task.Rows))
for _, row := range task.Rows {
if row != nil && row.Iccid != "" {
iccids = append(iccids, row.Iccid)
}
}
if len(iccids) == 0 {
return result, nil
}
var cards []*entity.TSimCardInfo
err := dao.TSimCardInfo.Ctx(ctx).
Where(dao.TSimCardInfo.Columns().TenantId, task.Batch.TenantId).
WhereIn(dao.TSimCardInfo.Columns().Iccid, iccids).
Scan(&cards)
if err != nil {
return nil, err
}
for _, card := range cards {
if card != nil {
result[strings.TrimSpace(card.Iccid)] = card
}
}
return result, nil
}
func (r *Repository) SaveValidationFailure(ctx context.Context, task *Task) error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, row := range task.Rows {
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: status,
ErrorReason: row.Reason,
Snapshot: row.Snapshot(),
UpdatedBy: task.Operator.UserId,
}).Update(); err != nil {
return gerror.Wrap(err, "保存整批校验失败明细失败")
}
}
return nil
})
}
func (r *Repository) SaveOutcome(ctx context.Context, task *Task, row *Row, card *entity.TSimCardInfo, outcome Outcome) error {
row.OrderNum = outcome.OrderNum
row.UpstreamCode = outcome.Code
row.UpstreamReason = outcome.Message
if outcome.Success {
row.Reason = ""
} else if strings.TrimSpace(outcome.Message) != "" {
row.Reason = outcome.Message
} else {
row.Reason = "上游处理失败"
}
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if outcome.Success {
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
Where(dao.TSimCardInfo.Columns().Id, card.Id).
Where(dao.TSimCardInfo.Columns().TenantId, task.Batch.TenantId).
Where(dao.TSimCardInfo.Columns().BossCardStatus, row.StatusBefore).
Data(do.TSimCardInfo{
BossCardStatus: task.Config.TargetStatus,
StatusSyncTime: gtime.Now(),
UpdatedBy: task.Operator.UserId,
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.New("SIM卡本地状态已变化")
}
}
detailStatus := consts.BatchDetailStatusFailed
if outcome.Success {
detailStatus = consts.BatchDetailStatusSuccess
}
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().Id, row.DetailID).
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
Data(do.BatchDetail{
DetailStatus: detailStatus,
ErrorReason: row.Reason,
Snapshot: row.Snapshot(),
UpdatedBy: task.Operator.UserId,
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.New("批次明细状态已变化")
}
return nil
})
}
func (r *Repository) SaveDetailFailure(ctx context.Context, task *Task, row *Row) error {
_, err := dao.BatchDetail.Ctx(ctx).
Where(dao.BatchDetail.Columns().Id, row.DetailID).
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
Data(do.BatchDetail{
DetailStatus: consts.BatchDetailStatusFailed,
ErrorReason: row.Reason,
Snapshot: row.Snapshot(),
UpdatedBy: task.Operator.UserId,
}).Update()
return err
}
func (r *Repository) FinalizeBatch(ctx context.Context, task *Task) error {
processor, err := NewProcessor(task.Operation)
if err != nil {
return err
}
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 == "" && task.ValidationOK {
successCount++
} else if row == nil || !batchimport.IsNotExecuted(row) {
failCount++
}
}
validCount := len(task.Rows) - failCount
if task.ValidationOK {
validCount = len(task.Rows)
}
importStatus := consts.ImportStatusSuccess
batchStatus := consts.BatchAsyncStatusSuccess
runResult := consts.BatchRunResultSuccess
if failCount > 0 || !task.ValidationOK {
importStatus = consts.ImportStatusFailed
batchStatus = consts.BatchAsyncStatusFailed
runResult = consts.BatchRunResultFailed
if task.FailureReason == "" {
task.FailureReason = fmt.Sprintf("%d张卡处理失败", failCount)
}
}
result := &batchimport.Result{
ImportStatus: importStatus,
BatchNo: task.Batch.BatchNo,
BatchType: task.Batch.BatchType,
FailReason: task.FailureReason,
ImportFileOssId: task.ImportOssID,
TotalRows: len(task.Rows),
ValidRows: validCount,
ErrorRows: failCount,
InsertedRows: successCount,
}
req := batchimport.SubmitRequest{
SourceChannel: batchmetadata.Channel(task.Batch.SourceChannel),
ExecutionMode: int(task.Batch.ExecutionMode),
BizParams: &SubmitOptions{Remark: task.Remark},
SourceType: task.Batch.SourceType,
Operator: task.Operator,
BatchNo: task.Batch.BatchNo,
BatchType: task.Batch.BatchType,
}
importOss, err := r.loadImportOss(ctx, task)
if err != nil {
return err
}
endTime := gtime.Now()
backgroundCtx := contexts.Set(ctx, &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{
ID: task.Operator.UserId,
DeptId: task.Operator.DeptId,
TenantId: task.Operator.TenantId,
}}})
resultOss, downloadID, err := batchimport.SaveAsyncResultFiles(backgroundCtx, req, processor, result, rows, importOss, task.StartTime, endTime)
if err != nil {
return gerror.Wrap(err, "生成停复机结果Excel失败")
}
batchInfo := processor.BuildBatchInfo(backgroundCtx, req, result, importOss, resultOss, downloadID)
_, err = dao.BatchMain.Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Data(do.BatchMain{
BatchAsyncStatus: batchStatus,
ValidCount: validCount,
SuccessCount: successCount,
FailCount: failCount,
RunResult: runResult,
FailReason: task.FailureReason,
ResultOssId: lifecycleOssIDValue(resultOss.OssId),
BatchInfo: batchInfo,
EndTime: endTime,
UpdatedBy: task.Operator.UserId,
}).Update()
return gerror.Wrap(err, "汇总停复机批次失败")
}
func (r *Repository) loadImportOss(ctx context.Context, task *Task) (*model.SysOssSaveContentModel, error) {
if task.ImportOssID == 0 {
return &model.SysOssSaveContentModel{SysOss: &entity.SysOss{}}, nil
}
oss := &entity.SysOss{}
if err := dao.SysOss.Ctx(ctx).
Where(dao.SysOss.Columns().OssId, task.ImportOssID).
Where(dao.SysOss.Columns().TenantId, task.Batch.TenantId).
Scan(oss); err != nil {
return nil, gerror.Wrap(err, "读取原始导入文件失败")
}
return &model.SysOssSaveContentModel{SysOss: oss}, nil
}
func sourceOssIDFromBatch(batch *entity.BatchMain) int64 {
if batch == nil {
return 0
}
if batch.SourceOssId > 0 {
return batch.SourceOssId
}
if batch.BatchInfo == nil {
return 0
}
for _, path := range []string{"importFileOssId", "source.importFileOssId", "source.inputFileOssId"} {
if ossID := batch.BatchInfo.Get(path).Int64(); ossID > 0 {
return ossID
}
}
return 0
}
func lifecycleOssIDValue(ossID int64) any {
if ossID <= 0 {
return nil
}
return ossID
}
func (r *Repository) MarkBatchFailed(ctx context.Context, batchNo, reason string) error {
if batchNo == "" {
return gerror.New("批次号不能为空")
}
if reason == "" {
reason = "后台执行失败"
}
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
WhereIn(dao.BatchDetail.Columns().DetailStatus, []int{consts.BatchDetailStatusPending, consts.BatchDetailStatusRunning}).
Data(do.BatchDetail{
DetailStatus: consts.BatchDetailStatusFailed,
ErrorReason: reason,
}).Update()
if err != nil {
return err
}
successCount, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusSuccess).
Count()
if err != nil {
return err
}
failCount, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusFailed).
Count()
if err != nil {
return err
}
_, err = tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
WhereIn(dao.BatchMain.Columns().BatchAsyncStatus, []int{consts.BatchAsyncStatusPending, consts.BatchAsyncStatusProcessing}).
Data(do.BatchMain{
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
SuccessCount: successCount,
FailCount: failCount,
RunResult: consts.BatchRunResultFailed,
FailReason: reason,
EndTime: gtime.Now(),
}).Update()
return err
})
}
@@ -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)
}
}
@@ -0,0 +1,91 @@
package lifecycle
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
"strings"
"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/logic/iot/simcardbatch/common"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/net/ghttp"
)
type BatchExecuteMessage = common.BatchExecuteMessage
type SubmitResult = common.SubmitResult
type Submitter struct {
operation Operation
}
func NewSubmitter(operation Operation) *Submitter {
return &Submitter{operation: operation}
}
func (s *Submitter) SubmitExcel(ctx context.Context, file *ghttp.UploadFile, remark string) (*SubmitResult, error) {
processor, err := NewProcessor(s.operation)
if err != nil {
return nil, err
}
req := batchimport.SubmitRequest{
SourceChannel: batchmetadata.Console,
ExecutionMode: batchmetadata.Bulk,
File: file,
BizParams: &common.SubmitOptions{Remark: strings.TrimSpace(remark)},
SourceType: consts.BatchSourceTypeFile,
Operator: common.CurrentOperator(ctx),
}
result, err := batchimport.CreatePendingUpload(ctx, req, processor, batchimport.PendingOptions{
PrepareRows: func(ctx context.Context, rows []batchimport.Row) error {
return common.PrepareAuthorizedRows(ctx, rows, func(item batchimport.Row) (*Row, error) {
row, ok := item.(*Row)
if !ok || row == nil {
return nil, gerror.New("停复机批次行类型错误")
}
return row, nil
}, "停复机批次行类型错误")
},
})
if err != nil {
return nil, err
}
common.PublishBatchMessage(ctx, result, consts.QueueBatchExecute, "lifecycle")
return common.PendingSubmitResult(result, consts.BatchAsyncStatusPending), nil
}
func (s *Submitter) SubmitInput(ctx context.Context, input, remark string) (*SubmitResult, error) {
rows, err := ParseInputRows(ctx, input)
if err != nil {
return nil, err
}
processor, err := NewProcessor(s.operation)
if err != nil {
return nil, err
}
req := batchimport.SubmitRequest{
SourceChannel: batchmetadata.Console,
ExecutionMode: batchmetadata.Bulk,
BizParams: &common.SubmitOptions{Remark: strings.TrimSpace(remark)},
SourceType: consts.BatchSourceTypeBatchInput,
Operator: common.CurrentOperator(ctx),
}
result, err := batchimport.CreatePendingRows(ctx, req, processor, rows, batchimport.PendingOptions{
PrepareRows: func(ctx context.Context, rows []batchimport.Row) error {
return common.PrepareAuthorizedRows(ctx, rows, func(item batchimport.Row) (*Row, error) {
row, ok := item.(*Row)
if !ok || row == nil {
return nil, gerror.New("停复机批次行类型错误")
}
return row, nil
}, "停复机批次行类型错误")
},
})
if err != nil {
return nil, err
}
common.PublishBatchMessage(ctx, result, consts.QueueBatchExecute, "lifecycle")
return common.PendingSubmitResult(result, consts.BatchAsyncStatusPending), nil
}
@@ -0,0 +1,107 @@
package lifecycle
import (
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/common"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
type Row struct {
DetailID int64
RowNo int
Iccid string
Msisdn string
Imsi string
Authorized bool
ChanKey string
StatusBefore int
OrderNum string
UpstreamCode int
UpstreamReason string
Reason string
}
func (r *Row) GetRowNo() int {
if r == nil {
return 0
}
return r.RowNo
}
func (r *Row) GetItemID() string {
if r == nil {
return ""
}
return r.Iccid
}
func (r *Row) GetErrorReasons() []string {
if r == nil || r.Reason == "" {
return nil
}
return []string{r.Reason}
}
func (r *Row) AddErrorReason(reason string) {
if r == nil || r.Reason != "" || reason == "" {
return
}
r.Reason = reason
}
func (r *Row) GetIccid() string {
if r == nil {
return ""
}
return r.Iccid
}
func (r *Row) SetAuthorized(v bool) {
if r == nil {
return
}
r.Authorized = v
}
func (r *Row) Snapshot() *gjson.Json {
if r == nil {
return gjson.New(g.Map{})
}
return gjson.New(g.Map{
"ICCID": r.Iccid,
"MSISDN": r.Msisdn,
"IMSI": r.Imsi,
"authorized": r.Authorized,
"chanKey": r.ChanKey,
"statusBefore": r.StatusBefore,
"orderNum": r.OrderNum,
"upstreamCode": r.UpstreamCode,
"upstreamMessage": r.UpstreamReason,
})
}
type SubmitOptions = common.SubmitOptions
type Task struct {
Batch *entity.BatchMain
Operation Operation
Config OperationConfig
Rows []*Row
Operator batchimport.OperatorContext
ImportOssID int64
Remark string
StartTime *gtime.Time
ValidationOK bool
FailureReason string
}
type Outcome struct {
Success bool
Code int
Message string
OrderNum string
}