sync: 同步 v1.0.4 开源代码并脱敏
This commit is contained in:
@@ -46,7 +46,7 @@ func (r *Repository) loadParentTaskTx(ctx context.Context, tx gdb.TX, parent *en
|
||||
continue
|
||||
}
|
||||
detail := &Detail{OriginalSnapshot: batchDetail.Snapshot, ID: batchDetail.Id, BatchNo: batchDetail.BatchNo, RowNo: batchDetail.RowNo, DetailStatus: batchDetail.DetailStatus, ICCID: strings.TrimSpace(batchDetail.ItemId), Reason: strings.TrimSpace(batchDetail.ErrorReason)}
|
||||
if detail.DetailStatus != consts.BatchDetailStatusSuccess && detail.Reason == "" {
|
||||
if detail.DetailStatus != consts.BatchDetailStatusSuccess && detail.DetailStatus != consts.BatchDetailStatusRunning && detail.Reason == "" {
|
||||
detail.Reason = "子批次明细尚未全部验证成功"
|
||||
}
|
||||
if snapshot := batchDetail.Snapshot; snapshot != nil {
|
||||
|
||||
@@ -2,19 +2,19 @@ package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPersistedAllocateDetailsRequireExactBusinessResult(t *testing.T) {
|
||||
task := &Task{Batch: Batch{BatchNo: "ALC-child", TenantID: "000000", CreatedBy: 133, CreatedDept: 175}}
|
||||
detail := &Detail{ID: 1, ICCID: "89860401790000277308", TargetDeptID: 185}
|
||||
detail := &Detail{ID: 1, ICCID: "89860401791508277308", TargetDeptID: 185}
|
||||
good := entity.BatchDetail{Id: 1, BatchNo: task.Batch.BatchNo, BatchType: consts.BatchTypeAllocate, TenantId: task.Batch.TenantID, DetailStatus: consts.BatchDetailStatusSuccess, UpdatedBy: 133, Snapshot: gjson.New(detail.Snapshot(task.Batch).String())}
|
||||
if err := validatePersistedDetailResults(task, []*Detail{detail}, []*entity.BatchDetail{&good}); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -166,68 +166,99 @@ func markAtomicFailure(task *Task, reason string) {
|
||||
task.FailureReason = reason
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && strings.TrimSpace(detail.Reason) == "" {
|
||||
detail.Reason = "本批次存在失败明细,整批未执行"
|
||||
detail.Reason = atomicNotExecutedReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const atomicNotExecutedReason = "校验通过,但本批次存在失败明细,整批未执行划拨"
|
||||
|
||||
func allocationDetailStatus(detail *Detail) int {
|
||||
if detail.Reason == atomicNotExecutedReason {
|
||||
return consts.BatchDetailStatusNotExecuted
|
||||
}
|
||||
if detail.Success() {
|
||||
return consts.BatchDetailStatusSuccess
|
||||
}
|
||||
return consts.BatchDetailStatusFailed
|
||||
}
|
||||
|
||||
func countValidationFailures(task *Task) int {
|
||||
count := 0
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && !detail.Success() && detail.Reason != atomicNotExecutedReason {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func persistedAllocationDetailStatus(task *Task, detail *Detail) int {
|
||||
status := allocationDetailStatus(detail)
|
||||
if strings.TrimSpace(task.Batch.ParentBatchNo) != "" && status == consts.BatchDetailStatusSuccess {
|
||||
return consts.BatchDetailStatusRunning
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func validateDetail(task *Task, detail *Detail) {
|
||||
if detail == nil || strings.TrimSpace(detail.Reason) != "" {
|
||||
if task == nil || detail == nil {
|
||||
return
|
||||
}
|
||||
var reasons []string
|
||||
if reason := strings.TrimSpace(detail.Reason); reason != "" {
|
||||
reasons = append(reasons, reason)
|
||||
}
|
||||
defer func() { detail.Reason = strings.Join(reasons, ";") }()
|
||||
if detail.ICCID == "" {
|
||||
detail.Reason = "ICCID不能为空"
|
||||
reasons = append(reasons, "ICCID不能为空")
|
||||
return
|
||||
}
|
||||
detail.Reason = ""
|
||||
target := resolveTarget(task, detail)
|
||||
if target == nil {
|
||||
return
|
||||
}
|
||||
detail.Target = target
|
||||
detail.TargetDeptID = target.DeptID
|
||||
if detail.TargetDeptKey == "" {
|
||||
detail.TargetDeptKey = strings.TrimSpace(target.DeptKey)
|
||||
}
|
||||
if detail.TargetDeptName == "" {
|
||||
detail.TargetDeptName = strings.TrimSpace(target.DeptName)
|
||||
reasons = append(reasons, detail.Reason)
|
||||
} else {
|
||||
detail.Target = target
|
||||
detail.TargetDeptID = target.DeptID
|
||||
if detail.TargetDeptKey == "" {
|
||||
detail.TargetDeptKey = strings.TrimSpace(target.DeptKey)
|
||||
}
|
||||
if detail.TargetDeptName == "" {
|
||||
detail.TargetDeptName = strings.TrimSpace(target.DeptName)
|
||||
}
|
||||
}
|
||||
if detail.Card == nil {
|
||||
detail.Reason = "ICCID不存在或不属于当前租户"
|
||||
reasons = append(reasons, "ICCID不存在或不属于当前租户")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(detail.ICCID) != strings.TrimSpace(detail.Card.ICCID) {
|
||||
detail.Reason = "ICCID与卡档案不一致"
|
||||
reasons = append(reasons, "ICCID与卡档案不一致")
|
||||
return
|
||||
}
|
||||
detail.MSISDN = coalesce(detail.MSISDN, detail.Card.MSISDN)
|
||||
detail.IMSI = coalesce(detail.IMSI, detail.Card.IMSI)
|
||||
if detail.Card.DeptID != task.Batch.CreatedDept {
|
||||
detail.Reason = "ICCID不在当前操作员授权范围内"
|
||||
return
|
||||
reasons = append(reasons, "ICCID不在当前操作员授权范围内")
|
||||
}
|
||||
if strings.TrimSpace(detail.MSISDN) != "" && strings.TrimSpace(detail.MSISDN) != detail.Card.MSISDN {
|
||||
detail.Reason = "MSISDN与卡档案不一致"
|
||||
return
|
||||
reasons = append(reasons, "MSISDN与卡档案不一致")
|
||||
}
|
||||
if strings.TrimSpace(detail.IMSI) != "" && strings.TrimSpace(detail.IMSI) != detail.Card.IMSI {
|
||||
detail.Reason = "IMSI与卡档案不一致"
|
||||
return
|
||||
reasons = append(reasons, "IMSI与卡档案不一致")
|
||||
}
|
||||
if detail.Card.BossCardStatus == consts.SimBossCardStatusStopped {
|
||||
detail.Reason = "卡已停机,不能划拨"
|
||||
return
|
||||
reasons = append(reasons, "卡已停机,不能划拨")
|
||||
}
|
||||
if detail.Card.BossCardStatus == consts.SimBossCardStatusCanceled {
|
||||
detail.Reason = "卡已销户,不能划拨"
|
||||
return
|
||||
reasons = append(reasons, "卡已销户,不能划拨")
|
||||
}
|
||||
if detail.Card.PackageStatus == consts.PackageStatusExpired ||
|
||||
(detail.Card.FlowPkgEndAt != nil && !detail.Card.FlowPkgEndAt.IsZero() && detail.Card.FlowPkgEndAt.Before(gtime.Now())) {
|
||||
detail.Reason = "套餐已过期,不能划拨"
|
||||
return
|
||||
reasons = append(reasons, "套餐已过期,不能划拨")
|
||||
}
|
||||
if detail.Card.DeptID == target.DeptID {
|
||||
detail.Reason = "卡已属于目标客户,无需重复划拨"
|
||||
if target != nil && detail.Card.DeptID == target.DeptID {
|
||||
reasons = append(reasons, "卡已属于目标客户,无需重复划拨")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestAllocateBatchSizesRemainBounded(t *testing.T) {
|
||||
func TestBuildDetailUpdateStatementUsesBoundParameters(t *testing.T) {
|
||||
task := &Task{Batch: Batch{BatchNo: "ALC-1", CreatedBy: 7}}
|
||||
details := []*Detail{
|
||||
{ID: 11, DetailStatus: consts.BatchDetailStatusPending, Reason: "unsafe ' reason"},
|
||||
{ID: 11, DetailStatus: consts.BatchDetailStatusPending, Reason: atomicNotExecutedReason},
|
||||
{ID: 12, DetailStatus: consts.BatchDetailStatusRunning, Reason: "second"},
|
||||
}
|
||||
statement, args := buildDetailUpdateStatement(task, details, gtime.Now())
|
||||
@@ -84,8 +84,11 @@ func TestBuildDetailUpdateStatementUsesBoundParameters(t *testing.T) {
|
||||
if !strings.Contains(statement, "CASE") || !strings.Contains(statement, "IN (?,?)") {
|
||||
t.Fatalf("unexpected statement: %s", statement)
|
||||
}
|
||||
if len(args) != len(details)*7+9 {
|
||||
t.Fatalf("argument count = %d, want %d", len(args), len(details)*7+9)
|
||||
if len(args) != len(details)*7+10 {
|
||||
t.Fatalf("argument count = %d, want %d", len(args), len(details)*7+10)
|
||||
}
|
||||
if args[1] != consts.BatchDetailStatusNotExecuted || args[3] != consts.BatchDetailStatusFailed {
|
||||
t.Fatalf("status parameters = %v, %v; want not executed and failed", args[1], args[3])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,20 +99,68 @@ func TestNormalizeAtomicParentResultRejectsPartialSuccess(t *testing.T) {
|
||||
}}
|
||||
|
||||
successCount, failCount := normalizeAtomicParentResult(task)
|
||||
if successCount != 0 || failCount != len(task.Details) {
|
||||
t.Fatalf("normalizeAtomicParentResult() = %d, %d; want 0, %d", successCount, failCount, len(task.Details))
|
||||
if successCount != 0 || failCount != 1 {
|
||||
t.Fatalf("normalizeAtomicParentResult() = %d, %d; want 0, 1", successCount, failCount)
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail.Success() {
|
||||
t.Fatalf("detail %s remained successful", detail.ICCID)
|
||||
}
|
||||
}
|
||||
if allocationDetailStatus(task.Details[0]) != consts.BatchDetailStatusNotExecuted || allocationDetailStatus(task.Details[1]) != consts.BatchDetailStatusFailed {
|
||||
t.Fatalf("wrong detail statuses: %+v", task.Details)
|
||||
}
|
||||
rows := task.ResultRows()
|
||||
if rows[0].Status != "未执行" || rows[1].Status != "失败" {
|
||||
t.Fatalf("wrong result file statuses: %+v", rows)
|
||||
}
|
||||
status, result, _ := parentBatchResult(successCount, failCount)
|
||||
if status != consts.BatchAsyncStatusFailed || result != consts.BatchRunResultFailed {
|
||||
t.Fatalf("parentBatchResult() = %d, %d; want failed, failed", status, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateChildValidationDoesNotReportExecutedSuccess(t *testing.T) {
|
||||
task := &Task{Batch: Batch{BatchNo: "child", ParentBatchNo: "parent"}}
|
||||
detail := &Detail{ID: 1, ICCID: "valid"}
|
||||
_, args := buildDetailUpdateStatement(task, []*Detail{detail}, gtime.Now())
|
||||
if args[1] != consts.BatchDetailStatusRunning {
|
||||
t.Fatalf("validated child status = %v, want running until parent commits", args[1])
|
||||
}
|
||||
task.Batch.ParentBatchNo = ""
|
||||
_, args = buildDetailUpdateStatement(task, []*Detail{detail}, gtime.Now())
|
||||
if args[1] != consts.BatchDetailStatusSuccess {
|
||||
t.Fatalf("committed parent status = %v, want success", args[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTaskCollectsAllCardFailuresBeforeBlockingBatch(t *testing.T) {
|
||||
task := &Task{
|
||||
Batch: Batch{CreatedDept: 100, TargetDeptID: 200},
|
||||
TargetsByID: map[int64]*Target{200: {DeptID: 200, ParentID: 100}},
|
||||
Details: []*Detail{
|
||||
{ICCID: "missing"},
|
||||
{ICCID: "valid", Card: &Card{ICCID: "valid", DeptID: 100}},
|
||||
{ICCID: "stopped", Card: &Card{ICCID: "stopped", DeptID: 100, BossCardStatus: consts.SimBossCardStatusStopped, PackageStatus: consts.PackageStatusExpired}},
|
||||
},
|
||||
}
|
||||
validateTask(task)
|
||||
if task.Details[0].Reason != "ICCID不存在或不属于当前租户" {
|
||||
t.Fatalf("first failure was lost: %q", task.Details[0].Reason)
|
||||
}
|
||||
if task.Details[1].Reason != atomicNotExecutedReason || allocationDetailStatus(task.Details[1]) != consts.BatchDetailStatusNotExecuted {
|
||||
t.Fatalf("validated card was not blocked: %+v", task.Details[1])
|
||||
}
|
||||
for _, reason := range []string{"卡已停机,不能划拨", "套餐已过期,不能划拨"} {
|
||||
if !strings.Contains(task.Details[2].Reason, reason) {
|
||||
t.Fatalf("later card was not fully validated: %q", task.Details[2].Reason)
|
||||
}
|
||||
}
|
||||
if successes, failures := normalizeAtomicParentResult(task); successes != 0 || failures != 2 {
|
||||
t.Fatalf("got successes=%d failures=%d, want 0 and 2", successes, failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDetailAllowsCurrentHolderReallocation(t *testing.T) {
|
||||
task := &Task{
|
||||
Batch: Batch{
|
||||
|
||||
@@ -283,6 +283,9 @@ func (r *Repository) Commit(ctx context.Context, task *Task) error {
|
||||
markAtomicFailure(task, "划拨批次存在失败明细,整批未执行")
|
||||
}
|
||||
successCount, failCount := countResult(task)
|
||||
if failCount > 0 {
|
||||
failCount = countValidationFailures(task)
|
||||
}
|
||||
autoSplitChild := strings.TrimSpace(task.Batch.ParentBatchNo) != ""
|
||||
var artifact batchresult.Artifact
|
||||
var artifactErr error
|
||||
@@ -443,7 +446,7 @@ func (r *Repository) aggregateParentOnce(ctx context.Context, parentBatchNo stri
|
||||
finalInfo["successCount"] = successCount
|
||||
finalInfo["failCount"] = failCount
|
||||
startTime := resolveAllocateParentStartTime(parent.StartTime, aggregate.EarliestStartTime, now)
|
||||
data := do.BatchMain{StartTime: startTime, BatchAsyncStatus: status, RunResult: runResult, TotalCount: len(task.Details), ValidCount: successCount, SuccessCount: successCount, FailCount: failCount, FailReason: failReason, BatchInfo: mergeBatchInfo(gjson.New(finalInfo), ossID, downloadID, artifactErr != nil), EndTime: now, UpdatedBy: parent.CreatedBy, UpdatedAt: now}
|
||||
data := do.BatchMain{StartTime: startTime, BatchAsyncStatus: status, RunResult: runResult, TotalCount: len(task.Details), ValidCount: len(task.Details) - failCount, SuccessCount: successCount, FailCount: failCount, FailReason: failReason, BatchInfo: mergeBatchInfo(gjson.New(finalInfo), ossID, downloadID, artifactErr != nil), EndTime: now, UpdatedBy: parent.CreatedBy, UpdatedAt: now}
|
||||
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where(mainColumns.Id, parent.Id).WhereIn(mainColumns.BatchAsyncStatus, []int{consts.BatchAsyncStatusPending, consts.BatchAsyncStatusProcessing}).Data(data).Update()
|
||||
if err != nil {
|
||||
if artifactErr == nil {
|
||||
@@ -536,7 +539,7 @@ func normalizeAtomicParentResult(task *Task) (int, int) {
|
||||
}
|
||||
task.FailureReason = coalesce(task.FailureReason, "划拨批次存在失败明细,整批未执行")
|
||||
markAtomicFailure(task, task.FailureReason)
|
||||
return 0, len(task.Details)
|
||||
return 0, countValidationFailures(task)
|
||||
}
|
||||
|
||||
func (r *Repository) updateDetails(ctx context.Context, tx gdb.TX, task *Task, now *gtime.Time) error {
|
||||
@@ -644,10 +647,7 @@ func validatePersistedDetailResults(task *Task, details []*Detail, persisted []*
|
||||
}
|
||||
for _, detail := range details {
|
||||
row := byID[detail.ID]
|
||||
status := consts.BatchDetailStatusFailed
|
||||
if detail.Success() {
|
||||
status = consts.BatchDetailStatusSuccess
|
||||
}
|
||||
status := persistedAllocationDetailStatus(task, detail)
|
||||
if row == nil || row.DetailStatus != status || row.ErrorReason != detail.Reason || row.UpdatedBy != task.Batch.CreatedBy || row.Snapshot == nil || !sameDetailSnapshot(row.Snapshot, detail.Snapshot(task.Batch)) {
|
||||
return gerror.Newf("划拨明细最终结果不一致,明细ID:%d", detail.ID)
|
||||
}
|
||||
@@ -669,12 +669,9 @@ func buildDetailUpdateStatement(task *Task, details []*Detail, now *gtime.Time)
|
||||
columns := dao.BatchDetail.Columns()
|
||||
var builder strings.Builder
|
||||
fmt.Fprintf(&builder, "UPDATE %s SET %s = CASE %s ", dao.BatchDetail.Table(), columns.DetailStatus, columns.Id)
|
||||
args := make([]any, 0, len(details)*7+8)
|
||||
args := make([]any, 0, len(details)*7+9)
|
||||
for _, detail := range details {
|
||||
status := consts.BatchDetailStatusFailed
|
||||
if detail.Success() {
|
||||
status = consts.BatchDetailStatusSuccess
|
||||
}
|
||||
status := persistedAllocationDetailStatus(task, detail)
|
||||
builder.WriteString("WHEN ? THEN ? ")
|
||||
args = append(args, detail.ID, status)
|
||||
}
|
||||
@@ -690,7 +687,7 @@ func buildDetailUpdateStatement(task *Task, details []*Detail, now *gtime.Time)
|
||||
}
|
||||
fmt.Fprintf(&builder, "ELSE %s END, %s = ?, %s = ? ", columns.Snapshot, columns.UpdatedBy, columns.UpdatedAt)
|
||||
args = append(args, task.Batch.CreatedBy, now)
|
||||
fmt.Fprintf(&builder, "WHERE %s = ? AND %s = ? AND %s = ? AND %s IN (?, ?, ?, ?) AND %s IN (%s)",
|
||||
fmt.Fprintf(&builder, "WHERE %s = ? AND %s = ? AND %s = ? AND %s IN (?, ?, ?, ?, ?) AND %s IN (%s)",
|
||||
columns.BatchNo,
|
||||
columns.BatchType,
|
||||
columns.TenantId,
|
||||
@@ -706,6 +703,7 @@ func buildDetailUpdateStatement(task *Task, details []*Detail, now *gtime.Time)
|
||||
consts.BatchDetailStatusRunning,
|
||||
consts.BatchDetailStatusSuccess,
|
||||
consts.BatchDetailStatusFailed,
|
||||
consts.BatchDetailStatusNotExecuted,
|
||||
)
|
||||
for _, detail := range details {
|
||||
args = append(args, detail.ID)
|
||||
@@ -845,10 +843,7 @@ func (r *Repository) updateDetail(ctx context.Context, tx gdb.TX, task *Task, de
|
||||
detail.DetailStatus != consts.BatchDetailStatusRunning) {
|
||||
return nil
|
||||
}
|
||||
status := consts.BatchDetailStatusFailed
|
||||
if detail.Success() {
|
||||
status = consts.BatchDetailStatusSuccess
|
||||
}
|
||||
status := persistedAllocationDetailStatus(task, detail)
|
||||
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().Id, detail.ID).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
|
||||
@@ -949,7 +944,7 @@ func (r *Repository) finalizeBatch(ctx context.Context, tx gdb.TX, task *Task, s
|
||||
BatchAsyncStatus: batchAsyncStatus,
|
||||
RunResult: runResult,
|
||||
TotalCount: len(task.Details),
|
||||
ValidCount: successCount,
|
||||
ValidCount: len(task.Details) - failCount,
|
||||
SuccessCount: successCount,
|
||||
FailCount: failCount,
|
||||
FailReason: task.FailureReason,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
@@ -155,8 +156,19 @@ func (t *Task) ResultRows() []batchresult.Row {
|
||||
TargetDeptName: targetName,
|
||||
Operation: "卡划拨",
|
||||
Success: detail.Success(),
|
||||
Status: allocationResultStatus(detail),
|
||||
Reason: strings.TrimSpace(detail.Reason),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func allocationResultStatus(detail *Detail) string {
|
||||
if allocationDetailStatus(detail) == consts.BatchDetailStatusNotExecuted {
|
||||
return "未执行"
|
||||
}
|
||||
if detail.Success() {
|
||||
return "成功"
|
||||
}
|
||||
return "失败"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package batchresult
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/xuri/excelize/v2"
|
||||
@@ -32,7 +35,9 @@ type Row struct {
|
||||
TargetStatus int
|
||||
Operation string
|
||||
ApnName string
|
||||
DetailStatus int
|
||||
Success bool
|
||||
Status string
|
||||
Reason string
|
||||
UpstreamCode int
|
||||
OrderNum string
|
||||
@@ -88,13 +93,27 @@ func (gtr *generator) Create(ctx context.Context, request Request) (Artifact, er
|
||||
if err = os.MkdirAll(directory, 0o755); err != nil {
|
||||
return Artifact{}, gerror.Wrap(err, "create batch result directory")
|
||||
}
|
||||
fileName := request.BatchNo + "-result.xlsx"
|
||||
originalName := request.BatchNo + "-result.xlsx"
|
||||
var nonce [16]byte
|
||||
if _, err = rand.Read(nonce[:]); err != nil {
|
||||
return Artifact{}, gerror.Wrap(err, "generate batch result file name")
|
||||
}
|
||||
fileName := request.BatchNo + "-result-" + hex.EncodeToString(nonce[:]) + ".xlsx"
|
||||
filePath := filepath.Join(directory, fileName)
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o666)
|
||||
if err != nil {
|
||||
return Artifact{}, gerror.Wrap(err, "reserve batch result file")
|
||||
}
|
||||
if err = file.Close(); err != nil {
|
||||
_ = os.Remove(filePath)
|
||||
return Artifact{}, gerror.Wrap(err, "close reserved result file")
|
||||
}
|
||||
logicalPath := filepath.Join(uploadPath, objectDir, fileName)
|
||||
if err = writeWorkbook(filePath, request); err != nil {
|
||||
_ = os.Remove(filePath)
|
||||
return Artifact{}, err
|
||||
}
|
||||
artifact, err := inspectArtifact(ctx, filePath, logicalPath, fileName, tenantID)
|
||||
artifact, err := inspectArtifact(ctx, filePath, logicalPath, fileName, originalName, tenantID)
|
||||
if err != nil {
|
||||
_ = os.Remove(filePath)
|
||||
return Artifact{}, err
|
||||
@@ -262,6 +281,9 @@ func writeAllocateWorkbook(file *excelize.File, sheet, path string, request Requ
|
||||
if row.Success {
|
||||
status = "成功"
|
||||
}
|
||||
if row.Status != "" {
|
||||
status = row.Status
|
||||
}
|
||||
values := []interface{}{row.RowNo, row.ICCID, row.MSISDN, row.IMSI, row.TargetDeptName, status, row.Reason}
|
||||
cell, err := excelize.CoordinatesToCellName(1, index+2)
|
||||
if err != nil {
|
||||
@@ -387,6 +409,9 @@ func writeInboundWorkbook(file *excelize.File, sheet, path string, request Reque
|
||||
if row.Success {
|
||||
status = "成功"
|
||||
}
|
||||
if row.Status != "" {
|
||||
status = row.Status
|
||||
}
|
||||
values := []interface{}{row.RowNo, row.ICCID, status, row.Reason}
|
||||
cell, err := excelize.CoordinatesToCellName(1, index+2)
|
||||
if err != nil {
|
||||
@@ -480,13 +505,15 @@ func writeCancelWorkbook(file *excelize.File, sheet, path string, request Reques
|
||||
}
|
||||
|
||||
func writeDataServiceWorkbook(file *excelize.File, sheet, path string, request Request) error {
|
||||
headers := []interface{}{"行号", "ICCID", "MSISDN", "IMSI", "操作", "APN", "执行结果", "失败原因", "上游状态码", "上游订单号"}
|
||||
headers := []interface{}{"行号", "ICCID", "MSISDN", "IMSI", "操作", "APN", "执行结果", "失败/待核查原因", "上游状态码", "上游订单号"}
|
||||
if err := file.SetSheetRow(sheet, "A1", &headers); err != nil {
|
||||
return gerror.Wrap(err, "write data service result headers")
|
||||
}
|
||||
for index, row := range request.Rows {
|
||||
status := "失败"
|
||||
if row.Success {
|
||||
if row.DetailStatus == consts.BatchDetailStatusUnknown {
|
||||
status = "待核查"
|
||||
} else if row.Success {
|
||||
status = "成功"
|
||||
}
|
||||
values := []interface{}{row.RowNo, row.ICCID, row.MSISDN, row.IMSI, row.Operation, row.ApnName, status, row.Reason, row.UpstreamCode, row.OrderNum}
|
||||
@@ -513,7 +540,7 @@ func writeDataServiceWorkbook(file *excelize.File, sheet, path string, request R
|
||||
return nil
|
||||
}
|
||||
|
||||
func inspectArtifact(ctx context.Context, localPath, logicalPath, fileName string, tenantID string) (Artifact, error) {
|
||||
func inspectArtifact(ctx context.Context, localPath, logicalPath, fileName, originalName, tenantID string) (Artifact, error) {
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return Artifact{}, gerror.Wrap(err, "open result file")
|
||||
@@ -526,7 +553,7 @@ func inspectArtifact(ctx context.Context, localPath, logicalPath, fileName strin
|
||||
}
|
||||
return Artifact{
|
||||
FileName: fileName,
|
||||
OriginalName: fileName,
|
||||
OriginalName: originalName,
|
||||
FileSuffix: "xlsx",
|
||||
Path: logicalPath,
|
||||
LocalPath: localPath,
|
||||
|
||||
@@ -4,13 +4,189 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestWriteDataServiceWorkbookPreservesReviewStatusAndReason(t *testing.T) {
|
||||
for _, batchType := range []int{consts.BatchTypeDataCut, consts.BatchTypeDataStart, consts.BatchTypeDataStop} {
|
||||
t.Run(TaskName(batchType), func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "result.xlsx")
|
||||
rows := []Row{
|
||||
{RowNo: 1, ICCID: "test-success", DetailStatus: consts.BatchDetailStatusSuccess, Success: true},
|
||||
{RowNo: 2, ICCID: "test-failed", DetailStatus: consts.BatchDetailStatusFailed, Reason: "上游明确拒绝"},
|
||||
{RowNo: 3, ICCID: "test-unknown", DetailStatus: consts.BatchDetailStatusUnknown, Reason: "查询超时,尚未确认上游执行结果", UpstreamCode: 202, OrderNum: "test-job-id"},
|
||||
{RowNo: 4, ICCID: "test-review-priority", DetailStatus: consts.BatchDetailStatusUnknown, Success: true, Reason: "明细仍待核查"},
|
||||
{RowNo: 5, ICCID: "test-legacy-success", Success: true},
|
||||
{RowNo: 6, ICCID: "test-legacy-failed", Reason: "未受理"},
|
||||
}
|
||||
if err := writeWorkbook(path, Request{BatchType: batchType, Rows: rows}); err != nil {
|
||||
t.Fatalf("写入数据服务结果失败:%v", err)
|
||||
}
|
||||
file, err := excelize.OpenFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("打开数据服务结果失败:%v", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
for cell, want := range map[string]string{
|
||||
"G1": "执行结果", "H1": "失败/待核查原因",
|
||||
"G2": "成功", "G3": "失败", "H3": "上游明确拒绝",
|
||||
"G4": "待核查", "H4": rows[2].Reason, "I4": "202", "J4": "test-job-id",
|
||||
"G5": "待核查", "H5": rows[3].Reason,
|
||||
"G6": "成功", "G7": "失败", "H7": "未受理",
|
||||
} {
|
||||
got, err := file.GetCellValue("处理结果", cell)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("单元格 %s = %q,预期 %q,错误:%v", cell, got, want, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSameBatchUsesIndependentResultFiles(t *testing.T) {
|
||||
// Keep the generated workbook path below Excel's 207-character limit on Windows.
|
||||
root, rootErr := os.MkdirTemp("", "batch-result-")
|
||||
if rootErr != nil {
|
||||
t.Fatal(rootErr)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
t.Errorf("remove test result directory: %v", err)
|
||||
}
|
||||
})
|
||||
t.Setenv("IOTLCM_WORKER_UPLOAD_ROOT", root)
|
||||
oldAdapter := g.Cfg().GetAdapter()
|
||||
adapter, err := gcfg.NewAdapterContent(`
|
||||
system:
|
||||
upload:
|
||||
local:
|
||||
path: "resource/upload"
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(oldAdapter) })
|
||||
request := Request{BatchNo: "DATA-REVIEW", BatchType: consts.BatchTypeDataStart, TenantID: "tenant", Rows: []Row{{RowNo: 1, ICCID: "first-card", DetailStatus: consts.BatchDetailStatusUnknown, Reason: "待核查"}}}
|
||||
requests := []Request{request, request}
|
||||
requests[1].Rows = []Row{{RowNo: 1, ICCID: "second-card", DetailStatus: consts.BatchDetailStatusUnknown, Reason: "待核查"}}
|
||||
artifacts := make([]Artifact, 2)
|
||||
errors := make([]error, 2)
|
||||
var workers sync.WaitGroup
|
||||
for index := range requests {
|
||||
workers.Add(1)
|
||||
go func(index int) {
|
||||
defer workers.Done()
|
||||
artifacts[index], errors[index] = New().Create(context.Background(), requests[index])
|
||||
}(index)
|
||||
}
|
||||
workers.Wait()
|
||||
for _, err := range errors {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
first, second := artifacts[0], artifacts[1]
|
||||
defer RemoveLocalFile(first)
|
||||
defer RemoveLocalFile(second)
|
||||
if first.Path == second.Path || first.LocalPath == second.LocalPath || first.FileName == second.FileName {
|
||||
t.Fatalf("同批结果物理文件冲突: first=%+v second=%+v", first, second)
|
||||
}
|
||||
if first.OriginalName != "DATA-REVIEW-result.xlsx" || second.OriginalName != first.OriginalName {
|
||||
t.Fatalf("用户下载原始文件名被更改: %q / %q", first.OriginalName, second.OriginalName)
|
||||
}
|
||||
RemoveLocalFile(first)
|
||||
if _, err := os.Stat(first.LocalPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("首次生成文件未删除: %v", err)
|
||||
}
|
||||
file, err := excelize.OpenFile(second.LocalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("删除第一份后第二份不可读: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
got, err := file.GetCellValue("处理结果", "B2")
|
||||
if err != nil || got != "second-card" {
|
||||
t.Fatalf("第二份结果内容损坏: B2=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePreservesWorkbookAndPermissionsForAllBatchTypes(t *testing.T) {
|
||||
root, err := os.MkdirTemp("", "batchresult-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
||||
t.Setenv("IOTLCM_WORKER_UPLOAD_ROOT", root)
|
||||
oldAdapter := g.Cfg().GetAdapter()
|
||||
adapter, err := gcfg.NewAdapterContent(`
|
||||
system:
|
||||
upload:
|
||||
local:
|
||||
path: "resource/upload"
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(oldAdapter) })
|
||||
for _, batchType := range []int{1, 3, 4, 11, 12, 40, 41, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110} {
|
||||
t.Run(strconv.Itoa(batchType), func(t *testing.T) {
|
||||
request := Request{BatchNo: "COMPAT-" + strconv.Itoa(batchType), BatchType: batchType, TenantID: "tenant", Rows: []Row{
|
||||
{RowNo: 1, ICCID: "test-success", ObjectCode: "test-success", Success: true},
|
||||
{RowNo: 2, ICCID: "test-failed", ObjectCode: "test-failed", Reason: "原有失败原因"},
|
||||
}}
|
||||
baseline := filepath.Join(t.TempDir(), "baseline.xlsx")
|
||||
if err := writeWorkbook(baseline, request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artifact, err := New().Create(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer RemoveLocalFile(artifact)
|
||||
if artifact.OriginalName != request.BatchNo+"-result.xlsx" || filepath.Ext(artifact.LocalPath) != ".xlsx" {
|
||||
t.Fatalf("结果文件名或后缀发生变化: %+v", artifact)
|
||||
}
|
||||
baselineInfo, err := os.Stat(baseline)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actualInfo, err := os.Stat(artifact.LocalPath)
|
||||
if err != nil || actualInfo.Mode().Perm() != baselineInfo.Mode().Perm() {
|
||||
t.Fatalf("结果文件权限未保留原SaveAs行为: %v", err)
|
||||
}
|
||||
before, err := excelize.OpenFile(baseline)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer before.Close()
|
||||
after, err := excelize.OpenFile(artifact.LocalPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer after.Close()
|
||||
want, err := before.GetRows("处理结果")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := after.GetRows("处理结果")
|
||||
if err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("随机物理名改变了批次%d工作簿内容: err=%v", batchType, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWritesToMountedUploadAndKeepsLogicalPath(t *testing.T) {
|
||||
wd := t.TempDir()
|
||||
oldWd, err := os.Getwd()
|
||||
@@ -44,7 +220,7 @@ system:
|
||||
BatchNo: "INB202608140001001",
|
||||
BatchType: 1,
|
||||
TenantID: "000000",
|
||||
Rows: []Row{{RowNo: 1, ICCID: "89860401780000567308", Success: true}},
|
||||
Rows: []Row{{RowNo: 1, ICCID: "89860401786527567308", Success: true}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
cascadeFailureReason = "same batch has invalid cancellation rows; this row was skipped"
|
||||
validationSkipReason = "batch validation failed; cancellation was skipped"
|
||||
emptyICCIDReason = "ICCID cannot be empty"
|
||||
duplicateICCIDReason = "duplicate ICCID in batch"
|
||||
cascadeFailureReason = "同一批次存在无效销户明细,该行已跳过"
|
||||
validationSkipReason = "批次校验未通过,已跳过取消操作"
|
||||
emptyICCIDReason = "ICCID不能为空"
|
||||
duplicateICCIDReason = "ICCID在批次内重复"
|
||||
)
|
||||
|
||||
type executor struct {
|
||||
@@ -77,7 +77,7 @@ func (e *executor) saveResultFile(ctx context.Context, task *Task) {
|
||||
func validateTask(task *Task) bool {
|
||||
if task == nil || len(task.Details) == 0 {
|
||||
if task != nil {
|
||||
task.FailureReason = "cancel batch has no valid details"
|
||||
task.FailureReason = "销户批次无有效明细"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
// 首查 +3min,此后每 3min 重试 3 次(+3/+6/+9/+12min)。
|
||||
// 2026-10-15 潍坊、濮阳实测:10/30/100 卡共12笔首次可见终态约 65~225s;
|
||||
// 15s 采样只能给出完成区间,不能据此承诺所有高峰时段的完成上限。
|
||||
// 偶发超长任务不增加自动重试;超窗记 review,保留受理号和原因供手动查询。
|
||||
// 未确认不得记办理失败,也不得重新提交办理请求。
|
||||
const (
|
||||
subBatchLease = 5 * time.Minute
|
||||
firstResultDelay = 3 * time.Minute
|
||||
furtherResultDelay = 3 * time.Minute
|
||||
maxResultQueries = 4
|
||||
)
|
||||
|
||||
func newSubBatch(task *Task, details []*Detail) SubBatch {
|
||||
keys := make([]*model.SimKey, 0, len(details))
|
||||
ids := make([]int64, 0, len(details))
|
||||
for _, detail := range details {
|
||||
ids = append(ids, detail.ID)
|
||||
keys = append(keys, &model.SimKey{ChanKey: detail.ChanKey, ICCID: detail.ICCID, MSISDN: detail.MSISDN, IMSI: detail.IMSI})
|
||||
}
|
||||
raw, _ := json.Marshal(struct {
|
||||
BatchNo string
|
||||
BatchType int
|
||||
IDs []int64
|
||||
}{task.Batch.BatchNo, task.Batch.BatchType, ids})
|
||||
digest := sha256.Sum256(raw)
|
||||
id := hex.EncodeToString(digest[:])
|
||||
input := model.SimCardApnChangeInput{SimKeys: keys, ReqData: model.SimCardApnChangeInputItem{OperType: task.Operation.ApnOperType, ApnName: task.ApnName, OperationID: id, SubmitOnly: true}}
|
||||
return SubBatch{BatchNo: task.Batch.BatchNo, BatchType: task.Batch.BatchType, ChanKey: details[0].ChanKey, OperationID: id, DetailIDs: ids, Request: input, Status: "prepared"}
|
||||
}
|
||||
|
||||
func splitAsyncDetails(details []*Detail) [][]*Detail {
|
||||
withMSISDN := make([]*Detail, 0, len(details))
|
||||
batches := make([][]*Detail, 0)
|
||||
for _, detail := range details {
|
||||
if strings.TrimSpace(detail.MSISDN) == "" {
|
||||
batches = append(batches, []*Detail{detail})
|
||||
} else {
|
||||
withMSISDN = append(withMSISDN, detail)
|
||||
}
|
||||
}
|
||||
for _, chunk := range splitDetails(withMSISDN, consts.BatchSubBatchSize) {
|
||||
if len(chunk) < 10 {
|
||||
for _, detail := range chunk {
|
||||
batches = append(batches, []*Detail{detail})
|
||||
}
|
||||
} else {
|
||||
batches = append(batches, chunk)
|
||||
}
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
// prepareAndSubmit persists every intent before a single write leaves the process.
|
||||
func (e *executor) prepareAndSubmit(ctx context.Context, task *Task, repo SubBatchRepository, upstream AsyncUpstream) error {
|
||||
groups := make(map[string][]*Detail)
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && detail.Reason == "" && (detail.DetailStatus == 0 || detail.DetailStatus == consts.BatchDetailStatusRunning) {
|
||||
groups[detail.ChanKey] = append(groups[detail.ChanKey], detail)
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(groups))
|
||||
for key := range groups {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
for _, slice := range splitAsyncDetails(groups[key]) {
|
||||
sub := newSubBatch(task, slice)
|
||||
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
|
||||
return fmt.Errorf("保存APN提交意图失败(未提交运营商): %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processSubBatch only submits while prepared. Any interrupted submission is recovered by operationId, never repeated.
|
||||
func (e *executor) processSubBatch(ctx context.Context, repo SubBatchRepository, upstream AsyncUpstream, operationID string) error {
|
||||
channel, err := repo.SubBatchChannel(ctx, operationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, _ := e.channelSlots.LoadOrStore(channel, make(chan struct{}, consts.BatchSubBatchConcurrency))
|
||||
slots := value.(chan struct{})
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
defer func() { <-slots }()
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
token := uuid.NewString()
|
||||
callCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
|
||||
defer cancel()
|
||||
if checker, ok := upstream.(interface {
|
||||
CheckSimApnAsyncCapability(context.Context, *model.SimCardApnChangeInput) error
|
||||
}); ok {
|
||||
snapshot, err := repo.GetSubBatch(callCtx, operationID)
|
||||
if err != nil || snapshot == nil {
|
||||
return err
|
||||
}
|
||||
if snapshot.Status == "done" || snapshot.Status == "review" {
|
||||
return nil
|
||||
}
|
||||
if snapshot.Status == "prepared" {
|
||||
if err := checker.CheckSimApnAsyncCapability(callCtx, &snapshot.Request); err != nil {
|
||||
persistCtx, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer stop()
|
||||
if delayErr := repo.DelaySubBatchPrecheck(persistCtx, *snapshot, time.Now().Add(30*time.Second), err.Error()); delayErr != nil {
|
||||
return fmt.Errorf("保存APN预检调度退避失败: %w", delayErr)
|
||||
}
|
||||
return fmt.Errorf("APN提交预检未就绪,未领取提交意图: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
sub, err := repo.ClaimSubBatch(ctx, operationID, token, time.Now())
|
||||
if err != nil || sub == nil {
|
||||
return err
|
||||
}
|
||||
if sub.Status == "prepared" {
|
||||
response, submitErr := upstream.SubmitSimApnFunction(callCtx, &sub.Request)
|
||||
if submitErr != nil {
|
||||
if isAPNRequestNotSent(submitErr) {
|
||||
return releaseUnsent(ctx, repo, *sub, submitErr)
|
||||
}
|
||||
return e.rememberUncertain(ctx, repo, *sub, "提交结果未确认(网络超时或连接中断): "+submitErr.Error())
|
||||
}
|
||||
return e.handleResponse(ctx, repo, *sub, response, false)
|
||||
}
|
||||
if sub.Status == "submitting" || sub.Status == "uncertain" || (sub.Status == "accepted" && sub.JobID == "") {
|
||||
sub.Request.ReqData.RecoverOnly = true
|
||||
sub.Request.ReqData.QueryJobID = ""
|
||||
response, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request)
|
||||
if queryErr != nil {
|
||||
if isAPNRequestNotSent(queryErr) {
|
||||
return releaseUnsent(ctx, repo, *sub, queryErr)
|
||||
}
|
||||
return e.rememberUncertain(ctx, repo, *sub, "受理映射恢复失败(不重发): "+queryErr.Error())
|
||||
}
|
||||
return e.handleResponse(ctx, repo, *sub, response, false)
|
||||
}
|
||||
sub.Request.ReqData.QueryJobID = sub.JobID
|
||||
sub.Request.ReqData.RecoverOnly = false
|
||||
response, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request)
|
||||
if queryErr != nil {
|
||||
if isAPNRequestNotSent(queryErr) {
|
||||
return releaseUnsent(ctx, repo, *sub, queryErr)
|
||||
}
|
||||
return e.queryUnavailable(ctx, repo, *sub, "查询请求超时或不可用: "+queryErr.Error())
|
||||
}
|
||||
return e.handleResponse(ctx, repo, *sub, response, true)
|
||||
}
|
||||
|
||||
// QueryReview仅执行一次只读补查,不重新开启自动轮询或提交办理。
|
||||
func (e *executor) QueryReview(ctx context.Context, operationID string) error {
|
||||
operationID = strings.TrimSpace(operationID)
|
||||
if operationID == "" {
|
||||
return fmt.Errorf("operationId不能为空")
|
||||
}
|
||||
repo, ok := e.repository.(SubBatchRepository)
|
||||
if !ok {
|
||||
return fmt.Errorf("未配置APN持久化仓储")
|
||||
}
|
||||
reviewRepo, ok := e.repository.(ReviewRepository)
|
||||
if !ok {
|
||||
return fmt.Errorf("未配置APN待核查查询仓储")
|
||||
}
|
||||
upstream, ok := e.upstream.(AsyncUpstream)
|
||||
if !ok {
|
||||
return fmt.Errorf("未配置APN只读补查客户端")
|
||||
}
|
||||
sub, err := reviewRepo.ClaimReviewSubBatch(ctx, operationID, uuid.NewString(), time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sub == nil {
|
||||
snapshot, err := repo.GetSubBatch(ctx, operationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if snapshot != nil && snapshot.Status == "done" {
|
||||
return repo.FinalizeReadyBatch(ctx, snapshot.BatchNo)
|
||||
}
|
||||
return fmt.Errorf("子批不处于可查询的待核查状态,或已有查询/汇总正在运行")
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
|
||||
defer cancel()
|
||||
sub.Request.ReqData.SubmitOnly = false
|
||||
sub.Request.ReqData.QueryJobID = sub.JobID
|
||||
sub.Request.ReqData.RecoverOnly = sub.JobID == ""
|
||||
items, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request)
|
||||
if queryErr != nil {
|
||||
if err := e.deferResult(ctx, repo, *sub, "review", "手动补查未确认:"+queryErr.Error()); err != nil {
|
||||
return err
|
||||
}
|
||||
return queryErr
|
||||
}
|
||||
return e.handleResponse(ctx, repo, *sub, items, true)
|
||||
}
|
||||
|
||||
// QueryReviewBatch 是"手动同步上游结果"入口:只对父批下已冻结的待核查子批
|
||||
// 做一次只读补查,然后尝试汇总父批。
|
||||
// 不重发办理请求、不清零自动补查计数、不重新开启轮询调度。
|
||||
func (e *executor) QueryReviewBatch(ctx context.Context, batchNo string) (int, error) {
|
||||
batchNo = strings.TrimSpace(batchNo)
|
||||
if batchNo == "" {
|
||||
return 0, fmt.Errorf("批次编号不能为空")
|
||||
}
|
||||
repo, ok := e.repository.(ReviewBatchRepository)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("未配置APN待核查批量同步仓储")
|
||||
}
|
||||
if _, ok := e.upstream.(AsyncUpstream); !ok {
|
||||
return 0, fmt.Errorf("未配置APN只读补查客户端")
|
||||
}
|
||||
ids, err := repo.ListReviewSubBatches(ctx, batchNo)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
queried := 0
|
||||
var firstErr error
|
||||
for _, id := range ids {
|
||||
if err := e.QueryReview(ctx, id); err != nil {
|
||||
g.Log().Warningf(ctx, "手动同步待核查子批未完成 batchNo=%s operationId=%s error=%v", batchNo, id, err)
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("operationId=%s: %w", id, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
queried++
|
||||
}
|
||||
// 已确认终态的子批会在这里收敛父批;仍为 review 的子批继续留在待核查。
|
||||
if err := repo.FinalizeReadyBatch(ctx, batchNo); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
g.Log().Infof(ctx, "手动同步APN上游结果 batchNo=%s 待核查子批=%d 本次已同步=%d", batchNo, len(ids), queried)
|
||||
if firstErr != nil {
|
||||
return queried, firstErr
|
||||
}
|
||||
return queried, nil
|
||||
}
|
||||
|
||||
func isAPNRequestNotSent(err error) bool {
|
||||
var notSent interface{ APNRequestNotSent() bool }
|
||||
return errors.As(err, ¬Sent) && notSent.APNRequestNotSent()
|
||||
}
|
||||
func releaseUnsent(ctx context.Context, repo SubBatchRepository, sub SubBatch, cause error) error {
|
||||
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
return repo.ReleaseUnsentSubBatch(persistCtx, sub, cause.Error())
|
||||
}
|
||||
|
||||
func nextResultCheck(sub SubBatch) time.Time {
|
||||
base := sub.SubmittedAt
|
||||
if base.IsZero() {
|
||||
base = time.Now()
|
||||
}
|
||||
return base.Add(firstResultDelay + time.Duration(sub.PollCount)*furtherResultDelay)
|
||||
}
|
||||
|
||||
func (e *executor) rememberUncertain(ctx context.Context, repo SubBatchRepository, sub SubBatch, reason string) error {
|
||||
return e.deferResult(ctx, repo, sub, "uncertain", reason)
|
||||
}
|
||||
|
||||
func (e *executor) queryUnavailable(ctx context.Context, repo SubBatchRepository, sub SubBatch, reason string) error {
|
||||
return e.deferResult(ctx, repo, sub, "accepted", reason)
|
||||
}
|
||||
|
||||
func (e *executor) deferResult(ctx context.Context, repo SubBatchRepository, sub SubBatch, state, reason string) error {
|
||||
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
g.Log().Warningf(persistCtx, "APN补查 operationId=%s jobId=%s pollCount=%d reason=%s", sub.OperationID, sub.JobID, sub.PollCount, reason)
|
||||
if sub.Status == "review_query" || sub.PollCount >= maxResultQueries {
|
||||
if err := repo.UpdateSubBatch(persistCtx, sub, "review", sub.JobID, "补查窗口结束,办理结果未知:"+reason, time.Time{}, sub.PollCount); err != nil {
|
||||
return err
|
||||
}
|
||||
return repo.FinalizeReadyBatch(persistCtx, sub.BatchNo)
|
||||
}
|
||||
return repo.UpdateSubBatch(persistCtx, sub, state, sub.JobID, reason, nextResultCheck(sub), sub.PollCount)
|
||||
}
|
||||
|
||||
func (e *executor) handleResponse(ctx context.Context, repo SubBatchRepository, sub SubBatch, items []model.SimBaseRes[model.SimCardApnChangeOutputItem], queried bool) error {
|
||||
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
|
||||
defer cancel()
|
||||
outcomes := make([]Outcome, len(sub.Request.SimKeys))
|
||||
used := make([]bool, len(items))
|
||||
pending := false
|
||||
reason := "未返回该卡结果,办理未确认"
|
||||
for i, key := range sub.Request.SimKeys {
|
||||
matched := -1
|
||||
for j, item := range items {
|
||||
if !used[j] && sameSim(&Detail{ICCID: key.ICCID, MSISDN: key.MSISDN, IMSI: key.IMSI}, item.SimKey) {
|
||||
matched = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched < 0 {
|
||||
outcomes[i] = Outcome{Pending: true, Message: reason}
|
||||
pending = true
|
||||
continue
|
||||
}
|
||||
used[matched] = true
|
||||
item := items[matched]
|
||||
if item.ResData.Pending || item.ResData.Uncertain {
|
||||
pending = true
|
||||
outcomes[i] = Outcome{Pending: true, Code: item.Code, Message: item.Message}
|
||||
if strings.TrimSpace(item.Message) != "" {
|
||||
reason = item.Message
|
||||
}
|
||||
// Only Pending identifies a batch job: a synchronous per-card order is never queried as a job.
|
||||
if item.ResData.Pending && strings.TrimSpace(item.ResData.OrderNum) != "" {
|
||||
sub.JobID = strings.TrimSpace(item.ResData.OrderNum)
|
||||
}
|
||||
continue
|
||||
}
|
||||
outcomes[i] = Outcome{Success: item.Code == 0, Code: item.Code, Message: item.Message, OrderNum: item.ResData.OrderNum}
|
||||
}
|
||||
if pending {
|
||||
// Confirmed cards are committed now. Later missing/pending responses cannot overwrite their terminal rows.
|
||||
if err := repo.SaveSubBatchProgress(persistCtx, sub, outcomes); err != nil {
|
||||
return err
|
||||
}
|
||||
state := "accepted"
|
||||
if sub.JobID == "" {
|
||||
state = "uncertain"
|
||||
reason += "(缺少jobId,仅按operationId恢复,不重发)"
|
||||
}
|
||||
return e.deferResult(persistCtx, repo, sub, state, reason)
|
||||
}
|
||||
if err := repo.CompleteSubBatch(persistCtx, sub, outcomes); err != nil {
|
||||
return err
|
||||
}
|
||||
return repo.FinalizeReadyBatch(persistCtx, sub.BatchNo)
|
||||
}
|
||||
|
||||
func mapOutcomesFromKeys(keys []*model.SimKey, response []model.SimBaseRes[model.SimCardApnChangeOutputItem]) []Outcome {
|
||||
details := make([]*Detail, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
details = append(details, &Detail{ICCID: key.ICCID, MSISDN: key.MSISDN, IMSI: key.IMSI})
|
||||
}
|
||||
return mapOutcomes(details, response)
|
||||
}
|
||||
|
||||
// RunReconciler uses a fixed global worker bound; waiting for a job never retains a worker slot.
|
||||
func (e *executor) RunReconciler(ctx context.Context) error {
|
||||
repo, ok := e.repository.(SubBatchRepository)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
upstream, ok := e.upstream.(AsyncUpstream)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
jobs := make(chan string, 64)
|
||||
var inFlight sync.Map
|
||||
var workers sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case id := <-jobs:
|
||||
if err := e.processSubBatch(ctx, repo, upstream, id); err != nil {
|
||||
g.Log().Errorf(ctx, "APN恢复失败 operationId=%s error=%v", id, err)
|
||||
}
|
||||
inFlight.Delete(id)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
defer workers.Wait()
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
batches, err := repo.ListRecoverableBatches(ctx, 32)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "读取APN恢复父批失败: %v", err)
|
||||
} else {
|
||||
for _, batchNo := range batches {
|
||||
if err := e.Execute(ctx, batchNo); err != nil {
|
||||
g.Log().Errorf(ctx, "恢复APN父批失败 batchNo=%s error=%v", batchNo, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
ids, err := repo.ListDueSubBatches(ctx, time.Now(), 128)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "读取APN补查子批失败: %v", err)
|
||||
} else {
|
||||
for _, id := range ids {
|
||||
if _, loaded := inFlight.LoadOrStore(id, true); loaded {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case jobs <- id:
|
||||
case <-ctx.Done():
|
||||
inFlight.Delete(id)
|
||||
return nil
|
||||
default:
|
||||
inFlight.Delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
type fairnessRepository struct{ *durableFake }
|
||||
|
||||
func (f *fairnessRepository) Load(context.Context, string) (*Task, error) {
|
||||
return nil, errors.New("公平性测试不应准备父批")
|
||||
}
|
||||
func (f *fairnessRepository) SaveValidationFailure(context.Context, *Task) error { return nil }
|
||||
func (f *fairnessRepository) SaveResult(context.Context, *Task, *Detail, Outcome) (Outcome, error) {
|
||||
return Outcome{}, nil
|
||||
}
|
||||
func (f *fairnessRepository) Finalize(context.Context, *Task) error { return nil }
|
||||
|
||||
type fairnessUpstream struct {
|
||||
asyncFake
|
||||
rejected atomic.Int32
|
||||
}
|
||||
|
||||
func (f *fairnessUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return nil, errors.New("调度不得走同步办理")
|
||||
}
|
||||
|
||||
func (f *fairnessUpstream) CheckSimApnAsyncCapability(_ context.Context, in *model.SimCardApnChangeInput) error {
|
||||
if in.SimKeys[0].ChanKey == "removed" {
|
||||
f.rejected.Add(1)
|
||||
return errors.New("通道已移除,无protocolVersion")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDurableAPNPrecheckBackoffAllowsHealthy129thTask(t *testing.T) {
|
||||
repo := newDurableFake()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
for i := 0; i < 129; i++ {
|
||||
sub := fixtureSub()
|
||||
sub.OperationID = fmt.Sprintf("fair-%03d", i)
|
||||
sub.Request.ReqData.OperationID = sub.OperationID
|
||||
sub.ChanKey = "removed"
|
||||
if i == 128 {
|
||||
sub.ChanKey = "healthy"
|
||||
}
|
||||
for _, key := range sub.Request.SimKeys {
|
||||
key.ChanKey = sub.ChanKey
|
||||
}
|
||||
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
completed := make(chan struct{}, 1)
|
||||
upstream := &fairnessUpstream{}
|
||||
upstream.submit = func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if in.SimKeys[0].ChanKey != "healthy" {
|
||||
t.Error("拒绝通道发出办理")
|
||||
}
|
||||
out := pendingResponse(in, 0)
|
||||
for i := range out {
|
||||
out[i].ResData.Pending = false
|
||||
}
|
||||
completed <- struct{}{}
|
||||
return out, nil
|
||||
}
|
||||
e := &executor{repository: &fairnessRepository{repo}, upstream: upstream}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- e.RunReconciler(ctx) }()
|
||||
select {
|
||||
case <-completed:
|
||||
case <-time.After(15 * time.Second):
|
||||
cancel()
|
||||
<-done
|
||||
t.Fatal("前128个预检拒绝使第129健康任务饥饿")
|
||||
}
|
||||
// 健康提交可能早于已入队的拒绝任务完成;等待退避持久化后再取消调度器。
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
timeout := time.NewTimer(15 * time.Second)
|
||||
defer timeout.Stop()
|
||||
for {
|
||||
repo.mu.Lock()
|
||||
delayed := 0
|
||||
for id, sub := range repo.batches {
|
||||
if sub.ChanKey == "removed" && !repo.next[id].IsZero() {
|
||||
delayed++
|
||||
}
|
||||
}
|
||||
repo.mu.Unlock()
|
||||
if delayed == 128 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-timeout.C:
|
||||
cancel()
|
||||
<-done
|
||||
t.Fatalf("拒绝任务退避未全部持久化: delayed=%d rejected=%d", delayed, upstream.rejected.Load())
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.submitCalls != 1 || upstream.rejected.Load() != 128 {
|
||||
t.Fatalf("submit=%d rejected=%d", upstream.submitCalls, upstream.rejected.Load())
|
||||
}
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
for id, sub := range repo.batches {
|
||||
if sub.ChanKey == "healthy" {
|
||||
continue
|
||||
}
|
||||
if sub.Status != "prepared" || sub.PollCount != 0 || !sub.SubmittedAt.IsZero() || sub.LeaseToken != "" || repo.next[id].IsZero() {
|
||||
t.Fatalf("只读退避改变办理意图: %+v", sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
// reviewBatchFake 复用 durableFake 的持久化语义,只补手动同步需要的仓储能力。
|
||||
type reviewBatchFake struct {
|
||||
*durableFake
|
||||
candidates []string
|
||||
finalized []string
|
||||
}
|
||||
|
||||
func newReviewBatchFake(candidates ...string) *reviewBatchFake {
|
||||
return &reviewBatchFake{durableFake: newDurableFake(), candidates: candidates}
|
||||
}
|
||||
|
||||
func (f *reviewBatchFake) ListReviewSubBatches(_ context.Context, _ string) ([]string, error) {
|
||||
return append([]string(nil), f.candidates...), nil
|
||||
}
|
||||
|
||||
// 手动同步不触碰校验/终态写入路径,这里只补齐 Repository 契约。
|
||||
func (f *reviewBatchFake) Load(context.Context, string) (*Task, error) { return &Task{}, nil }
|
||||
func (f *reviewBatchFake) SaveValidationFailure(context.Context, *Task) error { return nil }
|
||||
func (f *reviewBatchFake) SaveResult(context.Context, *Task, *Detail, Outcome) (Outcome, error) {
|
||||
return Outcome{}, nil
|
||||
}
|
||||
func (f *reviewBatchFake) Finalize(context.Context, *Task) error { return nil }
|
||||
|
||||
// manualSyncUpstream 补上同步办理入口,确保手动同步一旦误走同步路径立即暴露。
|
||||
type manualSyncUpstream struct {
|
||||
*asyncFake
|
||||
}
|
||||
|
||||
func (u *manualSyncUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return nil, errors.New("手动同步不得调用同步办理入口")
|
||||
}
|
||||
|
||||
func (f *reviewBatchFake) FinalizeReadyBatch(_ context.Context, batchNo string) error {
|
||||
f.finalized = append(f.finalized, batchNo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *reviewBatchFake) ClaimReviewSubBatch(_ context.Context, operationID, token string, now time.Time) (*SubBatch, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
sub, ok := f.batches[operationID]
|
||||
if !ok || (sub.Status != "review" && sub.Status != "review_query") {
|
||||
return nil, nil
|
||||
}
|
||||
sub.Status = "review_query"
|
||||
sub.LeaseToken = token
|
||||
sub.LeaseUntil = now.Add(subBatchLease)
|
||||
f.batches[operationID] = sub
|
||||
out := sub
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// 手动同步只用既有受理号只读补查:不重发办理、不清零自动补查计数。
|
||||
func TestManualSyncQueriesFrozenIntentsWithoutResubmitting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sub := fixtureSub()
|
||||
repo := newReviewBatchFake(sub.OperationID)
|
||||
sub.Status = "review"
|
||||
sub.JobID = "job-1"
|
||||
sub.PollCount = 4
|
||||
repo.batches[sub.OperationID] = sub
|
||||
|
||||
upstream := &manualSyncUpstream{asyncFake: &asyncFake{
|
||||
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
t.Fatal("手动同步不得重发办理")
|
||||
return nil, nil
|
||||
},
|
||||
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if in.ReqData.QueryJobID != "job-1" {
|
||||
t.Fatalf("手动同步必须按既有 jobId 查询: %+v", in.ReqData)
|
||||
}
|
||||
out := make([]model.SimBaseRes[model.SimCardApnChangeOutputItem], 0, len(in.SimKeys))
|
||||
for _, key := range in.SimKeys {
|
||||
out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: 0, ResData: model.SimCardApnChangeOutputItem{OrderNum: "job-1"}})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
}}
|
||||
executor := &executor{repository: repo, upstream: upstream}
|
||||
|
||||
queried, err := executor.QueryReviewBatch(ctx, " P-1 ")
|
||||
if err != nil {
|
||||
t.Fatalf("QueryReviewBatch() error = %v", err)
|
||||
}
|
||||
if queried != 1 {
|
||||
t.Fatalf("queried = %d, want 1", queried)
|
||||
}
|
||||
if upstream.submitCalls != 0 {
|
||||
t.Fatalf("submitCalls = %d, want 0", upstream.submitCalls)
|
||||
}
|
||||
got := repo.batches[sub.OperationID]
|
||||
if got.Status != "done" {
|
||||
t.Fatalf("状态 = %s, want done", got.Status)
|
||||
}
|
||||
if got.PollCount != 4 {
|
||||
t.Fatalf("pollCount = %d, want 4(手动同步不得清零自动补查计数)", got.PollCount)
|
||||
}
|
||||
// 子批收敛与批量入口都会尝试汇总父批,重复汇总是幂等的。
|
||||
if len(repo.finalized) == 0 || repo.finalized[0] != "P-1" {
|
||||
t.Fatalf("父批未汇总: %+v", repo.finalized)
|
||||
}
|
||||
if len(repo.outcomes[sub.OperationID]) != len(sub.DetailIDs) {
|
||||
t.Fatalf("逐卡结果未写回: %+v", repo.outcomes[sub.OperationID])
|
||||
}
|
||||
}
|
||||
|
||||
// 上游仍无结论时明细保持待核查,且可以再次手动同步。
|
||||
func TestManualSyncKeepsUnknownWhenUpstreamStillPending(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sub := fixtureSub()
|
||||
repo := newReviewBatchFake(sub.OperationID)
|
||||
sub.Status = "review"
|
||||
repo.batches[sub.OperationID] = sub
|
||||
|
||||
upstream := &manualSyncUpstream{asyncFake: &asyncFake{
|
||||
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
t.Fatal("手动同步不得重发办理")
|
||||
return nil, nil
|
||||
},
|
||||
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return pendingResponse(in, 14008), nil
|
||||
},
|
||||
}}
|
||||
executor := &executor{repository: repo, upstream: upstream}
|
||||
|
||||
if _, err := executor.QueryReviewBatch(ctx, "P-1"); err != nil {
|
||||
t.Fatalf("QueryReviewBatch() error = %v", err)
|
||||
}
|
||||
got := repo.batches[sub.OperationID]
|
||||
if got.Status != "review" {
|
||||
t.Fatalf("状态 = %s, want review(未确认不得记失败)", got.Status)
|
||||
}
|
||||
if got.JobID != "job-1" {
|
||||
t.Fatalf("jobId = %s, want job-1", got.JobID)
|
||||
}
|
||||
if got.PollCount != 0 {
|
||||
t.Fatalf("pollCount = %d, want 0(手动同步不得占用自动补查次数)", got.PollCount)
|
||||
}
|
||||
// 再次同步仍可用,说明没有被自动窗口锁死。
|
||||
if _, err := executor.QueryReviewBatch(ctx, "P-1"); err != nil {
|
||||
t.Fatalf("第二次 QueryReviewBatch() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 单个子批补查失败不影响其余子批,并把失败如实返回给调用方。
|
||||
func TestManualSyncReportsPerIntentFailureAndContinues(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
first := fixtureSub()
|
||||
first.Status = "review"
|
||||
first.JobID = "job-1"
|
||||
second := fixtureSub()
|
||||
second.OperationID = "intent-2"
|
||||
second.Status = "review"
|
||||
repo := newReviewBatchFake(first.OperationID, second.OperationID)
|
||||
repo.batches[first.OperationID] = first
|
||||
repo.batches[second.OperationID] = second
|
||||
|
||||
upstream := &manualSyncUpstream{asyncFake: &asyncFake{
|
||||
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
t.Fatal("手动同步不得重发办理")
|
||||
return nil, nil
|
||||
},
|
||||
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if in.ReqData.RecoverOnly {
|
||||
return nil, errors.New("上游查询不可用")
|
||||
}
|
||||
return pendingResponse(in, 0), nil
|
||||
},
|
||||
}}
|
||||
executor := &executor{repository: repo, upstream: upstream}
|
||||
|
||||
queried, err := executor.QueryReviewBatch(ctx, "P-1")
|
||||
if err == nil {
|
||||
t.Fatal("QueryReviewBatch() error = nil, want error")
|
||||
}
|
||||
if queried != 1 {
|
||||
t.Fatalf("queried = %d, want 1(成功一个,失败一个)", queried)
|
||||
}
|
||||
if upstream.submitCalls != 0 {
|
||||
t.Fatalf("submitCalls = %d, want 0", upstream.submitCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// 空批次号直接拒绝,避免误触发全量扫描。
|
||||
func TestManualSyncRejectsBlankBatchNo(t *testing.T) {
|
||||
executor := &executor{repository: newReviewBatchFake(), upstream: &manualSyncUpstream{asyncFake: &asyncFake{}}}
|
||||
if _, err := executor.QueryReviewBatch(context.Background(), " "); err == nil {
|
||||
t.Fatal("QueryReviewBatch() blank batchNo error = nil, want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
upstreamclient "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/client/upstream"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestDurableAPNResultScheduleIsThreeMinutesAndThreeRetries(t *testing.T) {
|
||||
if maxResultQueries != 4 {
|
||||
t.Fatalf("首次加三次重试应为四次查询: got=%d", maxResultQueries)
|
||||
}
|
||||
base := time.Date(2026, 10, 15, 8, 0, 0, 0, time.UTC)
|
||||
for i, minute := range []int{3, 6, 9, 12} {
|
||||
got := nextResultCheck(SubBatch{SubmittedAt: base, PollCount: i})
|
||||
want := base.Add(time.Duration(minute) * time.Minute)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("第%d次查询: got=%v want=%v", i+1, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type durableFake struct {
|
||||
mu sync.Mutex
|
||||
batches map[string]SubBatch
|
||||
next map[string]time.Time
|
||||
outcomes map[string][]Outcome
|
||||
preparedBeforeSubmit int
|
||||
}
|
||||
|
||||
func newDurableFake() *durableFake {
|
||||
return &durableFake{batches: map[string]SubBatch{}, next: map[string]time.Time{}, outcomes: map[string][]Outcome{}}
|
||||
}
|
||||
func (f *durableFake) HasDurableParent(context.Context, string) (bool, error) { return true, nil }
|
||||
func (f *durableFake) PrepareSubBatch(_ context.Context, b SubBatch) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.batches[b.OperationID]; !ok {
|
||||
b.ID = int64(len(f.batches) + 1)
|
||||
f.batches[b.OperationID] = b
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *durableFake) ResumeOrPrepare(ctx context.Context, _ string, p func(context.Context) error) error {
|
||||
return p(ctx)
|
||||
}
|
||||
func (f *durableFake) GetSubBatch(_ context.Context, id string) (*SubBatch, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
b := f.batches[id]
|
||||
b.NextCheckAt = f.next[id]
|
||||
return &b, nil
|
||||
}
|
||||
func (f *durableFake) DelaySubBatchPrecheck(_ context.Context, snapshot SubBatch, next time.Time, _ string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
b := f.batches[snapshot.OperationID]
|
||||
if b.Status == snapshot.Status && b.LeaseToken == snapshot.LeaseToken && f.next[snapshot.OperationID].Equal(snapshot.NextCheckAt) {
|
||||
f.next[snapshot.OperationID] = next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *durableFake) ReleaseUnsentSubBatch(_ context.Context, b SubBatch, _ string) error {
|
||||
old := f.batches[b.OperationID]
|
||||
old.Status = b.Status
|
||||
old.LeaseToken = ""
|
||||
if b.Status == "prepared" {
|
||||
old.SubmittedAt = time.Time{}
|
||||
} else {
|
||||
old.PollCount--
|
||||
}
|
||||
f.batches[b.OperationID] = old
|
||||
return nil
|
||||
}
|
||||
func (f *durableFake) SubBatchChannel(_ context.Context, id string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.batches[id].ChanKey, nil
|
||||
}
|
||||
func (f *durableFake) ProtectExecutionFailure(context.Context, string, string) error { return nil }
|
||||
func (f *durableFake) SaveSubBatchProgress(_ context.Context, b SubBatch, results []Outcome) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
old := f.outcomes[b.OperationID]
|
||||
if old == nil {
|
||||
old = make([]Outcome, len(results))
|
||||
for i := range old {
|
||||
old[i].Pending = true
|
||||
}
|
||||
}
|
||||
for i, result := range results {
|
||||
if old[i].Pending && !result.Pending {
|
||||
old[i] = result
|
||||
}
|
||||
}
|
||||
confirmed := false
|
||||
for _, result := range old {
|
||||
confirmed = confirmed || !result.Pending
|
||||
}
|
||||
if confirmed {
|
||||
f.outcomes[b.OperationID] = old
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *durableFake) ClaimSubBatch(_ context.Context, id, token string, now time.Time) (*SubBatch, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
b, ok := f.batches[id]
|
||||
if !ok || b.LeaseToken != "" || b.Status == "done" || b.Status == "review" || f.next[id].After(now) {
|
||||
return nil, nil
|
||||
}
|
||||
out := b
|
||||
b.LeaseToken = token
|
||||
out.LeaseToken = token
|
||||
if b.Status == "prepared" {
|
||||
b.Status = "submitting"
|
||||
b.SubmittedAt = now
|
||||
out.SubmittedAt = now
|
||||
} else {
|
||||
b.PollCount++
|
||||
out.PollCount = b.PollCount
|
||||
}
|
||||
f.batches[id] = b
|
||||
return &out, nil
|
||||
}
|
||||
func (f *durableFake) ListDueSubBatches(_ context.Context, now time.Time, limit int) ([]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var ids []string
|
||||
for id, b := range f.batches {
|
||||
if b.Status != "done" && b.Status != "review" && !f.next[id].After(now) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
a, b := f.next[ids[i]], f.next[ids[j]]
|
||||
if a.Equal(b) {
|
||||
return ids[i] < ids[j]
|
||||
}
|
||||
return a.Before(b)
|
||||
})
|
||||
if len(ids) > limit {
|
||||
ids = ids[:limit]
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
func (f *durableFake) UpdateSubBatch(_ context.Context, b SubBatch, status, job, reason string, next time.Time, count int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
old := f.batches[b.OperationID]
|
||||
if old.LeaseToken != b.LeaseToken {
|
||||
return errors.New("lease lost")
|
||||
}
|
||||
old.Status = status
|
||||
old.JobID = job
|
||||
old.PollCount = count
|
||||
old.LeaseToken = ""
|
||||
f.batches[b.OperationID] = old
|
||||
f.next[b.OperationID] = next
|
||||
return nil
|
||||
}
|
||||
func (f *durableFake) CompleteSubBatch(_ context.Context, b SubBatch, results []Outcome) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
old := f.batches[b.OperationID]
|
||||
if old.LeaseToken != b.LeaseToken {
|
||||
return errors.New("lease lost")
|
||||
}
|
||||
old.Status = "done"
|
||||
old.LeaseToken = ""
|
||||
f.batches[b.OperationID] = old
|
||||
f.outcomes[b.OperationID] = results
|
||||
return nil
|
||||
}
|
||||
func (f *durableFake) FinalizeReadyBatch(context.Context, string) error { return nil }
|
||||
func (f *durableFake) ListRecoverableBatches(context.Context, int) ([]string, error) { return nil, nil }
|
||||
|
||||
type asyncFake struct {
|
||||
submitCalls int
|
||||
queryCalls int
|
||||
submit func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
|
||||
query func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
|
||||
}
|
||||
|
||||
func (f *asyncFake) SubmitSimApnFunction(_ context.Context, in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
f.submitCalls++
|
||||
return f.submit(in)
|
||||
}
|
||||
func (f *asyncFake) QuerySimApnFunctionResult(_ context.Context, in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
f.queryCalls++
|
||||
return f.query(in)
|
||||
}
|
||||
func fixtureSub() SubBatch {
|
||||
op, _ := OperationByBatchType(consts.BatchTypeDataStop)
|
||||
return newSubBatch(&Task{Batch: Batch{BatchNo: "P-1", BatchType: consts.BatchTypeDataStop}, Operation: op, ApnName: "CMIOT"}, []*Detail{{ID: 1, ICCID: "iccid-1", MSISDN: "1001", ChanKey: "a"}, {ID: 2, ICCID: "iccid-2", MSISDN: "1002", ChanKey: "a"}})
|
||||
}
|
||||
func pendingResponse(in *model.SimCardApnChangeInput, code int) []model.SimBaseRes[model.SimCardApnChangeOutputItem] {
|
||||
var out []model.SimBaseRes[model.SimCardApnChangeOutputItem]
|
||||
for _, key := range in.SimKeys {
|
||||
out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: code, Message: "结果暂不可查", ResData: model.SimCardApnChangeOutputItem{OrderNum: "job-1", Pending: true}})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestDurableAPNRestartDoesNotSubmitAcceptedAgain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if repo.batches[sub.OperationID].Status != "submitting" {
|
||||
t.Fatal("提交前未保存意图")
|
||||
}
|
||||
return pendingResponse(in, 0), nil
|
||||
}, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if in.ReqData.QueryJobID != "job-1" {
|
||||
t.Fatalf("查询丢失jobId: %+v", in.ReqData)
|
||||
}
|
||||
out := pendingResponse(in, 0)
|
||||
out[0].ResData.Pending = false
|
||||
out[1].ResData.Pending = false
|
||||
out[1].Code = 12047
|
||||
out[1].Message = "明确失败"
|
||||
return out, nil
|
||||
}}
|
||||
e := &executor{}
|
||||
before := time.Now()
|
||||
if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.next[sub.OperationID].Before(before.Add(firstResultDelay)) || repo.next[sub.OperationID].After(time.Now().Add(firstResultDelay)) {
|
||||
t.Fatalf("首查未按firstResultDelay排程: %v", repo.next[sub.OperationID])
|
||||
}
|
||||
// A second process must respect next_check_at and never issue a second write.
|
||||
restarted := &executor{}
|
||||
if err := restarted.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.queryCalls != 0 {
|
||||
t.Fatal("过早查询")
|
||||
}
|
||||
repo.next[sub.OperationID] = time.Time{}
|
||||
if err := restarted.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.submitCalls != 1 || upstream.queryCalls != 1 {
|
||||
t.Fatalf("submit=%d query=%d", upstream.submitCalls, upstream.queryCalls)
|
||||
}
|
||||
outcomes := repo.outcomes[sub.OperationID]
|
||||
if len(outcomes) != 2 || !outcomes[0].Success || outcomes[1].Success {
|
||||
t.Fatalf("部分失败未逐卡保留: %+v", outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNTimeoutRecoversMappingWithoutResubmission(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
}, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if !in.ReqData.RecoverOnly || in.ReqData.QueryJobID != "" || in.ReqData.OperationID != sub.OperationID {
|
||||
t.Fatalf("不是按operationId恢复: %+v", in.ReqData)
|
||||
}
|
||||
return pendingResponse(in, 0), nil
|
||||
}}
|
||||
e := &executor{}
|
||||
if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.outcomes) != 0 {
|
||||
t.Fatal("超时不能标记办理失败")
|
||||
}
|
||||
repo.next[sub.OperationID] = time.Time{}
|
||||
if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.submitCalls != 1 || upstream.queryCalls != 1 || repo.batches[sub.OperationID].JobID != "job-1" {
|
||||
t.Fatalf("受理恢复错误: %+v", repo.batches[sub.OperationID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNPendingNonzeroStopsAfterQueryWindowWithoutFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
sub.Status = "accepted"
|
||||
sub.JobID = "job-1"
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return pendingResponse(in, 500), nil
|
||||
}}
|
||||
for i := 1; i <= maxResultQueries; i++ {
|
||||
repo.next[sub.OperationID] = time.Time{}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := repo.batches[sub.OperationID]
|
||||
if b.PollCount != i {
|
||||
t.Fatalf("poll count=%d want %d", b.PollCount, i)
|
||||
}
|
||||
if i < maxResultQueries && b.Status != "accepted" {
|
||||
t.Fatalf("pending误终态: %+v", b)
|
||||
}
|
||||
}
|
||||
if repo.batches[sub.OperationID].Status != "review" || len(repo.outcomes) != 0 {
|
||||
t.Fatal("补查窗口耗尽后未确认不得写办理失败")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNMixedResultsPersistAcrossMissingAndReview(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
out := pendingResponse(in, 0)
|
||||
out[0].ResData.Pending = false
|
||||
out[0].ResData.OrderNum = "single-order"
|
||||
return out, nil
|
||||
}, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return nil, nil
|
||||
}}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := repo.outcomes[sub.OperationID]; len(got) != 2 || !got[0].Success || !got[1].Pending {
|
||||
t.Fatalf("混合确认结果未立即保存: %+v", got)
|
||||
}
|
||||
base := repo.batches[sub.OperationID].SubmittedAt
|
||||
for i := 1; i <= maxResultQueries; i++ {
|
||||
repo.next[sub.OperationID] = time.Time{}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := repo.batches[sub.OperationID]
|
||||
if b.PollCount != i || !b.SubmittedAt.Equal(base) {
|
||||
t.Fatalf("重启丢失持久时点/次数: %+v", b)
|
||||
}
|
||||
if i < maxResultQueries && !repo.next[sub.OperationID].Equal(base.Add(firstResultDelay+time.Duration(i)*furtherResultDelay)) {
|
||||
t.Fatalf("排程未按submitted_at: %v", repo.next[sub.OperationID])
|
||||
}
|
||||
}
|
||||
if !repo.outcomes[sub.OperationID][0].Success || repo.batches[sub.OperationID].Status != "review" || upstream.submitCalls != 1 {
|
||||
t.Fatal("后续缺卡覆盖终态或重发")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNMappingRecoveryKeepsConsumedPollCount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
sub.Status = "uncertain"
|
||||
sub.PollCount = 2
|
||||
sub.SubmittedAt = time.Now().Add(-8 * time.Minute)
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if !in.ReqData.RecoverOnly {
|
||||
t.Fatal("恢复应为只读")
|
||||
}
|
||||
return pendingResponse(in, 0), nil
|
||||
}}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := repo.batches[sub.OperationID]
|
||||
if b.JobID != "job-1" || b.PollCount != 3 || !repo.next[sub.OperationID].Equal(sub.SubmittedAt.Add(firstResultDelay+time.Duration(b.PollCount)*furtherResultDelay)) {
|
||||
t.Fatalf("恢复jobId清零或重排: %+v next=%v", b, repo.next[sub.OperationID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNExplicitUncertainAndMissingNeverFailCard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return []model.SimBaseRes[model.SimCardApnChangeOutputItem]{{SimKey: in.SimKeys[0], Code: 9999, Message: "代理未确认", ResData: model.SimCardApnChangeOutputItem{Uncertain: true}}}, nil
|
||||
}}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.outcomes) != 0 || repo.batches[sub.OperationID].Status != "uncertain" {
|
||||
t.Fatal("显式未知或缺卡不能转明确失败")
|
||||
}
|
||||
}
|
||||
|
||||
type capabilityFake struct {
|
||||
*asyncFake
|
||||
checkErr error
|
||||
}
|
||||
|
||||
func (f *capabilityFake) CheckSimApnAsyncCapability(context.Context, *model.SimCardApnChangeInput) error {
|
||||
return f.checkErr
|
||||
}
|
||||
|
||||
type requestNotSent struct{}
|
||||
|
||||
func (requestNotSent) Error() string { return "能力预检明确拒绝" }
|
||||
func (requestNotSent) APNRequestNotSent() bool { return true }
|
||||
|
||||
func TestDurableAPNCapabilityRefusalDoesNotClaimOrSpendQueries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &capabilityFake{asyncFake: &asyncFake{}, checkErr: errors.New("门禁关闭")}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err == nil {
|
||||
t.Fatal("应拒绝预检")
|
||||
}
|
||||
b := repo.batches[sub.OperationID]
|
||||
if b.Status != "prepared" || b.LeaseToken != "" || !b.SubmittedAt.IsZero() || b.PollCount != 0 || upstream.submitCalls != 0 {
|
||||
t.Fatalf("预检拒绝却已领取或提交: %+v", b)
|
||||
}
|
||||
upstream.checkErr = nil
|
||||
upstream.submit = func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return pendingResponse(in, 0), nil
|
||||
}
|
||||
if upstream.submitCalls != 0 || !repo.next[sub.OperationID].After(time.Now()) {
|
||||
t.Fatal("只读预检拒绝未持久退避")
|
||||
}
|
||||
repo.next[sub.OperationID] = time.Time{}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.submitCalls != 1 || repo.batches[sub.OperationID].Status != "accepted" {
|
||||
t.Fatal("门禁打开且退避到期后不能恢复首发")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNCapabilityRefusalDoesNotBlockReadOnlyQueries(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
status string
|
||||
jobID string
|
||||
recoverOnly bool
|
||||
}{
|
||||
{name: "accepted_with_job_id", status: "accepted", jobID: "job-1"},
|
||||
{name: "submitting", status: "submitting", recoverOnly: true},
|
||||
{name: "uncertain", status: "uncertain", recoverOnly: true},
|
||||
{name: "accepted_without_job_id", status: "accepted", recoverOnly: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
sub.Status = tt.status
|
||||
sub.JobID = tt.jobID
|
||||
sub.SubmittedAt = time.Now().Add(-firstResultDelay)
|
||||
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
upstream := &capabilityFake{
|
||||
checkErr: errors.New("门禁关闭"),
|
||||
asyncFake: &asyncFake{
|
||||
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
t.Fatal("只读补查不得重新提交")
|
||||
return nil, nil
|
||||
},
|
||||
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
if in.ReqData.OperationID != sub.OperationID || in.ReqData.QueryJobID != tt.jobID || in.ReqData.RecoverOnly != tt.recoverOnly {
|
||||
t.Fatalf("只读查询参数错误: %+v", in.ReqData)
|
||||
}
|
||||
return pendingResponse(in, 0), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatalf("只读查询被提交门禁阻断: %v", err)
|
||||
}
|
||||
if upstream.queryCalls != 1 || upstream.submitCalls != 0 {
|
||||
t.Fatalf("应仅查询一次: query=%d submit=%d", upstream.queryCalls, upstream.submitCalls)
|
||||
}
|
||||
b := repo.batches[sub.OperationID]
|
||||
if b.Status != "accepted" || b.JobID != "job-1" || b.PollCount != 1 || b.LeaseToken != "" {
|
||||
t.Fatalf("只读查询结果未持久化: %+v", b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNOnlyTypedUnsentCanReturnPrepared(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return nil, fmt.Errorf("预检竞态: %w", requestNotSent{})
|
||||
}}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := repo.batches[sub.OperationID]
|
||||
if b.Status != "prepared" || !b.SubmittedAt.IsZero() || b.PollCount != 0 {
|
||||
t.Fatalf("确定未发送应安全回退: %+v", b)
|
||||
}
|
||||
upstream.submit = func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.batches[sub.OperationID].Status != "uncertain" {
|
||||
t.Fatal("普通网络错误被误作确定未发送")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNFinishedFailure9999IsTerminal(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
sub.Status = "accepted"
|
||||
sub.JobID = "failed-job"
|
||||
sub.SubmittedAt = time.Now().Add(-time.Minute)
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
var out []model.SimBaseRes[model.SimCardApnChangeOutputItem]
|
||||
for _, key := range in.SimKeys {
|
||||
out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: 9999, Message: "任务已完成且明确失败"})
|
||||
}
|
||||
return out, nil
|
||||
}}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.batches[sub.OperationID].Status != "done" || len(repo.outcomes[sub.OperationID]) != 2 {
|
||||
t.Fatal("明确9999被误判待查")
|
||||
}
|
||||
for _, out := range repo.outcomes[sub.OperationID] {
|
||||
if out.Pending || out.Success || out.Code != 9999 {
|
||||
t.Fatalf("明确失败终态错误: %+v", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAPNRealClientSecondProbeRejectsThenSubmitsOnce(t *testing.T) {
|
||||
oldAdapter := g.Cfg().GetAdapter()
|
||||
defer g.Cfg().SetAdapter(oldAdapter)
|
||||
ctx := context.Background()
|
||||
repo := newDurableFake()
|
||||
sub := fixtureSub()
|
||||
_ = repo.PrepareSubBatch(ctx, sub)
|
||||
probes, submits := 0, 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var in model.SimCardApnChangeInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
t.Error(err)
|
||||
http.Error(w, "bad", 400)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/sim/query/sim-apn-capability" {
|
||||
probes++
|
||||
if probes == 2 {
|
||||
http.Error(w, "第二次探测拒绝", 404)
|
||||
return
|
||||
}
|
||||
type capability struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
}
|
||||
out := make([]model.SimBaseRes[capability], 0, len(in.SimKeys))
|
||||
for _, key := range in.SimKeys {
|
||||
out = append(out, model.SimBaseRes[capability]{SimKey: key, ResData: capability{ProtocolVersion: 1}})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": out})
|
||||
return
|
||||
}
|
||||
submits++
|
||||
out := pendingResponse(&in, 0)
|
||||
for i := range out {
|
||||
out[i].ResData.Pending = false
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": out})
|
||||
}))
|
||||
defer server.Close()
|
||||
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":1}}}`, strings.TrimPrefix(server.URL, "http://")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
client := upstreamclient.New()
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := repo.batches[sub.OperationID]
|
||||
if probes != 2 || submits != 0 || b.Status != "prepared" || !b.SubmittedAt.IsZero() || b.LeaseToken != "" {
|
||||
t.Fatalf("双probe竞态烧掉意图: probes=%d submits=%d sub=%+v", probes, submits, b)
|
||||
}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if submits != 1 || repo.batches[sub.OperationID].Status != "done" {
|
||||
t.Fatalf("门禁恢复未唯一首发: submits=%d sub=%+v", submits, repo.batches[sub.OperationID])
|
||||
}
|
||||
if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if submits != 1 {
|
||||
t.Fatal("完成后重复首发")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncSubBatchStableAndChannelIsolated(t *testing.T) {
|
||||
op, _ := OperationByBatchType(consts.BatchTypeDataStart)
|
||||
task := &Task{Batch: Batch{BatchNo: "P-2", BatchType: consts.BatchTypeDataStart}, Operation: op}
|
||||
groups := map[string][]*Detail{"a": {}, "b": {}}
|
||||
for i := 0; i < 203; i++ {
|
||||
key := "a"
|
||||
if i >= 201 {
|
||||
key = "b"
|
||||
}
|
||||
groups[key] = append(groups[key], &Detail{ID: int64(i + 1), ICCID: fmt.Sprint(i), MSISDN: fmt.Sprint(i), ChanKey: key})
|
||||
}
|
||||
total := 0
|
||||
for key, details := range groups {
|
||||
for _, chunk := range splitAsyncDetails(details) {
|
||||
if len(chunk) > 100 {
|
||||
t.Fatal("超过100卡")
|
||||
}
|
||||
first := newSubBatch(task, chunk)
|
||||
second := newSubBatch(task, chunk)
|
||||
if first.OperationID != second.OperationID {
|
||||
t.Fatal("operationId不稳定")
|
||||
}
|
||||
for _, sim := range first.Request.SimKeys {
|
||||
if sim.ChanKey != key {
|
||||
t.Fatal("跨通道子批")
|
||||
}
|
||||
}
|
||||
total++
|
||||
}
|
||||
}
|
||||
if total != 5 {
|
||||
t.Fatalf("sub batches=%d want5", total)
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,34 @@ import (
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
type executor struct {
|
||||
repository Repository
|
||||
upstream Upstream
|
||||
repository Repository
|
||||
upstream Upstream
|
||||
channelSlots sync.Map
|
||||
}
|
||||
|
||||
func (e *executor) ClaimsProcessingReentry() bool { return true }
|
||||
func (e *executor) CanExecuteProcessing(ctx context.Context, main *entity.BatchMain) (bool, error) {
|
||||
repo, durable := e.repository.(SubBatchRepository)
|
||||
_, async := e.upstream.(AsyncUpstream)
|
||||
if !durable || !async {
|
||||
return false, nil
|
||||
}
|
||||
return repo.HasDurableParent(ctx, strings.TrimSpace(main.BatchNo))
|
||||
}
|
||||
func (e *executor) ProtectExecutionFailure(ctx context.Context, batchNo string, batchType int, reason string) (bool, error) {
|
||||
repo, durable := e.repository.(SubBatchRepository)
|
||||
_, async := e.upstream.(AsyncUpstream)
|
||||
if !durable || !async {
|
||||
return false, nil
|
||||
}
|
||||
return true, repo.ProtectExecutionFailure(ctx, batchNo, reason)
|
||||
}
|
||||
|
||||
func NewExecutor(repository Repository, upstream Upstream) Executor {
|
||||
@@ -23,6 +44,18 @@ func NewExecutor(repository Repository, upstream Upstream) Executor {
|
||||
}
|
||||
|
||||
func (e *executor) Execute(ctx context.Context, batchNo string) error {
|
||||
if durable, ok := e.repository.(SubBatchRepository); ok {
|
||||
if _, ok := e.upstream.(AsyncUpstream); ok {
|
||||
// Earlier child intents were durably frozen: a re-delivered parent cannot re-split running rows.
|
||||
return batch.Retryable(durable.ResumeOrPrepare(ctx, strings.TrimSpace(batchNo), func(prepareCtx context.Context) error {
|
||||
return e.executeFresh(prepareCtx, batchNo)
|
||||
}))
|
||||
}
|
||||
}
|
||||
return e.executeFresh(ctx, batchNo)
|
||||
}
|
||||
|
||||
func (e *executor) executeFresh(ctx context.Context, batchNo string) error {
|
||||
task, err := e.repository.Load(ctx, strings.TrimSpace(batchNo))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "load data service batch")
|
||||
@@ -34,6 +67,11 @@ func (e *executor) Execute(ctx context.Context, batchNo string) error {
|
||||
if err = e.repository.SaveValidationFailure(ctx, task); err != nil {
|
||||
return gerror.Wrap(err, "save data service validation failure")
|
||||
}
|
||||
if _, durable := e.repository.(SubBatchRepository); durable {
|
||||
if _, async := e.upstream.(AsyncUpstream); async {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return gerror.Wrap(e.repository.Finalize(ctx, task), "finalize data service validation failure")
|
||||
}
|
||||
|
||||
@@ -45,6 +83,11 @@ func (e *executor) executeTrafficCutoff(ctx context.Context, task *Task) error {
|
||||
if err := e.repository.SaveValidationFailure(ctx, task); err != nil {
|
||||
return gerror.Wrap(err, "save traffic cutoff validation failure")
|
||||
}
|
||||
if _, durable := e.repository.(SubBatchRepository); durable {
|
||||
if _, async := e.upstream.(AsyncUpstream); async {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return gerror.Wrap(e.repository.Finalize(ctx, task), "finalize traffic cutoff validation failure")
|
||||
}
|
||||
if err := e.refreshDataServiceState(ctx, task); err != nil {
|
||||
@@ -53,6 +96,12 @@ func (e *executor) executeTrafficCutoff(ctx context.Context, task *Task) error {
|
||||
if err := e.saveInvalidTrafficCutoffRows(ctx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, durable := e.repository.(SubBatchRepository); durable {
|
||||
if _, async := e.upstream.(AsyncUpstream); async {
|
||||
// 本地disabled不能证明前序开通已终结;持久化路径统一经过卡级提交屏障。
|
||||
return e.executeValidated(ctx, task)
|
||||
}
|
||||
}
|
||||
if err := e.saveIdempotentCutoffSkips(ctx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -60,6 +109,26 @@ func (e *executor) executeTrafficCutoff(ctx context.Context, task *Task) error {
|
||||
}
|
||||
|
||||
func (e *executor) executeValidated(ctx context.Context, task *Task) error {
|
||||
if durable, ok := e.repository.(SubBatchRepository); ok {
|
||||
if async, ok := e.upstream.(AsyncUpstream); ok {
|
||||
seen := map[string]bool{}
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil || detail.Reason != "" || (detail.DetailStatus != consts.BatchDetailStatusPending && detail.DetailStatus != consts.BatchDetailStatusRunning) {
|
||||
continue
|
||||
}
|
||||
if seen[detail.ICCID] {
|
||||
resolved, err := e.repository.SaveResult(ctx, task, detail, Outcome{Message: "重复ICCID,未提交上游"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
detail.ApplyOutcome(resolved)
|
||||
continue
|
||||
}
|
||||
seen[detail.ICCID] = true
|
||||
}
|
||||
return e.prepareAndSubmit(ctx, task, durable, async)
|
||||
}
|
||||
}
|
||||
groups := groupByChanKey(task.Operation, task.Details)
|
||||
keys := make([]string, 0, len(groups))
|
||||
for chanKey := range groups {
|
||||
@@ -96,7 +165,7 @@ func (e *executor) saveIdempotentCutoffSkips(ctx context.Context, task *Task) er
|
||||
}
|
||||
resolved, saveErr := e.repository.SaveResult(ctx, task, detail, Outcome{
|
||||
Success: true,
|
||||
Message: "data service is already disabled; upstream request was skipped",
|
||||
Message: "数据服务已关闭,未发起上游请求",
|
||||
})
|
||||
if saveErr != nil {
|
||||
return gerror.Wrapf(saveErr, "save data service detail %d idempotent skip result", detail.ID)
|
||||
@@ -108,6 +177,9 @@ func (e *executor) saveIdempotentCutoffSkips(ctx context.Context, task *Task) er
|
||||
|
||||
func validateTask(task *Task) bool {
|
||||
if task == nil || len(task.Details) == 0 {
|
||||
if task != nil && task.FailureReason == "" {
|
||||
task.FailureReason = "数据开停批次没有有效明细"
|
||||
}
|
||||
return false
|
||||
}
|
||||
hasFailure := false
|
||||
@@ -132,17 +204,17 @@ func validateTask(task *Task) bool {
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && detail.Reason == "" {
|
||||
detail.Reason = "same batch contains validation failure; upstream request was skipped"
|
||||
detail.Reason = "同批次存在校验失败卡,本卡未请求上游"
|
||||
}
|
||||
}
|
||||
task.FailureReason = "batch validation failed; upstream request was skipped"
|
||||
task.FailureReason = "批次校验未通过,未发起上游请求"
|
||||
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"
|
||||
task.FailureReason = "流量断网批次无有效明细"
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -160,13 +232,13 @@ func validateTrafficCutoffTask(task *Task) bool {
|
||||
if hasSuccess {
|
||||
task.ValidationOK = true
|
||||
if failed := countFailedDetails(task); failed > 0 {
|
||||
task.FailureReason = strconv.Itoa(failed) + " SIM cards failed validation"
|
||||
task.FailureReason = strconv.Itoa(failed) + " 张卡未通过校验"
|
||||
} else {
|
||||
task.FailureReason = ""
|
||||
}
|
||||
return true
|
||||
}
|
||||
task.FailureReason = "traffic cutoff batch validation failed; upstream request was skipped"
|
||||
task.FailureReason = "流量断网批次校验未通过,未发起上游请求"
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -188,7 +260,7 @@ func validateDetail(_ Operation, detail *Detail) {
|
||||
return
|
||||
}
|
||||
if detail.Card == nil {
|
||||
detail.Reason = "ICCID does not exist in SIM card records"
|
||||
detail.Reason = "ICCID不存在或不属于当前租户"
|
||||
return
|
||||
}
|
||||
detail.MSISDN = strings.TrimSpace(detail.Card.MSISDN)
|
||||
@@ -198,26 +270,28 @@ func validateDetail(_ Operation, detail *Detail) {
|
||||
detail.StatusAfter = detail.Card.DataServiceStatus
|
||||
|
||||
if !detail.Authorized {
|
||||
detail.Reason = "ICCID is outside the current operator authorization scope"
|
||||
detail.Reason = "ICCID不在当前操作员授权范围内"
|
||||
return
|
||||
}
|
||||
var reasons []string
|
||||
if detail.ChanKey == "" {
|
||||
detail.Reason = "ICCID is missing chanKey"
|
||||
return
|
||||
reasons = append(reasons, "ICCID缺少通道标识")
|
||||
}
|
||||
if detail.InputMSISDN != "" && detail.InputMSISDN != detail.MSISDN {
|
||||
detail.Reason = "MSISDN does not match the SIM card record"
|
||||
return
|
||||
reasons = append(reasons, "MSISDN与卡档案不一致")
|
||||
}
|
||||
if detail.InputIMSI != "" && detail.InputIMSI != detail.IMSI {
|
||||
detail.Reason = "IMSI does not match the SIM card record"
|
||||
return
|
||||
reasons = append(reasons, "IMSI与卡档案不一致")
|
||||
}
|
||||
detail.Reason = strings.Join(reasons, ";")
|
||||
}
|
||||
|
||||
func groupByChanKey(operation Operation, details []*Detail) map[string][]*Detail {
|
||||
groups := make(map[string][]*Detail)
|
||||
for _, detail := range details {
|
||||
if detail == nil || detail.Reason != "" {
|
||||
continue
|
||||
}
|
||||
if operation.BatchType == consts.BatchTypeDataCut && (detail == nil || !detail.ValidationPassed || detail.Reason != "") {
|
||||
continue
|
||||
}
|
||||
@@ -316,7 +390,7 @@ func (e *executor) changeSubBatch(ctx context.Context, task *Task, details []*De
|
||||
if err != nil {
|
||||
outcomes := make([]Outcome, len(details))
|
||||
for index := range outcomes {
|
||||
outcomes[index].Message = "upstream request failed: " + err.Error()
|
||||
outcomes[index].Message = "请求上游失败:" + err.Error()
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
@@ -354,14 +428,14 @@ func mapOutcomes(details []*Detail, response []model.SimBaseRes[model.SimCardApn
|
||||
matched = detailIndex
|
||||
}
|
||||
if matched < 0 {
|
||||
outcomes[detailIndex] = Outcome{Message: "upstream did not return a result for this SIM card"}
|
||||
outcomes[detailIndex] = Outcome{Message: "上游未返回该卡处理结果"}
|
||||
continue
|
||||
}
|
||||
used[matched] = true
|
||||
item := response[matched]
|
||||
message := strings.TrimSpace(item.Message)
|
||||
if item.Code != 0 && message == "" {
|
||||
message = "upstream processing failed"
|
||||
message = "上游处理失败"
|
||||
}
|
||||
outcomes[detailIndex] = Outcome{Success: item.Code == 0, Code: item.Code, Message: message, OrderNum: strings.TrimSpace(item.ResData.OrderNum)}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestAccuracyInvalidDuplicateKeepsValidTrafficCutoffRow(t *testing.T) {
|
||||
for _, invalidFirst := range []bool{true, false} {
|
||||
for _, invalidInput := range []string{"MSISDN", "IMSI", "authorization", "existing"} {
|
||||
t.Run(fmt.Sprintf("%s/invalidFirst=%v", invalidInput, invalidFirst), func(t *testing.T) {
|
||||
operation, err := OperationByBatchType(107)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card := &Card{ID: 10, ICCID: "same-card", MSISDN: "1001", IMSI: "2001", ChanKey: "channel", DataServiceStatus: 1}
|
||||
invalid := &Detail{ID: 1, ICCID: card.ICCID, Authorized: true, Card: card}
|
||||
var wantReason string
|
||||
switch invalidInput {
|
||||
case "MSISDN":
|
||||
invalid.InputMSISDN = "wrong"
|
||||
wantReason = "MSISDN与卡档案不一致"
|
||||
case "IMSI":
|
||||
invalid.InputIMSI = "wrong"
|
||||
wantReason = "IMSI与卡档案不一致"
|
||||
case "authorization":
|
||||
invalid.Authorized = false
|
||||
wantReason = "ICCID不在当前操作员授权范围内"
|
||||
case "existing":
|
||||
wantReason = "导入行格式校验失败"
|
||||
invalid.Reason = wantReason
|
||||
}
|
||||
valid := &Detail{ID: 2, ICCID: card.ICCID, Authorized: true, Card: card}
|
||||
otherInvalid := &Detail{ID: 3, ICCID: card.ICCID, Reason: "该行独立校验失败"}
|
||||
duplicate := &Detail{ID: 4, ICCID: card.ICCID, Authorized: true, Card: card}
|
||||
details := []*Detail{invalid, valid, otherInvalid, duplicate}
|
||||
if !invalidFirst {
|
||||
details[0], details[1] = details[1], details[0]
|
||||
}
|
||||
task := &Task{Batch: Batch{BatchNo: "ACCURACY-CUT", BatchType: 107}, Operation: operation, ApnName: "CMIOT", Details: details}
|
||||
base := &accuracyRepository{task: task, saved: map[int64]Outcome{}}
|
||||
repo := &accuracyDurableRepository{accuracyRepository: base}
|
||||
if err := NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(context.Background(), task.Batch.BatchNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.prepared) != 1 || len(repo.prepared[0].DetailIDs) != 1 || repo.prepared[0].DetailIDs[0] != valid.ID {
|
||||
t.Fatalf("有效行未独立冻结: %+v", repo.prepared)
|
||||
}
|
||||
if valid.Reason != "" || valid.DetailStatus == consts.BatchDetailStatusFailed {
|
||||
t.Fatalf("有效行被无效重复行阻断: %+v", valid)
|
||||
}
|
||||
for _, check := range []struct {
|
||||
detail *Detail
|
||||
reason string
|
||||
}{{invalid, wantReason}, {otherInvalid, "该行独立校验失败"}, {duplicate, "重复ICCID,未提交上游"}} {
|
||||
outcome, ok := base.saved[check.detail.ID]
|
||||
if !ok || outcome.Success || outcome.Message != check.reason || check.detail.Reason != check.reason {
|
||||
t.Fatalf("逐行原因被改写: id=%d detail=%+v outcome=%+v", check.detail.ID, check.detail, outcome)
|
||||
}
|
||||
}
|
||||
if repo.prepared[0].Request.ReqData.OperType != "1" || operation.TargetStatus != 0 {
|
||||
t.Fatalf("107开停方向倒置: %+v", repo.prepared[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccuracyFrozenRequestsKeepAPNDirection(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
batchType int
|
||||
operType string
|
||||
target int
|
||||
}{{107, "1", 0}, {109, "0", 1}, {110, "1", 0}} {
|
||||
t.Run(fmt.Sprint(tc.batchType), func(t *testing.T) {
|
||||
operation, err := OperationByBatchType(tc.batchType)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card := &Card{ID: 10, ICCID: "card", MSISDN: "1001", ChanKey: "channel", DataServiceStatus: 1 - tc.target}
|
||||
task := &Task{
|
||||
Batch: Batch{BatchNo: "ACCURACY-DIRECTION", BatchType: tc.batchType}, Operation: operation, ApnName: "CMIOT",
|
||||
Details: []*Detail{{ID: 1, ICCID: card.ICCID, Authorized: true, Card: card}, {ID: 2, ICCID: card.ICCID, Authorized: true, Card: card}},
|
||||
}
|
||||
repo := &accuracyDurableRepository{accuracyRepository: &accuracyRepository{task: task, saved: map[int64]Outcome{}}}
|
||||
if err := NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(context.Background(), task.Batch.BatchNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.prepared) != 1 || len(repo.prepared[0].DetailIDs) != 1 || repo.prepared[0].DetailIDs[0] != 1 {
|
||||
t.Fatalf("同ICCID未只保留首个有效行: %+v", repo.prepared)
|
||||
}
|
||||
if operation.TargetStatus != tc.target || repo.prepared[0].Request.ReqData.OperType != tc.operType {
|
||||
t.Fatalf("批次%d开停方向错误: operation=%+v request=%+v", tc.batchType, operation, repo.prepared[0].Request)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccuracyEmptyBatchKeepsValidationReason(t *testing.T) {
|
||||
for _, batchType := range []int{107, 109, 110} {
|
||||
t.Run(fmt.Sprint(batchType), func(t *testing.T) {
|
||||
operation, err := OperationByBatchType(batchType)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{Batch: Batch{BatchNo: "ACCURACY-EMPTY", BatchType: batchType}, Operation: operation}
|
||||
repo := &accuracyRepository{task: task}
|
||||
if err := NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(context.Background(), task.Batch.BatchNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !repo.validationSaved || !repo.finalized || task.ValidationOK || task.FailureReason == "" {
|
||||
t.Fatalf("空明细校验失败原因缺失: task=%+v saved=%v finalized=%v", task, repo.validationSaved, repo.finalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type accuracyRepository struct {
|
||||
task *Task
|
||||
saved map[int64]Outcome
|
||||
validationSaved bool
|
||||
finalized bool
|
||||
}
|
||||
|
||||
func (r *accuracyRepository) Load(context.Context, string) (*Task, error) { return r.task, nil }
|
||||
func (r *accuracyRepository) SaveValidationFailure(context.Context, *Task) error {
|
||||
r.validationSaved = true
|
||||
return nil
|
||||
}
|
||||
func (r *accuracyRepository) SaveResult(_ context.Context, _ *Task, detail *Detail, outcome Outcome) (Outcome, error) {
|
||||
r.saved[detail.ID] = outcome
|
||||
return outcome, nil
|
||||
}
|
||||
func (r *accuracyRepository) Finalize(context.Context, *Task) error {
|
||||
r.finalized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type accuracyDurableRepository struct {
|
||||
*accuracyRepository
|
||||
SubBatchRepository
|
||||
prepared []SubBatch
|
||||
}
|
||||
|
||||
func (r *accuracyDurableRepository) ResumeOrPrepare(ctx context.Context, _ string, prepare func(context.Context) error) error {
|
||||
return prepare(ctx)
|
||||
}
|
||||
func (r *accuracyDurableRepository) PrepareSubBatch(_ context.Context, sub SubBatch) error {
|
||||
r.prepared = append(r.prepared, sub)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAccuracyCanExecuteProcessingRequiresExistingParent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
parent bool
|
||||
readErr error
|
||||
wantOK bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "旧Processing无凭据不可接管"},
|
||||
{name: "新批次ready0已持久化凭据", parent: true, wantOK: true},
|
||||
{name: "缺少迁移查询失败安全拒绝", readErr: errors.New("no such table: data_operation_parent"), wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
repo := &accuracyParentRepository{accuracyDurableRepository: &accuracyDurableRepository{accuracyRepository: &accuracyRepository{}}, present: tc.parent, readErr: tc.readErr}
|
||||
e := NewExecutor(repo, &accuracyNoUpstream{t: t}).(*executor)
|
||||
ok, err := e.CanExecuteProcessing(ctx, &entity.BatchMain{BatchNo: " APN-EXISTING "})
|
||||
if ok != tc.wantOK || (err != nil) != tc.wantErr || repo.lookups != 1 || repo.batchNo != "APN-EXISTING" {
|
||||
t.Fatalf("parent gate ok=%v err=%v lookups=%d batchNo=%q", ok, err, repo.lookups, repo.batchNo)
|
||||
}
|
||||
})
|
||||
}
|
||||
base := &accuracyRepository{}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
repo Repository
|
||||
upstream Upstream
|
||||
}{
|
||||
{name: "缺少持久仓储", repo: base, upstream: &accuracyNoUpstream{t: t}},
|
||||
{name: "仅同步上游", repo: &accuracyParentRepository{accuracyDurableRepository: &accuracyDurableRepository{accuracyRepository: base}}, upstream: &accuracySyncOnlyUpstream{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := NewExecutor(tc.repo, tc.upstream).(*executor)
|
||||
ok, err := e.CanExecuteProcessing(ctx, &entity.BatchMain{BatchNo: "APN-EXISTING"})
|
||||
if ok || err != nil {
|
||||
t.Fatalf("不支持持久异步不得接管Processing: ok=%v err=%v", ok, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccuracyDurableRepositoryWithSyncUpstreamFinalizesValidationFailure(t *testing.T) {
|
||||
for _, batchType := range []int{107, 109, 110} {
|
||||
t.Run(fmt.Sprint(batchType), func(t *testing.T) {
|
||||
operation, err := OperationByBatchType(batchType)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{Batch: Batch{BatchNo: "SYNC-FAILED", BatchType: batchType}, Operation: operation}
|
||||
base := &accuracyRepository{task: task}
|
||||
repo := &accuracyDurableRepository{accuracyRepository: base}
|
||||
if err := NewExecutor(repo, &accuracySyncOnlyUpstream{}).Execute(context.Background(), task.Batch.BatchNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !base.validationSaved || !base.finalized || task.FailureReason == "" {
|
||||
t.Fatalf("同步兜底遗漏校验失败父批: saved=%v finalized=%v task=%+v", base.validationSaved, base.finalized, task)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type accuracyParentRepository struct {
|
||||
*accuracyDurableRepository
|
||||
present bool
|
||||
readErr error
|
||||
lookups int
|
||||
batchNo string
|
||||
}
|
||||
|
||||
func (r *accuracyParentRepository) HasDurableParent(_ context.Context, batchNo string) (bool, error) {
|
||||
r.lookups++
|
||||
r.batchNo = batchNo
|
||||
return r.present, r.readErr
|
||||
}
|
||||
|
||||
type accuracySyncOnlyUpstream struct{}
|
||||
|
||||
func (*accuracySyncOnlyUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
panic("校验失败不得发运营商请求")
|
||||
}
|
||||
|
||||
type accuracyNoUpstream struct{ t *testing.T }
|
||||
|
||||
func (u *accuracyNoUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
u.t.Fatal("该回归不得调用同步上游")
|
||||
return nil, nil
|
||||
}
|
||||
func (u *accuracyNoUpstream) SubmitSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
u.t.Fatal("该回归只冻结意图,不得提交上游")
|
||||
return nil, nil
|
||||
}
|
||||
func (u *accuracyNoUpstream) QuerySimApnFunctionResult(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
||||
u.t.Fatal("该回归不得查询上游")
|
||||
return nil, nil
|
||||
}
|
||||
@@ -211,7 +211,7 @@ func TestExecuteTrafficCutoffValidationFailureAllowsValidRows(t *testing.T) {
|
||||
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" {
|
||||
if repo.saved[0].Success || repo.saved[0].Message != "ICCID不在当前操作员授权范围内" {
|
||||
t.Fatalf("invalid row result = %+v", repo.saved[0])
|
||||
}
|
||||
if !repo.saved[1].Success {
|
||||
@@ -522,3 +522,22 @@ func (u *fakeUpstream) ChangeSimApnFunction(_ context.Context, input *model.SimC
|
||||
u.input = input
|
||||
return u.response, u.responseError
|
||||
}
|
||||
|
||||
func TestValidateDetailCollectsIndependentDiagnostics(t *testing.T) {
|
||||
detail := &Detail{ICCID: "card", Authorized: true, InputMSISDN: "wrong-phone", InputIMSI: "wrong-imsi", Card: &Card{MSISDN: "phone", IMSI: "imsi"}}
|
||||
validateDetail(Operation{}, detail)
|
||||
want := "ICCID缺少通道标识;MSISDN与卡档案不一致;IMSI与卡档案不一致"
|
||||
if detail.Reason != want {
|
||||
t.Fatalf("独立校验原因丢失: got=%q want=%q", detail.Reason, want)
|
||||
}
|
||||
unauthorized := &Detail{ICCID: "card", InputMSISDN: "wrong", Card: &Card{MSISDN: "phone"}}
|
||||
validateDetail(Operation{}, unauthorized)
|
||||
if unauthorized.Reason != "ICCID不在当前操作员授权范围内" {
|
||||
t.Fatalf("越权卡不应返回其他数据差异: %q", unauthorized.Reason)
|
||||
}
|
||||
existing := &Detail{Reason: "原始输入格式错误", Authorized: true, Card: &Card{}}
|
||||
validateDetail(Operation{}, existing)
|
||||
if existing.Reason != "原始输入格式错误" {
|
||||
t.Fatal("原始原因被覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package data
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
@@ -39,6 +40,7 @@ type Card struct {
|
||||
|
||||
type Detail struct {
|
||||
ID int64
|
||||
DetailStatus int
|
||||
RowNo int
|
||||
ICCID string
|
||||
InputMSISDN string
|
||||
@@ -59,6 +61,7 @@ type Detail struct {
|
||||
}
|
||||
|
||||
type Outcome struct {
|
||||
Pending bool
|
||||
Success bool
|
||||
Code int
|
||||
Message string
|
||||
@@ -76,6 +79,11 @@ type Task struct {
|
||||
|
||||
func (d *Detail) ApplyOutcome(outcome Outcome) {
|
||||
d.OrderNum = strings.TrimSpace(outcome.OrderNum)
|
||||
if outcome.Success {
|
||||
d.DetailStatus = 2
|
||||
} else {
|
||||
d.DetailStatus = 3
|
||||
}
|
||||
d.UpstreamCode = outcome.Code
|
||||
d.UpstreamReason = strings.TrimSpace(outcome.Message)
|
||||
if outcome.Success {
|
||||
@@ -83,7 +91,7 @@ func (d *Detail) ApplyOutcome(outcome Outcome) {
|
||||
return
|
||||
}
|
||||
if d.UpstreamReason == "" {
|
||||
d.UpstreamReason = "upstream processing failed"
|
||||
d.UpstreamReason = "上游处理失败"
|
||||
}
|
||||
d.Reason = d.UpstreamReason
|
||||
}
|
||||
@@ -138,10 +146,66 @@ type Repository interface {
|
||||
Finalize(ctx context.Context, task *Task) error
|
||||
}
|
||||
|
||||
// SubBatch is a durable, channel-isolated intent; Request is frozen before submission.
|
||||
type SubBatch struct {
|
||||
ID int64
|
||||
BatchNo string
|
||||
BatchType int
|
||||
ChanKey string
|
||||
OperationID string
|
||||
DetailIDs []int64
|
||||
Request model.SimCardApnChangeInput
|
||||
JobID string
|
||||
Status string
|
||||
PollCount int
|
||||
SubmittedAt time.Time
|
||||
LeaseToken string
|
||||
LeaseUntil time.Time
|
||||
NextCheckAt time.Time
|
||||
}
|
||||
|
||||
// SubBatchRepository keeps the synchronous legacy tests separate from durable scheduling.
|
||||
type SubBatchRepository interface {
|
||||
HasDurableParent(ctx context.Context, batchNo string) (bool, error)
|
||||
PrepareSubBatch(ctx context.Context, batch SubBatch) error
|
||||
ResumeOrPrepare(ctx context.Context, batchNo string, prepare func(context.Context) error) error
|
||||
ClaimSubBatch(ctx context.Context, operationID, token string, now time.Time) (*SubBatch, error)
|
||||
ListDueSubBatches(ctx context.Context, now time.Time, limit int) ([]string, error)
|
||||
GetSubBatch(ctx context.Context, operationID string) (*SubBatch, error)
|
||||
DelaySubBatchPrecheck(ctx context.Context, snapshot SubBatch, next time.Time, reason string) error
|
||||
ReleaseUnsentSubBatch(ctx context.Context, batch SubBatch, reason string) error
|
||||
SubBatchChannel(ctx context.Context, operationID string) (string, error)
|
||||
UpdateSubBatch(ctx context.Context, batch SubBatch, status, jobID, reason string, next time.Time, pollCount int) error
|
||||
CompleteSubBatch(ctx context.Context, batch SubBatch, outcomes []Outcome) error
|
||||
SaveSubBatchProgress(ctx context.Context, batch SubBatch, outcomes []Outcome) error
|
||||
ProtectExecutionFailure(ctx context.Context, batchNo, reason string) error
|
||||
FinalizeReadyBatch(ctx context.Context, batchNo string) error
|
||||
ListRecoverableBatches(ctx context.Context, limit int) ([]string, error)
|
||||
}
|
||||
|
||||
type Upstream interface {
|
||||
ChangeSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
|
||||
}
|
||||
|
||||
// AsyncUpstream provides an explicit submit/recovery/query protocol: never resend a write request.
|
||||
type AsyncUpstream interface {
|
||||
SubmitSimApnFunction(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
|
||||
QuerySimApnFunctionResult(ctx context.Context, input *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
|
||||
}
|
||||
|
||||
type ReviewRepository interface {
|
||||
ClaimReviewSubBatch(ctx context.Context, operationID, token string, now time.Time) (*SubBatch, error)
|
||||
}
|
||||
|
||||
// ReviewBatchRepository 支撑"手动同步上游结果":按父批找出待核查子批并只读补查,
|
||||
// 不重发办理、不重置自动补查计数。
|
||||
type ReviewBatchRepository interface {
|
||||
ListReviewSubBatches(ctx context.Context, batchNo string) ([]string, error)
|
||||
FinalizeReadyBatch(ctx context.Context, batchNo string) error
|
||||
}
|
||||
|
||||
type Executor interface {
|
||||
Execute(ctx context.Context, batchNo string) error
|
||||
QueryReview(ctx context.Context, operationID string) error
|
||||
QueryReviewBatch(ctx context.Context, batchNo string) (int, error)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func task(tempKey string) queue.Task {
|
||||
ChanKey: "173",
|
||||
TempKey: tempKey,
|
||||
CardID: 1,
|
||||
ICCID: "898604F4152000015474",
|
||||
ICCID: "898604F4152391115474",
|
||||
MSISDN: "1064000000001",
|
||||
IMSI: "460000000000001",
|
||||
Operator: 1,
|
||||
@@ -54,7 +54,7 @@ func item(code int, errCode, imei string) model.SimBaseRes[model.SimImeiItem] {
|
||||
return model.SimBaseRes[model.SimImeiItem]{
|
||||
Code: code,
|
||||
ErrCode: errCode,
|
||||
SimKey: &model.SimKey{ChanKey: "173", ICCID: "898604F4152000015474"},
|
||||
SimKey: &model.SimKey{ChanKey: "173", ICCID: "898604F4152391115474"},
|
||||
ResData: model.SimImeiItem{Imei: imei},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
cascadeFailureReason = "同批存在校验失败,本次整批未入库"
|
||||
cascadeFailureReason = "校验通过,但本批次存在失败明细,整批未执行入库"
|
||||
maxIdentifierLength = 32
|
||||
minICCIDLength = 19
|
||||
minIMSILength = 15
|
||||
@@ -237,6 +237,9 @@ func (e *executor) populateExcelRows(ctx context.Context, task *Task) error {
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "read inbound Excel rows failed")
|
||||
}
|
||||
if len(lines) == 0 || !hasInboundIdentifierHeader(headerIndex(lines[0])) {
|
||||
return gerror.New("入库文件表头无效:缺少 ICCID、MSISDN 或 IMSI 列")
|
||||
}
|
||||
task.Rows = parseTableRows(lines)
|
||||
return nil
|
||||
}
|
||||
@@ -281,9 +284,19 @@ func (e *executor) validate(ctx context.Context, task *Task) error {
|
||||
}
|
||||
syncInboundOptionsFromRows(task)
|
||||
markSameBatchDuplicates(task.Rows)
|
||||
if hasFailedRows(task.Rows) {
|
||||
markIncompleteRows(task.Rows)
|
||||
task.ValidCount = countValidRows(task.Rows)
|
||||
return nil
|
||||
}
|
||||
if err := e.markStockDuplicates(ctx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasFailedRows(task.Rows) {
|
||||
markIncompleteRows(task.Rows)
|
||||
task.ValidCount = countValidRows(task.Rows)
|
||||
return nil
|
||||
}
|
||||
task.Options.ValidateUpstreamBasicInfo = normalizeUpstreamBasicInfoValidationOption(task.Options.ValidateUpstreamBasicInfo)
|
||||
if e.upstream != nil && task.Options.ValidateUpstreamBasicInfo == "1" {
|
||||
if err := e.validateUpstreamBasicInfo(ctx, task); err != nil {
|
||||
@@ -350,6 +363,15 @@ func parseTableRows(lines [][]string) []*Row {
|
||||
return rows
|
||||
}
|
||||
|
||||
func hasInboundIdentifierHeader(header map[string]int) bool {
|
||||
for _, key := range []string{"iccid", "msisdn", "imsi"} {
|
||||
if _, ok := header[key]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rowFromHeader(rowNo int, fields []string, header map[string]int) *Row {
|
||||
raw := make(map[string]string, len(header))
|
||||
for key, index := range header {
|
||||
@@ -447,7 +469,7 @@ func headerIndex(fields []string) map[string]int {
|
||||
indexes[key] = index
|
||||
}
|
||||
}
|
||||
if _, ok := indexes["iccid"]; !ok {
|
||||
if !hasInboundIdentifierHeader(indexes) {
|
||||
return nil
|
||||
}
|
||||
return indexes
|
||||
@@ -1382,13 +1404,27 @@ func countValidRows(rows []*Row) int {
|
||||
func countFailedRows(rows []*Row) int {
|
||||
count := 0
|
||||
for _, row := range rows {
|
||||
if row != nil && row.Reason != "" {
|
||||
if row != nil && row.Reason != "" && row.Reason != cascadeFailureReason {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func inboundRowStatus(row *Row) string {
|
||||
if IsCascadeFailure(row) {
|
||||
return "未执行"
|
||||
}
|
||||
if row.Reason != "" {
|
||||
return "失败"
|
||||
}
|
||||
return "成功"
|
||||
}
|
||||
|
||||
func IsCascadeFailure(row *Row) bool {
|
||||
return row != nil && row.Reason == cascadeFailureReason
|
||||
}
|
||||
|
||||
func rowKeys(rows []*Row) ([]string, []string, []string) {
|
||||
iccidSet := make(map[string]struct{}, len(rows))
|
||||
msisdnSet := make(map[string]struct{}, len(rows))
|
||||
@@ -1541,6 +1577,7 @@ func resultRequest(task *Task) batchresult.Request {
|
||||
MSISDN: row.MSISDN,
|
||||
IMSI: row.IMSI,
|
||||
Success: row.Reason == "",
|
||||
Status: inboundRowStatus(row),
|
||||
Reason: row.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,15 +3,29 @@ package inbound
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
adapter, err := gcfg.NewAdapterContent("batch:\n inbound:\n upstreamBatchSize: 1\n")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestAdminSourceTypeContract(t *testing.T) {
|
||||
if consts.BatchSourceTypeRange != 1 || consts.BatchSourceTypeBatchInput != 2 || consts.BatchSourceTypeFile != 3 {
|
||||
t.Fatalf("source_type contract mismatch: range=%d input=%d file=%d",
|
||||
@@ -19,6 +33,47 @@ func TestAdminSourceTypeContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCascadeFailurePreservesPerCardValidation(t *testing.T) {
|
||||
task := &Task{Rows: []*Row{
|
||||
{ICCID: "invalid", Reason: "卡号不合法"},
|
||||
{ICCID: "valid"},
|
||||
}}
|
||||
applyCascadeFailure(task)
|
||||
if countFailedRows(task.Rows) != 1 || !IsCascadeFailure(task.Rows[1]) {
|
||||
t.Fatalf("unexpected validation outcomes: %+v", task.Rows)
|
||||
}
|
||||
if task.Rows[0].Reason != "卡号不合法" || inboundRowStatus(task.Rows[0]) != "失败" || inboundRowStatus(task.Rows[1]) != "未执行" {
|
||||
t.Fatalf("wrong per-card status: %+v", task.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCollectsLaterInboundFailuresBeforeBlockingBatch(t *testing.T) {
|
||||
task := &Task{Rows: []*Row{
|
||||
{ICCID: "bad", ChanKey: "channel"},
|
||||
{ICCID: "8986000000000000001", MSISDN: "13800008000", IMSI: "460001234567890", ChanKey: "channel"},
|
||||
{ICCID: "8986000000000000002", MSISDN: "invalid", IMSI: "invalid", ChanKey: "channel"},
|
||||
}}
|
||||
executor := &executor{repository: &inboundRepositoryStub{}}
|
||||
if err := executor.validate(context.Background(), task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyCascadeFailure(task)
|
||||
if !strings.Contains(task.Rows[0].Reason, "ICCID格式不正确") {
|
||||
t.Fatalf("first failure was lost: %q", task.Rows[0].Reason)
|
||||
}
|
||||
if !IsCascadeFailure(task.Rows[1]) {
|
||||
t.Fatalf("validated card was not blocked: %q", task.Rows[1].Reason)
|
||||
}
|
||||
for _, reason := range []string{"MSISDN格式不正确", "IMSI格式不正确"} {
|
||||
if !strings.Contains(task.Rows[2].Reason, reason) {
|
||||
t.Fatalf("later card was not fully validated: %q", task.Rows[2].Reason)
|
||||
}
|
||||
}
|
||||
if countFailedRows(task.Rows) != 2 || countValidRows(task.Rows) != 0 {
|
||||
t.Fatalf("batch must have two validation failures and no executable cards: %+v", task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRowRequiresAtLeastOneCardIdentifierAndChanKey(t *testing.T) {
|
||||
row := &Row{}
|
||||
validateRow(row, "")
|
||||
@@ -32,7 +87,7 @@ func TestValidateRowRequiresAtLeastOneCardIdentifierAndChanKey(t *testing.T) {
|
||||
|
||||
func TestParseInputTextAcceptsOneIdentifierPerLine(t *testing.T) {
|
||||
rows := parseInputText(strings.Join([]string{
|
||||
"89860401780000567308",
|
||||
"89860401786527567308",
|
||||
"14100007308",
|
||||
"460046527567308",
|
||||
}, "\n"))
|
||||
@@ -40,7 +95,7 @@ func TestParseInputTextAcceptsOneIdentifierPerLine(t *testing.T) {
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("row count = %d, want 3", len(rows))
|
||||
}
|
||||
if rows[0].ICCID != "89860401780000567308" || rows[1].MSISDN != "14100007308" || rows[2].IMSI != "460046527567308" {
|
||||
if rows[0].ICCID != "89860401786527567308" || rows[1].MSISDN != "14100007308" || rows[2].IMSI != "460046527567308" {
|
||||
t.Fatalf("unexpected parsed rows: %+v", rows)
|
||||
}
|
||||
for _, row := range rows {
|
||||
@@ -111,7 +166,7 @@ func TestValidateRowSelectedMSISDNDoesNotRequireOtherIdentifiers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateRowAllowsAlphaNumericICCID(t *testing.T) {
|
||||
row := &Row{ICCID: "898604F4152000015473", MSISDN: "13800008000", IMSI: "460001234567890", ChanKey: "chan-a"}
|
||||
row := &Row{ICCID: "898604F4152391115473", MSISDN: "13800008000", IMSI: "460001234567890", ChanKey: "chan-a"}
|
||||
validateRow(row, "")
|
||||
if row.Reason != "" {
|
||||
t.Fatalf("unexpected validation reason: %q", row.Reason)
|
||||
@@ -149,14 +204,14 @@ func TestMarkSameBatchDuplicatesChecksIMSI(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateUpstreamBasicInfoAcceptsMatchingIdentifiers(t *testing.T) {
|
||||
rows := []*Row{{RowNo: 1, ICCID: "898604F4152000015473", MSISDN: "1440000615473", IMSI: "460087436015473", ChanKey: "chan-a"}}
|
||||
rows := []*Row{{RowNo: 1, ICCID: "898604F4152391115473", MSISDN: "1441543615473", IMSI: "460087436015473", ChanKey: "chan-a"}}
|
||||
upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{
|
||||
{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "898604F4152000015473"},
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "898604F4152391115473"},
|
||||
ResData: model.SimBasicInfoItem{
|
||||
ICCID: "898604F4152000015473",
|
||||
MSISDN: "1440000615473",
|
||||
ICCID: "898604F4152391115473",
|
||||
MSISDN: "1441543615473",
|
||||
IMSI: "460087436015473",
|
||||
},
|
||||
},
|
||||
@@ -176,7 +231,7 @@ func TestValidateUpstreamBasicInfoAcceptsMatchingIdentifiers(t *testing.T) {
|
||||
t.Fatalf("unexpected upstream inputs: %+v", upstream.inputs)
|
||||
}
|
||||
requestKey := upstream.inputs[0].SimKeys[0]
|
||||
if requestKey.ChanKey != "chan-a" || requestKey.ICCID != "898604F4152000015473" {
|
||||
if requestKey.ChanKey != "chan-a" || requestKey.ICCID != "898604F4152391115473" {
|
||||
t.Fatalf("unexpected upstream request key: %+v", requestKey)
|
||||
}
|
||||
if requestKey.MSISDN != "" || requestKey.IMSI != "" {
|
||||
@@ -185,11 +240,11 @@ func TestValidateUpstreamBasicInfoAcceptsMatchingIdentifiers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateUpstreamBasicInfoRequiresCompleteResData(t *testing.T) {
|
||||
rows := []*Row{{RowNo: 1, ICCID: "898604F4152000015473", MSISDN: "1440000615473", IMSI: "460087436015473", ChanKey: "chan-a"}}
|
||||
rows := []*Row{{RowNo: 1, ICCID: "898604F4152391115473", MSISDN: "1441543615473", IMSI: "460087436015473", ChanKey: "chan-a"}}
|
||||
upstream := &basicInfoUpstreamStub{response: []model.SimBaseRes[model.SimBasicInfoItem]{
|
||||
{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "898604f4152000015473", MSISDN: "1440000615473", IMSI: "460087436015473"},
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "898604f4152391115473", MSISDN: "1441543615473", IMSI: "460087436015473"},
|
||||
ResData: model.SimBasicInfoItem{
|
||||
ActiveDate: "2026-08-12 00:00:00",
|
||||
OpenDate: "2026-08-12 00:00:00",
|
||||
@@ -232,7 +287,7 @@ func TestValidateUpstreamBasicInfoMatchesByMSISDNAndBackfillsCodes(t *testing.T)
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", MSISDN: "14100007308"},
|
||||
ResData: model.SimBasicInfoItem{
|
||||
ICCID: "89860401780000567308",
|
||||
ICCID: "89860401786527567308",
|
||||
MSISDN: "14100007308",
|
||||
IMSI: "460046527567308",
|
||||
},
|
||||
@@ -246,7 +301,7 @@ func TestValidateUpstreamBasicInfoMatchesByMSISDNAndBackfillsCodes(t *testing.T)
|
||||
if rows[0].Reason != "" {
|
||||
t.Fatalf("unexpected reason: %q", rows[0].Reason)
|
||||
}
|
||||
if rows[0].ICCID != "89860401780000567308" || rows[0].IMSI != "460046527567308" {
|
||||
if rows[0].ICCID != "89860401786527567308" || rows[0].IMSI != "460046527567308" {
|
||||
t.Fatalf("expected upstream codes to be backfilled, got %+v", rows[0])
|
||||
}
|
||||
requestKey := upstream.inputs[0].SimKeys[0]
|
||||
@@ -263,7 +318,7 @@ func TestValidateBatchInputSingleMSISDNBackfillsBeforeCompletenessCheck(t *testi
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", MSISDN: "14100007308"},
|
||||
ResData: model.SimBasicInfoItem{
|
||||
ICCID: "89860401780000567308",
|
||||
ICCID: "89860401786527567308",
|
||||
MSISDN: "14100007308",
|
||||
IMSI: "460046527567308",
|
||||
},
|
||||
@@ -282,7 +337,7 @@ func TestValidateBatchInputSingleMSISDNBackfillsBeforeCompletenessCheck(t *testi
|
||||
if task.ValidCount != 1 {
|
||||
t.Fatalf("valid count = %d, want 1, reason=%q", task.ValidCount, rows[0].Reason)
|
||||
}
|
||||
if rows[0].ICCID != "89860401780000567308" || rows[0].IMSI != "460046527567308" {
|
||||
if rows[0].ICCID != "89860401786527567308" || rows[0].IMSI != "460046527567308" {
|
||||
t.Fatalf("expected upstream codes to be backfilled, got %+v", rows[0])
|
||||
}
|
||||
if upstream.calls != 1 {
|
||||
@@ -293,7 +348,7 @@ func TestValidateBatchInputSingleMSISDNBackfillsBeforeCompletenessCheck(t *testi
|
||||
func TestValidateSkipsUpstreamBasicInfoWhenDisabled(t *testing.T) {
|
||||
rows := []*Row{{
|
||||
RowNo: 1,
|
||||
ICCID: "89860401780000567308",
|
||||
ICCID: "89860401786527567308",
|
||||
MSISDN: "14100007308",
|
||||
IMSI: "460046527567308",
|
||||
ChanKey: "chan-a",
|
||||
@@ -323,7 +378,7 @@ func TestValidateSkipsUpstreamBasicInfoWhenDisabled(t *testing.T) {
|
||||
func TestValidateRunsUpstreamBasicInfoWhenEnabled(t *testing.T) {
|
||||
rows := []*Row{{
|
||||
RowNo: 1,
|
||||
ICCID: "89860401780000567308",
|
||||
ICCID: "89860401786527567308",
|
||||
MSISDN: "14100007308",
|
||||
IMSI: "460046527567308",
|
||||
ChanKey: "chan-a",
|
||||
@@ -363,7 +418,7 @@ func TestPopulateExcelRowsReturnsRetryableErrorWhenSourceIsUnreadable(t *testing
|
||||
|
||||
func TestValidateUpstreamBasicInfoWaitsForRateLimitBeforeQuery(t *testing.T) {
|
||||
var order []string
|
||||
rows := []*Row{{RowNo: 1, ICCID: "89860401780000567308", MSISDN: "14100007308", IMSI: "460046527567308", ChanKey: "chan-a"}}
|
||||
rows := []*Row{{RowNo: 1, ICCID: "89860401786527567308", MSISDN: "14100007308", IMSI: "460046527567308", ChanKey: "chan-a"}}
|
||||
limiter := &recordingRateLimiter{
|
||||
onWait: func(context.Context, string, int) error {
|
||||
order = append(order, "limit")
|
||||
@@ -377,9 +432,9 @@ func TestValidateUpstreamBasicInfoWaitsForRateLimitBeforeQuery(t *testing.T) {
|
||||
response: []model.SimBaseRes[model.SimBasicInfoItem]{
|
||||
{
|
||||
Code: 0,
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "89860401780000567308"},
|
||||
SimKey: &model.SimKey{ChanKey: "chan-a", ICCID: "89860401786527567308"},
|
||||
ResData: model.SimBasicInfoItem{
|
||||
ICCID: "89860401780000567308",
|
||||
ICCID: "89860401786527567308",
|
||||
MSISDN: "14100007308",
|
||||
IMSI: "460046527567308",
|
||||
},
|
||||
@@ -406,7 +461,7 @@ func TestValidateUpstreamBasicInfoWaitsForRateLimitBeforeQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateUpstreamBasicInfoStopsWhenRateLimitFails(t *testing.T) {
|
||||
rows := []*Row{{RowNo: 1, ICCID: "89860401780000567308", ChanKey: "chan-a"}}
|
||||
rows := []*Row{{RowNo: 1, ICCID: "89860401786527567308", ChanKey: "chan-a"}}
|
||||
upstream := &basicInfoUpstreamStub{}
|
||||
limiter := &recordingRateLimiter{err: errors.New("redis down")}
|
||||
|
||||
@@ -460,14 +515,14 @@ func TestValidateUpstreamBasicInfoRejectsUpstreamError(t *testing.T) {
|
||||
func TestParseTableRowsAcceptsChineseInboundHeaders(t *testing.T) {
|
||||
rows := parseTableRows([][]string{
|
||||
{"ICCID号", "手机号", "国际移动用户识别码", "国际移动设备识别码", "通道编码", "卡类型", "上游套餐编码", "供应商编码"},
|
||||
{"898604F4152000015473", "1440000615473", "460087436015473", "865111111111111", "channel_003", "通用插拔 SIM 卡", "UP001", "SUP001"},
|
||||
{"898604F4152391115473", "1441543615473", "460087436015473", "865111111111111", "channel_003", "通用插拔 SIM 卡", "UP001", "SUP001"},
|
||||
})
|
||||
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("row count = %d, want 1", len(rows))
|
||||
}
|
||||
row := rows[0]
|
||||
if row.ICCID != "898604F4152000015473" || row.MSISDN != "1440000615473" ||
|
||||
if row.ICCID != "898604F4152391115473" || row.MSISDN != "1441543615473" ||
|
||||
row.IMSI != "460087436015473" || row.IMEI != "865111111111111" || row.ChanKey != "channel_003" {
|
||||
t.Fatalf("unexpected parsed row: %+v", row)
|
||||
}
|
||||
@@ -479,19 +534,88 @@ func TestParseTableRowsAcceptsChineseInboundHeaders(t *testing.T) {
|
||||
func TestParseTableRowsAcceptsRequiredMarkerHeaders(t *testing.T) {
|
||||
rows := parseTableRows([][]string{
|
||||
{"ICCID*", "MSISDN*", "IMSI*", "通道编号*"},
|
||||
{"89860401780000567308", "14100007308", "460046527567308", "channel_003"},
|
||||
{"89860401786527567308", "14100007308", "460046527567308", "channel_003"},
|
||||
})
|
||||
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("row count = %d, want 1", len(rows))
|
||||
}
|
||||
row := rows[0]
|
||||
if row.ICCID != "89860401780000567308" || row.MSISDN != "14100007308" ||
|
||||
if row.ICCID != "89860401786527567308" || row.MSISDN != "14100007308" ||
|
||||
row.IMSI != "460046527567308" || row.ChanKey != "channel_003" {
|
||||
t.Fatalf("unexpected parsed row: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundExcelHeaderRequiresRecognizedIdentifier(t *testing.T) {
|
||||
for _, fields := range [][]string{{"随机内容", "乱码"}, {"通道编号", "卡类型"}, {"ICCID", "通道编号"}, {"MSISDN", "通道编号"}, {"IMSI", "通道编号"}} {
|
||||
got := hasInboundIdentifierHeader(headerIndex(fields))
|
||||
want := fields[0] == "ICCID" || fields[0] == "MSISDN" || fields[0] == "IMSI"
|
||||
if got != want {
|
||||
t.Fatalf("header %v valid = %t, want %t", fields, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopulateExcelRowsRejectsInvalidHeaderAndAcceptsMSISDNHeader(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
header []string
|
||||
valid bool
|
||||
}{
|
||||
{name: "invalid", header: []string{"乱码", "通道编号"}},
|
||||
{name: "msisdn", header: []string{"MSISDN", "通道编号"}, valid: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
workbook := excelize.NewFile()
|
||||
defer func() { _ = workbook.Close() }()
|
||||
for index, value := range test.header {
|
||||
cell, err := excelize.CoordinatesToCellName(index+1, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := workbook.SetCellValue("Sheet1", cell, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := workbook.SetCellValue("Sheet1", "A2", "14100007308"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := workbook.WriteToBuffer()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{Source: Source{ImportFileOssID: 1}}
|
||||
repository := &inboundRepositoryStub{readOssContent: content.Bytes(), readOssName: "cards.xlsx"}
|
||||
err = (&executor{repository: repository}).populateExcelRows(context.Background(), task)
|
||||
if !test.valid {
|
||||
if err == nil || len(task.Rows) != 0 {
|
||||
t.Fatalf("invalid header: err=%v, rows=%v", err, task.Rows)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || len(task.Rows) != 1 || task.Rows[0].MSISDN != "14100007308" {
|
||||
t.Fatalf("valid MSISDN file: err=%v, rows=%v", err, task.Rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInvalidRowSkipsUpstreamForEntireBatch(t *testing.T) {
|
||||
rows := []*Row{
|
||||
{RowNo: 2, ICCID: "invalid", ChanKey: "channel_003"},
|
||||
{RowNo: 3, ICCID: "89860401786527567308", ChanKey: "channel_003"},
|
||||
}
|
||||
upstream := &basicInfoUpstreamStub{}
|
||||
task := &Task{Rows: rows, Options: Options{ChanKey: "channel_003", ValidateUpstreamBasicInfo: "1"}}
|
||||
if err := (&executor{repository: &inboundRepositoryStub{}, upstream: upstream}).validate(context.Background(), task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 || task.ValidCount != 0 || !hasFailedRows(rows) {
|
||||
t.Fatalf("invalid batch should skip upstream: calls=%d valid=%d", upstream.calls, task.ValidCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSkipsUpstreamWhenLocalIdentifierFormatFails(t *testing.T) {
|
||||
rows := []*Row{{RowNo: 1, ICCID: "4", MSISDN: "4", IMSI: "4", ChanKey: "chan-a"}}
|
||||
upstream := &basicInfoUpstreamStub{}
|
||||
@@ -539,8 +663,8 @@ func TestValidateRequiresCompleteIdentifiersAfterUpstreamStep(t *testing.T) {
|
||||
func TestValidateNormalizesExcelInboundOptionsAndBackfillsBatchInfo(t *testing.T) {
|
||||
rows := parseTableRows([][]string{
|
||||
{"ICCID*", "MSISDN*", "IMSI*", "通道编号*", "卡类型", "上游套餐编码", "供应商编码", "是否贴片转插拔", "是否运营商达量断网"},
|
||||
{"89860401780000567308", "14100007308", "460046527567308", "channel_003", "MP1-消费级插拔物联卡", "UP001", "SUP001", "是", "否"},
|
||||
{"89860401780000567309", "14100007309", "460046527567309", "channel_003", "", "UP001", "SUP001", "", ""},
|
||||
{"89860401786527567308", "14100007308", "460046527567308", "channel_003", "MP1-消费级插拔物联卡", "UP001", "SUP001", "是", "否"},
|
||||
{"89860401786527567309", "14100007309", "460046527567309", "channel_003", "", "UP001", "SUP001", "", ""},
|
||||
})
|
||||
task := &Task{Rows: rows, Batch: Batch{SourceType: consts.BatchSourceTypeFile}}
|
||||
|
||||
@@ -563,8 +687,8 @@ func TestValidateNormalizesExcelInboundOptionsAndBackfillsBatchInfo(t *testing.T
|
||||
func TestValidateRejectsMixedInboundOptionsInOneExcelBatch(t *testing.T) {
|
||||
rows := parseTableRows([][]string{
|
||||
{"ICCID*", "MSISDN*", "IMSI*", "通道编号*", "卡类型"},
|
||||
{"89860401780000567308", "14100007308", "460046527567308", "channel_003", "1"},
|
||||
{"89860401780000567309", "14100007309", "460046527567309", "channel_003", "2"},
|
||||
{"89860401786527567308", "14100007308", "460046527567308", "channel_003", "1"},
|
||||
{"89860401786527567309", "14100007309", "460046527567309", "channel_003", "2"},
|
||||
})
|
||||
task := &Task{Rows: rows, Batch: Batch{SourceType: consts.BatchSourceTypeFile}}
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -73,10 +73,10 @@ func validateTask(task *Task) bool {
|
||||
}
|
||||
for _, detail := range task.Details {
|
||||
if detail != nil && detail.Reason == "" {
|
||||
detail.Reason = "same batch contains validation failure; upstream request was skipped"
|
||||
detail.Reason = "同批次存在校验失败卡,本卡未请求上游"
|
||||
}
|
||||
}
|
||||
task.FailureReason = "batch validation failed; upstream request was skipped"
|
||||
task.FailureReason = "批次校验未通过,未发起上游请求"
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func validateDetail(operation Operation, detail *Detail) {
|
||||
return
|
||||
}
|
||||
if detail.Card == nil {
|
||||
detail.Reason = "ICCID does not exist in SIM card records"
|
||||
detail.Reason = "ICCID不存在或不属于当前租户"
|
||||
return
|
||||
}
|
||||
detail.MSISDN = strings.TrimSpace(detail.Card.MSISDN)
|
||||
@@ -94,21 +94,20 @@ func validateDetail(operation Operation, detail *Detail) {
|
||||
detail.StatusBefore = detail.Card.BossCardStatus
|
||||
|
||||
if !detail.Authorized {
|
||||
detail.Reason = "ICCID is outside the current operator authorization scope"
|
||||
detail.Reason = "ICCID不在当前操作员授权范围内"
|
||||
return
|
||||
}
|
||||
var reasons []string
|
||||
if detail.ChanKey == "" {
|
||||
detail.Reason = "ICCID is missing chanKey"
|
||||
return
|
||||
reasons = append(reasons, "ICCID缺少通道标识")
|
||||
}
|
||||
if detail.InputMSISDN != "" && detail.InputMSISDN != detail.MSISDN {
|
||||
detail.Reason = "MSISDN does not match the SIM card record"
|
||||
return
|
||||
reasons = append(reasons, "MSISDN与卡档案不一致")
|
||||
}
|
||||
if detail.InputIMSI != "" && detail.InputIMSI != detail.IMSI {
|
||||
detail.Reason = "IMSI does not match the SIM card record"
|
||||
return
|
||||
reasons = append(reasons, "IMSI与卡档案不一致")
|
||||
}
|
||||
detail.Reason = strings.Join(reasons, ";")
|
||||
// TODO: 正常系统开发完成后恢复卡状态限制
|
||||
// 注释原因:开发环境经常出现卡状态不在预期可操作范围内的情况,先放行
|
||||
// if detail.Card.BossCardStatus != operation.ExpectedStatus {
|
||||
@@ -162,7 +161,7 @@ func (e *executor) changeSubBatch(ctx context.Context, task *Task, details []*De
|
||||
if err != nil {
|
||||
outcomes := make([]Outcome, len(details))
|
||||
for index := range outcomes {
|
||||
outcomes[index].Message = "upstream request failed: " + err.Error()
|
||||
outcomes[index].Message = "请求上游失败:" + err.Error()
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func TestValidateDetailDetectsMSISDNMismatch(t *testing.T) {
|
||||
},
|
||||
}
|
||||
validateDetail(operation, detail)
|
||||
if detail.Reason != "MSISDN does not match the SIM card record" {
|
||||
if detail.Reason != "MSISDN与卡档案不一致" {
|
||||
t.Fatalf("unexpected reason: %q", detail.Reason)
|
||||
}
|
||||
if detail.MSISDN != "msisdn-card" || detail.InputMSISDN != "msisdn-input" {
|
||||
@@ -190,3 +190,22 @@ func (u *fakeUpstream) ChangeSimStatus(_ context.Context, input *model.SimCardSt
|
||||
u.input = input
|
||||
return u.response, u.responseError
|
||||
}
|
||||
|
||||
func TestValidateDetailCollectsIndependentDiagnostics(t *testing.T) {
|
||||
detail := &Detail{ICCID: "card", Authorized: true, InputMSISDN: "wrong-phone", InputIMSI: "wrong-imsi", Card: &Card{MSISDN: "phone", IMSI: "imsi"}}
|
||||
validateDetail(Operation{}, detail)
|
||||
want := "ICCID缺少通道标识;MSISDN与卡档案不一致;IMSI与卡档案不一致"
|
||||
if detail.Reason != want {
|
||||
t.Fatalf("独立校验原因丢失: got=%q want=%q", detail.Reason, want)
|
||||
}
|
||||
unauthorized := &Detail{ICCID: "card", InputMSISDN: "wrong", Card: &Card{MSISDN: "phone"}}
|
||||
validateDetail(Operation{}, unauthorized)
|
||||
if unauthorized.Reason != "ICCID不在当前操作员授权范围内" {
|
||||
t.Fatalf("越权卡不应返回其他数据差异: %q", unauthorized.Reason)
|
||||
}
|
||||
existing := &Detail{Reason: "原始输入格式错误", Authorized: true, Card: &Card{}}
|
||||
validateDetail(Operation{}, existing)
|
||||
if existing.Reason != "原始输入格式错误" {
|
||||
t.Fatal("原始原因被覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package rolesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rolepolicy"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SyncInheritedRole(ctx context.Context, message *model.AgentRoleSyncMessage) error {
|
||||
if message == nil || message.SourceRoleId <= 0 || message.SourceDeptId <= 0 || message.TenantId == "" {
|
||||
return gerror.New("invalid agent role sync message")
|
||||
}
|
||||
template, templateErr := rolepolicy.Template(ctx)
|
||||
if templateErr != nil {
|
||||
return templateErr
|
||||
}
|
||||
if message.TemplateRole && (template == nil || message.SourceRoleId != template.RoleId) {
|
||||
message.Superseded = true
|
||||
return nil
|
||||
}
|
||||
if template != nil && message.SourceRoleId == template.RoleId && message.SourceDeptId == template.DeptId && (message.TenantId == template.TenantId || message.TenantId == "") {
|
||||
return syncTemplateRoles(ctx, template, message)
|
||||
}
|
||||
if template != nil {
|
||||
message.Superseded = true
|
||||
return nil
|
||||
}
|
||||
var source *entity.SysRole
|
||||
sourceModel := dao.SysRole.Ctx(ctx).Master().Where(dao.SysRole.Columns().RoleId, message.SourceRoleId)
|
||||
if message.TenantId != "" {
|
||||
sourceModel = sourceModel.Where(dao.SysRole.Columns().TenantId, message.TenantId)
|
||||
}
|
||||
if err := sourceModel.Scan(&source); err != nil {
|
||||
return err
|
||||
}
|
||||
if source == nil || source.IsPass != 1 || source.DeletedAt != nil {
|
||||
message.Superseded = true
|
||||
return nil
|
||||
}
|
||||
if message.SourceDeptId > 0 && message.SourceDeptId != source.DeptId {
|
||||
return gerror.New("role sync source department changed")
|
||||
}
|
||||
var children []*entity.SysDept
|
||||
if err := dao.SysDept.Ctx(ctx).Master().
|
||||
Where(dao.SysDept.Columns().TenantId, source.TenantId).
|
||||
Where(dao.SysDept.Columns().ParentId, source.DeptId).
|
||||
Where(dao.SysDept.Columns().DeptType, 1).
|
||||
Scan(&children); err != nil {
|
||||
return err
|
||||
}
|
||||
failed := 0
|
||||
if len(message.ChildDeptIds) == 0 {
|
||||
message.TotalCount = len(children)
|
||||
message.SuccessCount = 0
|
||||
}
|
||||
failedDeptIds := make([]int64, 0)
|
||||
var firstErr error
|
||||
for _, child := range children {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(message.ChildDeptIds) > 0 && !slices.Contains(message.ChildDeptIds, child.DeptId) {
|
||||
continue
|
||||
}
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return syncInheritedRoleChildrenTx(ctx, tx, message.SourceRoleId, source.TenantId, source.DeptId, message.ActorId, child.DeptId)
|
||||
})
|
||||
if err != nil {
|
||||
failed++
|
||||
failedDeptIds = append(failedDeptIds, child.DeptId)
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
g.Log().Warningf(ctx, "agent role sync child failed, sourceRoleId:%d, childDeptId:%d, err:%v", source.RoleId, child.DeptId, err)
|
||||
} else {
|
||||
message.SuccessCount++
|
||||
}
|
||||
}
|
||||
message.ChildDeptIds = failedDeptIds
|
||||
message.FailedCount = len(failedDeptIds)
|
||||
if firstErr != nil {
|
||||
return gerror.Wrapf(firstErr, "agent role sync failed for %d of %d children", failed, len(children))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncInheritedRoleChildrenTx(ctx context.Context, tx gdb.TX, sourceRoleID int64, tenantId string, sourceDeptId, actorId, childDeptId int64) error {
|
||||
if sourceRoleID == 0 {
|
||||
return nil
|
||||
}
|
||||
var source *entity.SysRole
|
||||
sourceModel := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where(dao.SysRole.Columns().RoleId, sourceRoleID)
|
||||
if tenantId != "" {
|
||||
sourceModel = sourceModel.Where(dao.SysRole.Columns().TenantId, tenantId)
|
||||
}
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
sourceModel = sourceModel.LockUpdate()
|
||||
}
|
||||
if err := sourceModel.Scan(&source); err != nil {
|
||||
return err
|
||||
}
|
||||
if source == nil || source.RoleId == 0 || source.DeletedAt != nil || source.IsPass != 1 {
|
||||
return nil
|
||||
}
|
||||
if sourceDeptId > 0 && source.DeptId != sourceDeptId {
|
||||
return gerror.New("role sync source department changed")
|
||||
}
|
||||
var childDepts []*entity.SysDept
|
||||
childDeptModel := tx.Ctx(ctx).Model(dao.SysDept.Table()).Where(dao.SysDept.Columns().TenantId, source.TenantId).Where(dao.SysDept.Columns().ParentId, source.DeptId).Where(dao.SysDept.Columns().DeptType, 1).Where(dao.SysDept.Columns().DeptId, childDeptId)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
childDeptModel = childDeptModel.LockUpdate()
|
||||
}
|
||||
if err := childDeptModel.Scan(&childDepts); err != nil {
|
||||
return err
|
||||
}
|
||||
var menus []*entity.SysRoleMenu
|
||||
if err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where(dao.SysRoleMenu.Columns().RoleId, sourceRoleID).OrderAsc(dao.SysRoleMenu.Columns().MenuId).Scan(&menus); err != nil {
|
||||
return err
|
||||
}
|
||||
menuIDs := make([]int64, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
menuIDs = append(menuIDs, menu.MenuId)
|
||||
}
|
||||
allowed, err := rolepolicy.RestrictMenus(ctx, source.TenantId, source.DeptId, menuIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template, templateErr := rolepolicy.Template(ctx)
|
||||
if templateErr != nil {
|
||||
return templateErr
|
||||
}
|
||||
filtered := make([]*entity.SysRoleMenu, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
if slices.Contains(allowed, menu.MenuId) {
|
||||
copy := *menu
|
||||
if template != nil {
|
||||
copy.DataScope = consts.SysRoleMenuDataScopeRole
|
||||
}
|
||||
filtered = append(filtered, ©)
|
||||
}
|
||||
}
|
||||
menus = filtered
|
||||
for _, childDept := range childDepts {
|
||||
var children []*entity.SysRole
|
||||
if err := tx.Ctx(ctx).Model(dao.SysRole.Table()+" r").
|
||||
Fields("r.*").
|
||||
Where("r.tenant_id", source.TenantId).
|
||||
Where("r.dept_id", childDept.DeptId).
|
||||
Where("r.created_dept", source.DeptId).
|
||||
Where("r.role_id", childDept.DeptRole).
|
||||
Scan(&children); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(children) > 1 {
|
||||
return gerror.Newf("multiple inherited roles in department %d for role %d", childDept.DeptId, source.RoleId)
|
||||
}
|
||||
if len(children) == 0 {
|
||||
var legacy *entity.SysRole
|
||||
if childDept.DeptRole > 0 {
|
||||
if err := tx.Ctx(ctx).Model(dao.SysRole.Table()).
|
||||
Where(dao.SysRole.Columns().RoleId, childDept.DeptRole).
|
||||
Where(dao.SysRole.Columns().DeptId, childDept.DeptId).
|
||||
Where(dao.SysRole.Columns().TenantId, source.TenantId).
|
||||
Scan(&legacy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if legacy != nil && legacy.RoleId > 0 {
|
||||
if legacy.CreatedDept != source.DeptId || legacy.RoleKey != source.RoleKey {
|
||||
return gerror.Newf("cannot identify inherited role for department %d", childDept.DeptId)
|
||||
}
|
||||
children = []*entity.SysRole{legacy}
|
||||
} else {
|
||||
if childDept.DeptRole > 0 {
|
||||
return gerror.Newf("missing default role for department %d", childDept.DeptId)
|
||||
}
|
||||
return gerror.Newf("missing default role for department %d", childDept.DeptId)
|
||||
}
|
||||
}
|
||||
for _, child := range children {
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where(dao.SysRole.Columns().RoleId, child.RoleId).Data(do.SysRole{RoleName: strings.TrimSpace(source.RoleName), RoleKey: source.RoleKey, RoleSort: source.RoleSort, DataScope: source.DataScope, MenuCheckStrictly: source.MenuCheckStrictly, DeptCheckStrictly: source.DeptCheckStrictly, Status: source.Status, Remark: source.Remark, UpdatedBy: actorId}).OmitNil().Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where(dao.SysRoleMenu.Columns().RoleId, child.RoleId).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
menuData := make([]*do.SysRoleMenu, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
menuData = append(menuData, &do.SysRoleMenu{TenantId: source.TenantId, RoleId: child.RoleId, MenuId: menu.MenuId, DataScope: menu.DataScope})
|
||||
}
|
||||
if len(menuData) > 0 {
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Data(menuData).OmitNil().Batch(200).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package rolesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rolepolicy"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTemplateSwitchOverwritesDefaultRolesAndPreservesOtherRoles(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES (1,'000000',0,1,0),(2,'000000',1,1,21),(3,'000000',2,1,31),(4,'000000',0,1,41)")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role(role_id,tenant_id,dept_id,role_name,role_key,status,data_scope,is_pass,created_dept) VALUES (10,'000000',1,'模板','template','0','4',0,1),(11,'000000',1,'新模板','next','0','4',0,1),(21,'000000',2,'管理员','admin','0','4',0,1),(31,'000000',3,'三级管理员','admin','0','4',0,2),(22,'000000',2,'财务','finance','0','3',0,2),(41,'000000',4,'其他机构','other','0','4',0,4)")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES ('000000','agent.role.template.role_id','10')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES ('000000',10,100,'0'),('000000',10,200,'0'),('000000',11,200,'0'),('000000',22,100,'3'),('000000',41,100,'0')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_user_role(user_id,role_id) VALUES(99,21)")
|
||||
message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, TemplateRole: true}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err := SyncInheritedRole(ctx, message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if message.TotalCount != 2 || message.SuccessCount != 2 {
|
||||
t.Fatalf("unexpected hierarchy counts: %+v", message)
|
||||
}
|
||||
for _, roleID := range []int64{21, 31} {
|
||||
assertAgentRoleSyncCount(t, ctx, fmt.Sprintf("SELECT COUNT(*) FROM sys_role_menu WHERE role_id=%d AND menu_id IN(100,200)", roleID), 2)
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=22 AND menu_id=200", 0)
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_config SET config_value='11'")
|
||||
ids, err := rolepolicy.RestrictMenus(ctx, "000000", 3, []int64{100, 200})
|
||||
if err != nil || !reflect.DeepEqual(ids, []int64{100, 200}) {
|
||||
t.Fatalf("template switch must wait for confirmed distribution: %v %v", ids, err)
|
||||
}
|
||||
if err := SyncInheritedRole(ctx, message); err != nil || !message.Superseded {
|
||||
t.Fatalf("obsolete template must not propagate: %+v %v", message, err)
|
||||
}
|
||||
next := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 11, SourceDeptId: 1, TemplateRole: true}
|
||||
if err := SyncInheritedRole(ctx, next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, roleID := range []int64{21, 31} {
|
||||
assertAgentRoleSyncCount(t, ctx, fmt.Sprintf("SELECT COUNT(*) FROM sys_role_menu WHERE role_id=%d AND menu_id=100", roleID), 0)
|
||||
assertAgentRoleSyncCount(t, ctx, fmt.Sprintf("SELECT COUNT(*) FROM sys_role_menu WHERE role_id=%d AND menu_id=200", roleID), 1)
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE role_id=31 AND created_dept=2", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=22 AND menu_id=100", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=41 AND menu_id=100", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_user_role WHERE user_id=99 AND role_id=21", 1)
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role(role_id,tenant_id,dept_id,is_pass,status,data_scope) VALUES(25,'000000',2,1,'0','4')")
|
||||
legacy := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 25, SourceDeptId: 2}
|
||||
if err := SyncInheritedRole(ctx, legacy); err != nil || !legacy.Superseded {
|
||||
t.Fatalf("legacy local templates must not overwrite configured hierarchy: %+v %v", legacy, err)
|
||||
}
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_config SET config_value='invalid'")
|
||||
if _, err := rolepolicy.RestrictMenus(ctx, "000000", 3, []int64{200}); err == nil {
|
||||
t.Fatal("invalid template must deny")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncInheritedRoleOnlyUpdatesDirectEnterpriseChildren(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept (dept_id, tenant_id, parent_id, dept_type, dept_role) VALUES
|
||||
(1, '000000', 0, 1, 0),
|
||||
(2, '000000', 1, 1, 21),
|
||||
(3, '000000', 2, 1, 0),
|
||||
(4, '000000', 1, 0, 0)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role
|
||||
(role_id, tenant_id, dept_id, role_name, role_key, role_sort, data_scope, menu_check_strictly, dept_check_strictly, status, is_pass, created_dept, created_by, updated_by, remark)
|
||||
VALUES
|
||||
(10, '000000', 1, '企业客户', 'enterpriseCustomer', 1, '4', 1, 1, '0', 1, 1, 1, 1, 'source'),
|
||||
(20, '000000', 2, '下级自建角色', 'localRole', 5, '3', 1, 1, '0', 0, 2, 2, 2, 'local'),
|
||||
(21, '000000', 2, '旧下发角色', 'enterpriseCustomer', 1, '4', 1, 1, '0', 0, 1, 1, 1, 'legacy')`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu (tenant_id, role_id, menu_id, data_scope) VALUES ('000000', 10, 100, '7')`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu (tenant_id, role_id, menu_id, data_scope) VALUES ('000000', 21, 200, '7')`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_user_role (user_id, role_id) VALUES (99, 21)`)
|
||||
|
||||
message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, ActorId: 9}
|
||||
if err := SyncInheritedRole(ctx, message); err != nil {
|
||||
t.Fatalf("first sync failed: %v", err)
|
||||
}
|
||||
if err := SyncInheritedRole(ctx, message); err != nil {
|
||||
t.Fatalf("second sync failed: %v", err)
|
||||
}
|
||||
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE dept_id = 2 AND created_dept = 1", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE role_id = 21 AND created_dept = 1", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_user_role WHERE user_id = 99 AND role_id = 21", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id = 21 AND menu_id = 200", 0)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE dept_id = 3 AND created_dept = 1", 0)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE dept_id = 4 AND created_dept = 1", 0)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE role_id = 20 AND role_name = '下级自建角色' AND created_dept = 2", 1)
|
||||
|
||||
menuScope, err := g.DB().GetValue(ctx, `SELECT rm.data_scope FROM sys_role_menu rm JOIN sys_role r ON r.role_id = rm.role_id WHERE r.dept_id = 2 AND r.created_dept = 1 AND rm.menu_id = 100`)
|
||||
if err != nil {
|
||||
t.Fatalf("query inherited menu data scope failed: %v", err)
|
||||
}
|
||||
if menuScope.String() != "7" {
|
||||
t.Fatalf("inherited menu data scope = %q, want 7", menuScope.String())
|
||||
}
|
||||
|
||||
mustExecAgentRoleSyncTest(t, ctx, `UPDATE sys_role SET role_name = '停止下发后的名称', is_pass = 0 WHERE role_id = 10`)
|
||||
if err := SyncInheritedRole(ctx, message); err != nil {
|
||||
t.Fatalf("disabled source sync failed: %v", err)
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE dept_id = 2 AND created_dept = 1 AND role_name = '企业客户'", 1)
|
||||
}
|
||||
|
||||
func TestSyncInheritedRoleRejectsUnrelatedDefaultRole(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept (dept_id, tenant_id, parent_id, dept_type, dept_role) VALUES (1, '000000', 0, 1, 0), (2, '000000', 1, 1, 21)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role (role_id, tenant_id, dept_id, role_name, role_key, is_pass, created_dept) VALUES (10, '000000', 1, '下发角色', 'source', 1, 1), (21, '000000', 2, '自建默认角色', 'local', 0, 2)`)
|
||||
err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguous default role to be rejected")
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role WHERE role_id = 21 AND created_dept = 2 AND role_name = '自建默认角色'", 1)
|
||||
}
|
||||
|
||||
func TestTemplateSyncReportsMissingRoles(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_dept (dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES (1,'000000',0,1,10),(2,'000000',1,1,0),(3,'000000',1,1,31)")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role (role_id,tenant_id,dept_id,status,data_scope,is_pass) VALUES (10,'000000',1,'0','4',1),(31,'000000',3,'0','4',0)")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_role SET created_dept=1 WHERE role_id=31")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_config (config_id,tenant_id,config_key,config_value) VALUES (1,'000000','agent.role.template.role_id','10')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu (tenant_id,role_id,menu_id,data_scope) VALUES ('000000',10,200,'0')")
|
||||
if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err == nil {
|
||||
t.Fatal("missing default role must report partial failure")
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=200", 1)
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role (role_id,tenant_id,dept_id,status,data_scope) VALUES (21,'000000',2,'0','4')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_role SET created_dept=1 WHERE role_id=21")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_dept SET dept_role=21 WHERE dept_id=2")
|
||||
if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=200", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=200", 1)
|
||||
}
|
||||
func setupAgentRoleSyncTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := gdb.SetConfig(gdb.Config{
|
||||
"default": gdb.ConfigGroup{
|
||||
{Type: "sqlite", Link: "sqlite::@file(:memory:)", Debug: false},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("configure sqlite failed: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
t.Cleanup(func() { _ = g.DB().Close(ctx) })
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, dept_type INTEGER, status TEXT DEFAULT '0', dept_role INTEGER, deleted_at DATETIME)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_config (config_id INTEGER PRIMARY KEY, tenant_id TEXT, config_name TEXT, config_key TEXT, config_value TEXT, config_type TEXT, created_dept INTEGER, created_by INTEGER, created_at DATETIME, updated_by INTEGER, updated_at DATETIME, remark TEXT)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role (
|
||||
role_id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT, dept_id INTEGER, role_name TEXT, role_key TEXT, role_sort INTEGER, data_scope TEXT,
|
||||
menu_check_strictly INTEGER, dept_check_strictly INTEGER, status TEXT, is_pass INTEGER, created_dept INTEGER, created_by INTEGER, created_at DATETIME, updated_by INTEGER, updated_at DATETIME, deleted_by INTEGER, deleted_at DATETIME, remark TEXT)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role_menu (tenant_id TEXT, role_id INTEGER, menu_id INTEGER, data_scope TEXT)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role_dept (tenant_id TEXT, role_id INTEGER, dept_id INTEGER)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_user_role (tenant_id TEXT DEFAULT '000000', user_id INTEGER, role_id INTEGER)`)
|
||||
}
|
||||
|
||||
func mustExecAgentRoleSyncTest(t *testing.T, ctx context.Context, sql string) {
|
||||
t.Helper()
|
||||
if _, err := g.DB().Exec(ctx, sql); err != nil {
|
||||
t.Fatalf("execute sql failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAgentRoleSyncCount(t *testing.T, ctx context.Context, sql string, expected int) {
|
||||
t.Helper()
|
||||
count, err := g.DB().GetValue(ctx, sql)
|
||||
if err != nil {
|
||||
t.Fatalf("query count failed: %v", err)
|
||||
}
|
||||
if count.Int() != expected {
|
||||
t.Fatalf("count = %d, want %d, sql=%s", count.Int(), expected, sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleSyncRetriesOnlyFailedDepartments(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept (dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES (1,'000000',0,1,0),(2,'000000',1,1,21),(3,'000000',1,1,31)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role (role_id,tenant_id,dept_id,role_name,role_key,is_pass,created_dept) VALUES (10,'000000',1,'source','source',1,1),(21,'000000',2,'before','source',0,1),(31,'000000',3,'local','local',0,3)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu (tenant_id,role_id,menu_id,data_scope) VALUES ('000000',10,100,'7')`)
|
||||
message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}
|
||||
if err := SyncInheritedRole(ctx, message); err == nil {
|
||||
t.Fatal("expected invalid department failure")
|
||||
}
|
||||
if !reflect.DeepEqual(message.ChildDeptIds, []int64{3}) {
|
||||
t.Fatalf("unexpected failures: %v", message.ChildDeptIds)
|
||||
}
|
||||
mustExecAgentRoleSyncTest(t, ctx, `UPDATE sys_role SET role_name='already-successful' WHERE role_id=21`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `UPDATE sys_role SET created_dept=1,role_key='source' WHERE role_id=31`)
|
||||
if err := SyncInheritedRole(ctx, message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role WHERE role_id=21 AND role_name='already-successful'`, 1)
|
||||
assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=100`, 1)
|
||||
if len(message.ChildDeptIds) != 0 {
|
||||
t.Fatal("failure list must clear after success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestObsoleteTemplateTaskDoesNotPropagateAsNormalRole(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_dept (dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES (1,'000000',0,1,10),(2,'000000',1,1,21)")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role (role_id,tenant_id,dept_id,role_key,is_pass,created_dept) VALUES (10,'000000',1,'source',1,1),(21,'000000',2,'source',0,1)")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu (tenant_id,role_id,menu_id,data_scope) VALUES ('000000',10,100,'7'),('000000',21,200,'7')")
|
||||
message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, TemplateRole: true}
|
||||
if err := SyncInheritedRole(ctx, message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !message.Superseded {
|
||||
t.Fatal("obsolete template task must be marked superseded")
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=200", 1)
|
||||
}
|
||||
|
||||
func TestMissingDefaultRoleDoesNotCreateOrphanRole(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,0)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role(role_id,tenant_id,dept_id,role_name,role_key,is_pass,created_dept) VALUES(10,'000000',1,'source','source',1,1)`)
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err == nil {
|
||||
t.Fatal("missing role must fail")
|
||||
}
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role WHERE dept_id=2`, 0)
|
||||
}
|
||||
|
||||
func TestTemplateChildRevalidatesCurrentBindingAndSource(t *testing.T) {
|
||||
for _, change := range []string{
|
||||
"UPDATE sys_dept SET parent_id=99 WHERE dept_id=2",
|
||||
"UPDATE sys_dept SET dept_type=0 WHERE dept_id=2",
|
||||
"UPDATE sys_role SET status='1' WHERE role_id=10",
|
||||
"UPDATE sys_role_menu SET data_scope='1' WHERE role_id=10",
|
||||
} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,21)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role(role_id,tenant_id,dept_id,status,data_scope,is_pass) VALUES(10,'000000',1,'0','4',1),(21,'000000',2,'0','3',0)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_role SET created_dept=1 WHERE role_id=21")
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',21,200,'0')`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, change)
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return syncTemplateChildTx(ctx, tx, &entity.SysRole{RoleId: 10, TenantId: "000000", DeptId: 1}, &entity.SysDept{DeptId: 2, DeptRole: 21, ParentId: 1}, 7)
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("changed source or binding must fail")
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=200`, 1)
|
||||
assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=100`, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedDefaultRoleIsNotRestoredBySynchronization(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,21)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role(role_id,tenant_id,dept_id,role_name,role_key,is_pass,created_dept,deleted_at) VALUES(10,'000000',1,'source','source',1,1,NULL),(21,'000000',2,'deleted','source',0,1,'2026-10-13 00:00:00')`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',21,200,'0')`)
|
||||
if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err == nil {
|
||||
t.Fatal("deleted default role must fail")
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=200`, 1)
|
||||
assertAgentRoleSyncCount(t, ctx, `SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=100`, 0)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package rolesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Start(ctx context.Context) (func(), error) {
|
||||
if !g.Cfg().MustGet(ctx, "roleSync.enabled", false).Bool() {
|
||||
return func() {}, nil
|
||||
}
|
||||
if !rabbitmq.Enabled(ctx) {
|
||||
return nil, gerror.New("权限同步 Worker 必须启用 RabbitMQ 并配置连接")
|
||||
}
|
||||
workerCtx, cancel := context.WithCancel(ctx)
|
||||
go superviseConsumer(workerCtx)
|
||||
g.Log().Info(ctx, "role sync Worker started")
|
||||
return cancel, nil
|
||||
}
|
||||
|
||||
func handleMessage(ctx context.Context, message model.AgentRoleSyncMessage) error {
|
||||
if err := syncRoleMessage(ctx, &message); err != nil {
|
||||
g.Log().Errorf(ctx, "role sync failed, taskId:%s, roleId:%d, attempt:%d, err:%+v", message.TaskId, message.SourceRoleId, message.RetryCount, err)
|
||||
message.RetryCount++
|
||||
body, marshalErr := json.Marshal(message)
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
if message.RetryCount > 5 {
|
||||
deadLetter, marshalErr := json.Marshal(model.AgentRoleSyncDeadLetterMessage{
|
||||
Message: message, Error: err.Error(), FailedAt: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
return publishRoleDeadLetter(ctx, deadLetter)
|
||||
}
|
||||
delay := time.Duration(1<<uint(message.RetryCount-1)) * 5 * time.Second
|
||||
if publishErr := publishRoleRetry(ctx, delay, body); publishErr != nil {
|
||||
return gerror.Wrap(publishErr, fmt.Sprintf("role sync retry publish failed: %v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var consumeRoleSync = rabbitmq.ConsumeWithDone
|
||||
|
||||
var syncRoleMessage = SyncInheritedRole
|
||||
|
||||
var publishRoleRetry = func(ctx context.Context, delay time.Duration, body []byte) error {
|
||||
return rabbitmq.PublishDelayedBytesWithHeaders(ctx, consts.QueueAgentRoleSync+".retry", consts.QueueAgentRoleSync, delay, body, nil)
|
||||
}
|
||||
|
||||
var publishRoleDeadLetter = func(ctx context.Context, body []byte) error {
|
||||
return rabbitmq.PublishBytesWithHeaders(ctx, consts.QueueAgentRoleSync+".dlq", body, nil)
|
||||
}
|
||||
|
||||
func superviseConsumer(ctx context.Context) {
|
||||
for ctx.Err() == nil {
|
||||
done, err := consumeRoleSync(ctx, consts.QueueAgentRoleSync, "iotlcm-worker-agent-role-sync", func(ctx context.Context, body []byte) error {
|
||||
var message model.AgentRoleSyncMessage
|
||||
invalid := json.Unmarshal(body, &message)
|
||||
if invalid != nil || message.SourceRoleId <= 0 || message.SourceDeptId <= 0 || strings.TrimSpace(message.TenantId) == "" || message.RetryCount < 0 {
|
||||
return quarantineRoleSync(ctx, body)
|
||||
}
|
||||
if err := handleMessage(ctx, message); err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
g.Log().Warningf(ctx, "role sync consumer disconnected, restarting: %v", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var quarantineRoleSync = func(ctx context.Context, body []byte) error {
|
||||
if err := publishRoleDeadLetter(ctx, body); err != nil {
|
||||
return gerror.Wrap(err, "保存无效权限消息到死信队列失败")
|
||||
}
|
||||
g.Log().Warning(ctx, "无效权限消息已隔离到死信队列")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package rolesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMessageRetryAndDeadLetter(t *testing.T) {
|
||||
originalSync, originalRetry, originalDead := syncRoleMessage, publishRoleRetry, publishRoleDeadLetter
|
||||
t.Cleanup(func() {
|
||||
syncRoleMessage, publishRoleRetry, publishRoleDeadLetter = originalSync, originalRetry, originalDead
|
||||
})
|
||||
syncRoleMessage = func(_ context.Context, message *model.AgentRoleSyncMessage) error {
|
||||
message.ChildDeptIds = []int64{200}
|
||||
return errors.New("child failed")
|
||||
}
|
||||
for attempt := 0; attempt <= 5; attempt++ {
|
||||
retries, deadLetters := 0, 0
|
||||
publishRoleRetry = func(_ context.Context, delay time.Duration, body []byte) error {
|
||||
retries++
|
||||
var message model.AgentRoleSyncMessage
|
||||
if err := json.Unmarshal(body, &message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message.RetryCount != attempt+1 || len(message.ChildDeptIds) != 1 || message.ChildDeptIds[0] != 200 || delay != time.Duration(1<<uint(attempt))*5*time.Second {
|
||||
t.Fatalf("invalid retry: %+v, delay=%s", message, delay)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
publishRoleDeadLetter = func(_ context.Context, body []byte) error {
|
||||
deadLetters++
|
||||
var dead model.AgentRoleSyncDeadLetterMessage
|
||||
if err := json.Unmarshal(body, &dead); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dead.Error != "child failed" || dead.FailedAt == "" || dead.Message.RetryCount != 6 {
|
||||
t.Fatalf("invalid dead letter: %+v", dead)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := handleMessage(context.Background(), model.AgentRoleSyncMessage{RetryCount: attempt}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if (attempt < 5 && (retries != 1 || deadLetters != 0)) || (attempt == 5 && (retries != 0 || deadLetters != 1)) {
|
||||
t.Fatalf("unexpected destinations: retry=%d, dead=%d", retries, deadLetters)
|
||||
}
|
||||
}
|
||||
brokerError := errors.New("broker offline")
|
||||
publishRoleRetry = func(context.Context, time.Duration, []byte) error { return brokerError }
|
||||
publishRoleDeadLetter = func(context.Context, []byte) error { return brokerError }
|
||||
for _, attempt := range []int{0, 5} {
|
||||
if err := handleMessage(context.Background(), model.AgentRoleSyncMessage{RetryCount: attempt}); err == nil {
|
||||
t.Fatal("publication failure must prevent acknowledgement")
|
||||
}
|
||||
}
|
||||
syncRoleMessage = func(context.Context, *model.AgentRoleSyncMessage) error { return nil }
|
||||
if err := handleMessage(context.Background(), model.AgentRoleSyncMessage{}); err != nil {
|
||||
t.Fatalf("successful sync should not publish retries: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeConfiguration(t *testing.T) {
|
||||
original := g.Cfg().GetAdapter()
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(original) })
|
||||
for _, test := range []struct {
|
||||
content string
|
||||
wantError bool
|
||||
}{
|
||||
{"roleSync:\n enabled: false\n", false},
|
||||
{"roleSync:\n enabled: true\nrabbitmq:\n enabled: false\n", true},
|
||||
} {
|
||||
adapter, err := gcfg.NewAdapterContent(test.content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
stop, err := Start(context.Background())
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("unexpected initialization: %v", err)
|
||||
}
|
||||
if stop != nil {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumerReconnectsAfterConnectionFailureAndChannelClose(t *testing.T) {
|
||||
original := consumeRoleSync
|
||||
t.Cleanup(func() { consumeRoleSync = original })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
reconnected := make(chan struct{})
|
||||
calls := 0
|
||||
consumeRoleSync = func(context.Context, string, string, rabbitmq.Handler) (<-chan struct{}, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return nil, errors.New("broker offline")
|
||||
}
|
||||
if calls == 2 {
|
||||
closed := make(chan struct{})
|
||||
close(closed)
|
||||
return closed, nil
|
||||
}
|
||||
close(reconnected)
|
||||
return make(chan struct{}), nil
|
||||
}
|
||||
finished := make(chan struct{})
|
||||
go func() { defer close(finished); superviseConsumer(ctx) }()
|
||||
select {
|
||||
case <-reconnected:
|
||||
case <-time.After(10 * time.Second):
|
||||
cancel()
|
||||
<-finished
|
||||
t.Fatal("consumer did not reconnect")
|
||||
}
|
||||
cancel()
|
||||
<-finished
|
||||
if calls != 3 {
|
||||
t.Fatalf("unexpected restarts: %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidMessagesAreQuarantinedAndPublicationFailureIsRetried(t *testing.T) {
|
||||
originalConsume, originalQuarantine := consumeRoleSync, quarantineRoleSync
|
||||
t.Cleanup(func() { consumeRoleSync = originalConsume; quarantineRoleSync = originalQuarantine })
|
||||
for _, body := range []string{`broken json`, `{"sourceRoleId":18}`} {
|
||||
for _, publishFails := range []bool{false, true} {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
calls := 0
|
||||
quarantineRoleSync = func(context.Context, []byte) error {
|
||||
calls++
|
||||
if publishFails {
|
||||
return errors.New("broker offline")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
consumeRoleSync = func(ctx context.Context, queue, consumer string, handler rabbitmq.Handler) (<-chan struct{}, error) {
|
||||
cancel()
|
||||
err := handler(ctx, []byte(body))
|
||||
if (err != nil) != publishFails {
|
||||
t.Fatalf("unexpected quarantine result: %v", err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
close(done)
|
||||
return done, nil
|
||||
}
|
||||
superviseConsumer(ctx)
|
||||
cancel()
|
||||
if calls != 1 {
|
||||
t.Fatalf("invalid message not isolated: %d", calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package rolesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rolepolicy"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func syncTemplateRoles(ctx context.Context, template *entity.SysRole, message *model.AgentRoleSyncMessage) error {
|
||||
departments, err := templateDescendants(ctx, template)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
retrying := len(message.ChildDeptIds) > 0
|
||||
if !retrying {
|
||||
message.TotalCount = len(departments)
|
||||
message.SuccessCount = 0
|
||||
}
|
||||
pending := make(map[int64]bool, len(message.ChildDeptIds))
|
||||
for _, deptID := range message.ChildDeptIds {
|
||||
pending[deptID] = true
|
||||
}
|
||||
failed := make(map[int64]bool)
|
||||
failedDeptIDs := make([]int64, 0)
|
||||
var firstErr error
|
||||
for _, child := range departments {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if retrying && !pending[child.DeptId] {
|
||||
continue
|
||||
}
|
||||
delete(pending, child.DeptId)
|
||||
var syncErr error
|
||||
if failed[child.ParentId] {
|
||||
syncErr = gerror.Newf("上级机构%d尚未同步成功", child.ParentId)
|
||||
} else {
|
||||
syncErr = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return syncTemplateChildTx(ctx, tx, template, child, message.ActorId)
|
||||
})
|
||||
}
|
||||
if syncErr != nil {
|
||||
failed[child.DeptId] = true
|
||||
failedDeptIDs = append(failedDeptIDs, child.DeptId)
|
||||
if firstErr == nil {
|
||||
firstErr = syncErr
|
||||
}
|
||||
g.Log().Errorf(ctx, "agent template sync child failed, deptId:%d, err:%v", child.DeptId, syncErr)
|
||||
continue
|
||||
}
|
||||
message.SuccessCount++
|
||||
}
|
||||
for deptID := range pending {
|
||||
failedDeptIDs = append(failedDeptIDs, deptID)
|
||||
if firstErr == nil {
|
||||
firstErr = gerror.Newf("待同步机构%d已不在当前模板的下级范围,请核对机构关系", deptID)
|
||||
}
|
||||
}
|
||||
slices.Sort(failedDeptIDs)
|
||||
message.ChildDeptIds = failedDeptIDs
|
||||
message.FailedCount = len(failedDeptIDs)
|
||||
if firstErr != nil {
|
||||
return gerror.Wrapf(firstErr, "代理模板同步失败企业数:%d", len(failedDeptIDs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func templateDescendants(ctx context.Context, template *entity.SysRole) ([]*entity.SysDept, error) {
|
||||
queue := []int64{template.DeptId}
|
||||
seen := map[int64]bool{template.DeptId: true}
|
||||
departments := make([]*entity.SysDept, 0)
|
||||
for len(queue) > 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentID := queue[0]
|
||||
queue = queue[1:]
|
||||
var children []*entity.SysDept
|
||||
if err := dao.SysDept.Ctx(ctx).Master().Where("tenant_id", template.TenantId).Where("parent_id", parentID).Where("dept_type", 1).OrderAsc("dept_id").Scan(&children); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
if seen[child.DeptId] {
|
||||
return nil, gerror.New("机构上下级存在循环")
|
||||
}
|
||||
seen[child.DeptId] = true
|
||||
queue = append(queue, child.DeptId)
|
||||
departments = append(departments, child)
|
||||
}
|
||||
}
|
||||
return departments, nil
|
||||
}
|
||||
|
||||
func syncTemplateChildTx(ctx context.Context, tx gdb.TX, template *entity.SysRole, child *entity.SysDept, actorID int64) error {
|
||||
if child.DeptRole <= 0 {
|
||||
return gerror.New("下级机构缺失默认角色")
|
||||
}
|
||||
lock := tx.Ctx(ctx).Model(dao.SysDept.Table()).Where("dept_id", child.DeptId).Where("tenant_id", template.TenantId)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
lock = lock.LockUpdate()
|
||||
}
|
||||
var locked *entity.SysDept
|
||||
if err := lock.Scan(&locked); err != nil {
|
||||
return err
|
||||
}
|
||||
if locked == nil || locked.DeptRole != child.DeptRole || locked.ParentId != child.ParentId || locked.DeptType != 1 {
|
||||
return gerror.New("下级机构角色绑定已变化")
|
||||
}
|
||||
currentLock := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("role_id", child.DeptRole).Where("tenant_id", template.TenantId).Where("dept_id", child.DeptId)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
currentLock = currentLock.LockUpdate()
|
||||
}
|
||||
var current *entity.SysRole
|
||||
if err := currentLock.Scan(¤t); err != nil {
|
||||
return err
|
||||
}
|
||||
if current == nil || current.Status != "0" {
|
||||
return gerror.New("下级机构默认角色不存在或已删除")
|
||||
}
|
||||
if current.CreatedDept != child.ParentId {
|
||||
return gerror.New("下级机构默认角色非直属上级继承角色,请核对角色来源")
|
||||
}
|
||||
configLock := tx.Ctx(ctx).Model(dao.SysConfig.Table()).Where("tenant_id", template.TenantId).Where("config_key", rolepolicy.TemplateConfigKey)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
configLock = configLock.LockUpdate()
|
||||
}
|
||||
var config *entity.SysConfig
|
||||
if err := configLock.Scan(&config); err != nil {
|
||||
return err
|
||||
}
|
||||
if config == nil || strings.TrimSpace(config.ConfigValue) != strconv.FormatInt(template.RoleId, 10) {
|
||||
return gerror.New("模板已切换,请重新同步")
|
||||
}
|
||||
templateLock := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("role_id", template.RoleId)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
templateLock = templateLock.LockUpdate()
|
||||
}
|
||||
var latest *entity.SysRole
|
||||
if err := templateLock.Scan(&latest); err != nil {
|
||||
return err
|
||||
}
|
||||
if latest == nil || latest.Status != "0" || latest.TenantId != template.TenantId || latest.DeptId != template.DeptId || latest.DataScope != consts.SysRoleDataScopeDeptAndBelow || (latest.CreatedDept > 0 && latest.CreatedDept != latest.DeptId) {
|
||||
return gerror.New("模板不可用")
|
||||
}
|
||||
menuLock := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where("tenant_id", template.TenantId).Where("role_id", template.RoleId)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
menuLock = menuLock.LockUpdate()
|
||||
}
|
||||
var menus []*entity.SysRoleMenu
|
||||
if err := menuLock.Scan(&menus); err != nil {
|
||||
return err
|
||||
}
|
||||
// Every linked default role receives the same template snapshot. Individual
|
||||
// changes are intentionally replaced; unrelated roles are never updated.
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("tenant_id", template.TenantId).Where("role_id", current.RoleId).Data(do.SysRole{DataScope: consts.SysRoleDataScopeDeptAndBelow, UpdatedBy: actorID}).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where("tenant_id", template.TenantId).Where("role_id", current.RoleId).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
menuData := make([]*do.SysRoleMenu, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
if menu.DataScope == consts.SysRoleDataScopeAll || menu.DataScope == consts.SysRoleDataScopeCustom {
|
||||
return gerror.New("模板按钮数据范围已变化,请重新同步")
|
||||
}
|
||||
menuData = append(menuData, &do.SysRoleMenu{TenantId: template.TenantId, RoleId: current.RoleId, MenuId: menu.MenuId, DataScope: consts.SysRoleMenuDataScopeRole})
|
||||
}
|
||||
if len(menuData) > 0 {
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Data(menuData).Batch(200).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package rolesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestTemplateHierarchyRetriesFailedParentAndDescendants(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
for _, statement := range []string{
|
||||
"INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,0),(3,'000000',2,1,31),(4,'000000',1,1,41),(5,'other',1,1,51),(6,'000000',1,0,61)",
|
||||
"INSERT INTO sys_role(role_id,tenant_id,dept_id,status,is_pass,created_dept,data_scope) VALUES(10,'000000',1,'0',0,1,'4'),(21,'000000',2,'0',0,1,'4'),(31,'000000',3,'0',0,2,'4'),(41,'000000',4,'0',0,1,'4'),(51,'other',5,'0',0,1,'4'),(61,'000000',6,'0',0,1,'4')",
|
||||
"INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')",
|
||||
"INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',31,999,'0')",
|
||||
} {
|
||||
mustExecAgentRoleSyncTest(t, ctx, statement)
|
||||
}
|
||||
message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, TemplateRole: true}
|
||||
if err := SyncInheritedRole(ctx, message); err == nil {
|
||||
t.Fatal("missing parent default role must fail")
|
||||
}
|
||||
if !reflect.DeepEqual(message.ChildDeptIds, []int64{2, 3}) || message.TotalCount != 3 || message.SuccessCount != 1 || message.FailedCount != 2 {
|
||||
t.Fatalf("unexpected failed hierarchy: %+v", message)
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=999", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(51,61)", 0)
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_dept SET dept_role=21 WHERE dept_id=2")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',41,777,'0')")
|
||||
if err := SyncInheritedRole(ctx, message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message.SuccessCount != 3 || message.FailedCount != 0 || len(message.ChildDeptIds) != 0 {
|
||||
t.Fatalf("retry counts: %+v", message)
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=100", 1)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=999", 0)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=41 AND menu_id=777", 1)
|
||||
message.ChildDeptIds = []int64{3}
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_dept SET parent_id=99 WHERE dept_id=3")
|
||||
if err := SyncInheritedRole(ctx, message); err == nil || !reflect.DeepEqual(message.ChildDeptIds, []int64{3}) {
|
||||
t.Fatalf("removed retry target must not be reported as successful: %+v %v", message, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateHierarchyRejectsCycleBeforeWriting(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',2,1,0),(2,'000000',1,1,21)")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role(role_id,tenant_id,dept_id,status,is_pass,created_dept,data_scope) VALUES(10,'000000',1,'0',0,1,'4'),(21,'000000',2,'0',0,1,'4')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',21,999,'0')")
|
||||
if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err == nil {
|
||||
t.Fatal("cycle must fail before updating roles")
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=999", 1)
|
||||
}
|
||||
|
||||
func TestTemplateOverwriteResetsIndividualDefaultsOnly(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
for _, statement := range []string{
|
||||
"INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,21),(3,'000000',2,1,31)",
|
||||
"INSERT INTO sys_role(role_id,tenant_id,dept_id,status,is_pass,created_dept,data_scope) VALUES(10,'000000',1,'0',0,1,'4'),(21,'000000',2,'0',0,1,'4'),(31,'000000',3,'0',0,2,'4'),(22,'000000',2,'0',0,2,'3'),(32,'000000',3,'0',0,3,'5')",
|
||||
"INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')",
|
||||
"INSERT INTO sys_role_menu VALUES('000000',10,100,'0'),('000000',10,200,'0'),('000000',22,999,'3'),('000000',32,888,'5')",
|
||||
"INSERT INTO sys_user_role(user_id,role_id) VALUES(99,21),(100,31),(101,22)",
|
||||
} {
|
||||
mustExecAgentRoleSyncTest(t, ctx, statement)
|
||||
}
|
||||
sync := func() {
|
||||
t.Helper()
|
||||
if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, TemplateRole: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sync()
|
||||
// Platform expands one role, removes a default permission from another, and
|
||||
// gives unrelated staff roles independent permissions.
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu VALUES('000000',21,999,'0'),('000000',31,888,'0')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "DELETE FROM sys_role_menu WHERE role_id=21 AND menu_id=200")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "DELETE FROM sys_role_menu WHERE role_id=10 AND menu_id=100")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu VALUES('000000',10,300,'0')")
|
||||
sync()
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31)", 4)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31) AND menu_id IN(200,300)", 4)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31) AND menu_id IN(100,888,999)", 0)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE (role_id=22 AND menu_id=999 AND data_scope='3') OR (role_id=32 AND menu_id=888 AND data_scope='5')", 2)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_user_role", 3)
|
||||
// A repeated distribution must also replace individual default-role edits.
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu VALUES('000000',31,999,'0')")
|
||||
sync()
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31)", 4)
|
||||
}
|
||||
@@ -11,14 +11,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
cascadeFailureReason = "same batch has invalid traffic cutoff rows; update skipped"
|
||||
validationSkipReason = "batch validation failed; traffic cutoff update skipped"
|
||||
emptyICCIDReason = "ICCID cannot be empty"
|
||||
duplicateICCIDReason = "duplicate ICCID in batch"
|
||||
unauthorizedReason = "ICCID is outside the current operator authorization scope"
|
||||
cardMissingReason = "ICCID does not exist in SIM card records"
|
||||
statusCanceledReason = "ICCID is already canceled"
|
||||
statusInconsistentReason = "same batch contains different card statuses"
|
||||
cascadeFailureReason = "同一批次存在无效流量断网明细,已跳过更新"
|
||||
validationSkipReason = "批次校验未通过,已跳过流量断网更新"
|
||||
emptyICCIDReason = "ICCID不能为空"
|
||||
duplicateICCIDReason = "ICCID在批次内重复"
|
||||
unauthorizedReason = "ICCID不在当前操作员授权范围内"
|
||||
cardMissingReason = "ICCID不存在或不属于当前租户"
|
||||
statusCanceledReason = "ICCID已销户"
|
||||
statusInconsistentReason = "同一批次内卡状态不一致"
|
||||
)
|
||||
|
||||
type executor struct {
|
||||
@@ -63,17 +63,17 @@ func (e *executor) saveResultFile(ctx context.Context, task *Task) {
|
||||
func validateTask(task *Task) bool {
|
||||
if task == nil || len(task.Details) == 0 {
|
||||
if task != nil {
|
||||
task.FailureReason = "traffic cutoff batch has no valid details"
|
||||
task.FailureReason = "流量断网批次无有效明细"
|
||||
}
|
||||
return false
|
||||
}
|
||||
if task.TrafficCutoffStatus != 1 {
|
||||
task.FailureReason = "traffic cutoff batch must enable cutoff"
|
||||
task.FailureReason = "流量断网批次必须开启断网"
|
||||
markAllFailed(task, task.FailureReason)
|
||||
return false
|
||||
}
|
||||
if task.TrafficCutoffThreshold <= 0 {
|
||||
task.FailureReason = "traffic cutoff threshold must be greater than 0"
|
||||
task.FailureReason = "流量断网阈值必须大于0"
|
||||
markAllFailed(task, task.FailureReason)
|
||||
return false
|
||||
}
|
||||
@@ -150,11 +150,11 @@ func validateDetail(detail *Detail) {
|
||||
detail.TrafficCutoffThresholdBefore = detail.Card.TrafficCutoffThreshold
|
||||
|
||||
if detail.InputMSISDN != "" && detail.InputMSISDN != detail.MSISDN {
|
||||
detail.Reason = "MSISDN does not match the SIM card record"
|
||||
detail.Reason = "MSISDN与卡档案不一致"
|
||||
return
|
||||
}
|
||||
if detail.InputIMSI != "" && detail.InputIMSI != detail.IMSI {
|
||||
detail.Reason = "IMSI does not match the SIM card record"
|
||||
detail.Reason = "IMSI与卡档案不一致"
|
||||
return
|
||||
}
|
||||
if detail.BossCardStatusBefore == BossCardStatusCanceled {
|
||||
|
||||
@@ -128,7 +128,7 @@ func TestExecuteCommitFailurePropagatesAndSkipsResult(t *testing.T) {
|
||||
func validTask() *Task {
|
||||
return &Task{
|
||||
Batch: Batch{BatchNo: "DCT-1", BatchType: 107, TenantID: "000000"},
|
||||
TrafficCutoffStatus: 1,
|
||||
TrafficCutoffStatus: 1,
|
||||
TrafficCutoffThreshold: 1024.5,
|
||||
ThresholdUnit: "MB",
|
||||
Details: []*Detail{
|
||||
|
||||
@@ -145,7 +145,7 @@ func validConfigTask(threshold float64) *Task {
|
||||
CreatedBy: 7,
|
||||
},
|
||||
Config: Config{
|
||||
TrafficCutoffStatus: 1,
|
||||
TrafficCutoffStatus: 1,
|
||||
TrafficCutoffThreshold: threshold,
|
||||
},
|
||||
Details: []*Detail{validConfigDetail(1, "898600000000001")},
|
||||
@@ -174,7 +174,7 @@ func validConfigCard(rowNo int, iccid string) *Card {
|
||||
PackageStatus: 0,
|
||||
BossCardStatus: rowNo,
|
||||
PackageTotalFlowMb: 1024,
|
||||
TrafficCutoffStatus: 0,
|
||||
TrafficCutoffStatus: 0,
|
||||
TrafficCutoffThreshold: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
||||
)
|
||||
|
||||
type UpstreamClient interface {
|
||||
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
cascadeFailureReason = "same batch has invalid unsubscribe rows; this row was skipped"
|
||||
validationSkipReason = "batch validation failed; unsub request was skipped"
|
||||
emptyICCIDReason = "ICCID cannot be empty"
|
||||
duplicateICCIDReason = "duplicate ICCID in batch"
|
||||
cascadeFailureReason = "同一批次存在无效退订明细,该行已跳过"
|
||||
validationSkipReason = "批次校验未通过,已跳过退订请求"
|
||||
emptyICCIDReason = "ICCID不能为空"
|
||||
duplicateICCIDReason = "ICCID在批次内重复"
|
||||
)
|
||||
|
||||
type executor struct {
|
||||
@@ -77,7 +77,7 @@ func (e *executor) saveResultFile(ctx context.Context, task *Task) {
|
||||
func validateTask(task *Task) bool {
|
||||
if task == nil || len(task.Details) == 0 {
|
||||
if task != nil {
|
||||
task.FailureReason = "unsub batch has no valid details"
|
||||
task.FailureReason = "退订批次无有效明细"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestConfigFromDetailSnapshotUsesRowValues(t *testing.T) {
|
||||
"thirdWarn": 900,
|
||||
"warnMethods": "3,1,0",
|
||||
"warnPhones": "13800008000",
|
||||
"warnEmails": "test@example.com",
|
||||
"warnEmails": "xxx@example.com",
|
||||
"autoAction": warnAutoActionSuspend,
|
||||
"isEnabled": 1,
|
||||
"remark": "row config",
|
||||
|
||||
@@ -2,13 +2,13 @@ package workorder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user