sync: 同步 v1.0.4 开源代码并脱敏

This commit is contained in:
MiFaZhan
2026-10-29 14:52:43 +08:00
parent 32e4316ee4
commit 6893e21f07
136 changed files with 9851 additions and 436 deletions
+1 -1
View File
@@ -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)
+59 -28
View File
@@ -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, "卡已属于目标客户,无需重复划拨")
}
}
+56 -5
View File
@@ -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{
+12 -17
View File
@@ -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,
+12
View File
@@ -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 "失败"
}
+33 -6
View File
@@ -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,
+177 -1
View File
@@ -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)
+5 -5
View File
@@ -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
}
+431
View File
@@ -0,0 +1,431 @@
package data
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"sort"
"strings"
"sync"
"time"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
// 首查 +3min,此后每 3min 重试 3 次(+3/+6/+9/+12min)。
// 2026-10-15 潍坊、濮阳实测:10/30/100 卡共12笔首次可见终态约 65~225s;
// 15s 采样只能给出完成区间,不能据此承诺所有高峰时段的完成上限。
// 偶发超长任务不增加自动重试;超窗记 review,保留受理号和原因供手动查询。
// 未确认不得记办理失败,也不得重新提交办理请求。
const (
subBatchLease = 5 * time.Minute
firstResultDelay = 3 * time.Minute
furtherResultDelay = 3 * time.Minute
maxResultQueries = 4
)
func newSubBatch(task *Task, details []*Detail) SubBatch {
keys := make([]*model.SimKey, 0, len(details))
ids := make([]int64, 0, len(details))
for _, detail := range details {
ids = append(ids, detail.ID)
keys = append(keys, &model.SimKey{ChanKey: detail.ChanKey, ICCID: detail.ICCID, MSISDN: detail.MSISDN, IMSI: detail.IMSI})
}
raw, _ := json.Marshal(struct {
BatchNo string
BatchType int
IDs []int64
}{task.Batch.BatchNo, task.Batch.BatchType, ids})
digest := sha256.Sum256(raw)
id := hex.EncodeToString(digest[:])
input := model.SimCardApnChangeInput{SimKeys: keys, ReqData: model.SimCardApnChangeInputItem{OperType: task.Operation.ApnOperType, ApnName: task.ApnName, OperationID: id, SubmitOnly: true}}
return SubBatch{BatchNo: task.Batch.BatchNo, BatchType: task.Batch.BatchType, ChanKey: details[0].ChanKey, OperationID: id, DetailIDs: ids, Request: input, Status: "prepared"}
}
func splitAsyncDetails(details []*Detail) [][]*Detail {
withMSISDN := make([]*Detail, 0, len(details))
batches := make([][]*Detail, 0)
for _, detail := range details {
if strings.TrimSpace(detail.MSISDN) == "" {
batches = append(batches, []*Detail{detail})
} else {
withMSISDN = append(withMSISDN, detail)
}
}
for _, chunk := range splitDetails(withMSISDN, consts.BatchSubBatchSize) {
if len(chunk) < 10 {
for _, detail := range chunk {
batches = append(batches, []*Detail{detail})
}
} else {
batches = append(batches, chunk)
}
}
return batches
}
// prepareAndSubmit persists every intent before a single write leaves the process.
func (e *executor) prepareAndSubmit(ctx context.Context, task *Task, repo SubBatchRepository, upstream AsyncUpstream) error {
groups := make(map[string][]*Detail)
for _, detail := range task.Details {
if detail != nil && detail.Reason == "" && (detail.DetailStatus == 0 || detail.DetailStatus == consts.BatchDetailStatusRunning) {
groups[detail.ChanKey] = append(groups[detail.ChanKey], detail)
}
}
keys := make([]string, 0, len(groups))
for key := range groups {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
for _, slice := range splitAsyncDetails(groups[key]) {
sub := newSubBatch(task, slice)
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
return fmt.Errorf("保存APN提交意图失败(未提交运营商): %w", err)
}
}
}
return nil
}
// processSubBatch only submits while prepared. Any interrupted submission is recovered by operationId, never repeated.
func (e *executor) processSubBatch(ctx context.Context, repo SubBatchRepository, upstream AsyncUpstream, operationID string) error {
channel, err := repo.SubBatchChannel(ctx, operationID)
if err != nil {
return err
}
value, _ := e.channelSlots.LoadOrStore(channel, make(chan struct{}, consts.BatchSubBatchConcurrency))
slots := value.(chan struct{})
select {
case slots <- struct{}{}:
defer func() { <-slots }()
case <-ctx.Done():
return ctx.Err()
}
token := uuid.NewString()
callCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()
if checker, ok := upstream.(interface {
CheckSimApnAsyncCapability(context.Context, *model.SimCardApnChangeInput) error
}); ok {
snapshot, err := repo.GetSubBatch(callCtx, operationID)
if err != nil || snapshot == nil {
return err
}
if snapshot.Status == "done" || snapshot.Status == "review" {
return nil
}
if snapshot.Status == "prepared" {
if err := checker.CheckSimApnAsyncCapability(callCtx, &snapshot.Request); err != nil {
persistCtx, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer stop()
if delayErr := repo.DelaySubBatchPrecheck(persistCtx, *snapshot, time.Now().Add(30*time.Second), err.Error()); delayErr != nil {
return fmt.Errorf("保存APN预检调度退避失败: %w", delayErr)
}
return fmt.Errorf("APN提交预检未就绪,未领取提交意图: %w", err)
}
}
}
sub, err := repo.ClaimSubBatch(ctx, operationID, token, time.Now())
if err != nil || sub == nil {
return err
}
if sub.Status == "prepared" {
response, submitErr := upstream.SubmitSimApnFunction(callCtx, &sub.Request)
if submitErr != nil {
if isAPNRequestNotSent(submitErr) {
return releaseUnsent(ctx, repo, *sub, submitErr)
}
return e.rememberUncertain(ctx, repo, *sub, "提交结果未确认(网络超时或连接中断): "+submitErr.Error())
}
return e.handleResponse(ctx, repo, *sub, response, false)
}
if sub.Status == "submitting" || sub.Status == "uncertain" || (sub.Status == "accepted" && sub.JobID == "") {
sub.Request.ReqData.RecoverOnly = true
sub.Request.ReqData.QueryJobID = ""
response, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request)
if queryErr != nil {
if isAPNRequestNotSent(queryErr) {
return releaseUnsent(ctx, repo, *sub, queryErr)
}
return e.rememberUncertain(ctx, repo, *sub, "受理映射恢复失败(不重发): "+queryErr.Error())
}
return e.handleResponse(ctx, repo, *sub, response, false)
}
sub.Request.ReqData.QueryJobID = sub.JobID
sub.Request.ReqData.RecoverOnly = false
response, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request)
if queryErr != nil {
if isAPNRequestNotSent(queryErr) {
return releaseUnsent(ctx, repo, *sub, queryErr)
}
return e.queryUnavailable(ctx, repo, *sub, "查询请求超时或不可用: "+queryErr.Error())
}
return e.handleResponse(ctx, repo, *sub, response, true)
}
// QueryReview仅执行一次只读补查,不重新开启自动轮询或提交办理。
func (e *executor) QueryReview(ctx context.Context, operationID string) error {
operationID = strings.TrimSpace(operationID)
if operationID == "" {
return fmt.Errorf("operationId不能为空")
}
repo, ok := e.repository.(SubBatchRepository)
if !ok {
return fmt.Errorf("未配置APN持久化仓储")
}
reviewRepo, ok := e.repository.(ReviewRepository)
if !ok {
return fmt.Errorf("未配置APN待核查查询仓储")
}
upstream, ok := e.upstream.(AsyncUpstream)
if !ok {
return fmt.Errorf("未配置APN只读补查客户端")
}
sub, err := reviewRepo.ClaimReviewSubBatch(ctx, operationID, uuid.NewString(), time.Now())
if err != nil {
return err
}
if sub == nil {
snapshot, err := repo.GetSubBatch(ctx, operationID)
if err != nil {
return err
}
if snapshot != nil && snapshot.Status == "done" {
return repo.FinalizeReadyBatch(ctx, snapshot.BatchNo)
}
return fmt.Errorf("子批不处于可查询的待核查状态,或已有查询/汇总正在运行")
}
callCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()
sub.Request.ReqData.SubmitOnly = false
sub.Request.ReqData.QueryJobID = sub.JobID
sub.Request.ReqData.RecoverOnly = sub.JobID == ""
items, queryErr := upstream.QuerySimApnFunctionResult(callCtx, &sub.Request)
if queryErr != nil {
if err := e.deferResult(ctx, repo, *sub, "review", "手动补查未确认:"+queryErr.Error()); err != nil {
return err
}
return queryErr
}
return e.handleResponse(ctx, repo, *sub, items, true)
}
// QueryReviewBatch 是"手动同步上游结果"入口:只对父批下已冻结的待核查子批
// 做一次只读补查,然后尝试汇总父批。
// 不重发办理请求、不清零自动补查计数、不重新开启轮询调度。
func (e *executor) QueryReviewBatch(ctx context.Context, batchNo string) (int, error) {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return 0, fmt.Errorf("批次编号不能为空")
}
repo, ok := e.repository.(ReviewBatchRepository)
if !ok {
return 0, fmt.Errorf("未配置APN待核查批量同步仓储")
}
if _, ok := e.upstream.(AsyncUpstream); !ok {
return 0, fmt.Errorf("未配置APN只读补查客户端")
}
ids, err := repo.ListReviewSubBatches(ctx, batchNo)
if err != nil {
return 0, err
}
queried := 0
var firstErr error
for _, id := range ids {
if err := e.QueryReview(ctx, id); err != nil {
g.Log().Warningf(ctx, "手动同步待核查子批未完成 batchNo=%s operationId=%s error=%v", batchNo, id, err)
if firstErr == nil {
firstErr = fmt.Errorf("operationId=%s: %w", id, err)
}
continue
}
queried++
}
// 已确认终态的子批会在这里收敛父批;仍为 review 的子批继续留在待核查。
if err := repo.FinalizeReadyBatch(ctx, batchNo); err != nil && firstErr == nil {
firstErr = err
}
g.Log().Infof(ctx, "手动同步APN上游结果 batchNo=%s 待核查子批=%d 本次已同步=%d", batchNo, len(ids), queried)
if firstErr != nil {
return queried, firstErr
}
return queried, nil
}
func isAPNRequestNotSent(err error) bool {
var notSent interface{ APNRequestNotSent() bool }
return errors.As(err, &notSent) && notSent.APNRequestNotSent()
}
func releaseUnsent(ctx context.Context, repo SubBatchRepository, sub SubBatch, cause error) error {
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
return repo.ReleaseUnsentSubBatch(persistCtx, sub, cause.Error())
}
func nextResultCheck(sub SubBatch) time.Time {
base := sub.SubmittedAt
if base.IsZero() {
base = time.Now()
}
return base.Add(firstResultDelay + time.Duration(sub.PollCount)*furtherResultDelay)
}
func (e *executor) rememberUncertain(ctx context.Context, repo SubBatchRepository, sub SubBatch, reason string) error {
return e.deferResult(ctx, repo, sub, "uncertain", reason)
}
func (e *executor) queryUnavailable(ctx context.Context, repo SubBatchRepository, sub SubBatch, reason string) error {
return e.deferResult(ctx, repo, sub, "accepted", reason)
}
func (e *executor) deferResult(ctx context.Context, repo SubBatchRepository, sub SubBatch, state, reason string) error {
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
g.Log().Warningf(persistCtx, "APN补查 operationId=%s jobId=%s pollCount=%d reason=%s", sub.OperationID, sub.JobID, sub.PollCount, reason)
if sub.Status == "review_query" || sub.PollCount >= maxResultQueries {
if err := repo.UpdateSubBatch(persistCtx, sub, "review", sub.JobID, "补查窗口结束,办理结果未知:"+reason, time.Time{}, sub.PollCount); err != nil {
return err
}
return repo.FinalizeReadyBatch(persistCtx, sub.BatchNo)
}
return repo.UpdateSubBatch(persistCtx, sub, state, sub.JobID, reason, nextResultCheck(sub), sub.PollCount)
}
func (e *executor) handleResponse(ctx context.Context, repo SubBatchRepository, sub SubBatch, items []model.SimBaseRes[model.SimCardApnChangeOutputItem], queried bool) error {
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
outcomes := make([]Outcome, len(sub.Request.SimKeys))
used := make([]bool, len(items))
pending := false
reason := "未返回该卡结果,办理未确认"
for i, key := range sub.Request.SimKeys {
matched := -1
for j, item := range items {
if !used[j] && sameSim(&Detail{ICCID: key.ICCID, MSISDN: key.MSISDN, IMSI: key.IMSI}, item.SimKey) {
matched = j
break
}
}
if matched < 0 {
outcomes[i] = Outcome{Pending: true, Message: reason}
pending = true
continue
}
used[matched] = true
item := items[matched]
if item.ResData.Pending || item.ResData.Uncertain {
pending = true
outcomes[i] = Outcome{Pending: true, Code: item.Code, Message: item.Message}
if strings.TrimSpace(item.Message) != "" {
reason = item.Message
}
// Only Pending identifies a batch job: a synchronous per-card order is never queried as a job.
if item.ResData.Pending && strings.TrimSpace(item.ResData.OrderNum) != "" {
sub.JobID = strings.TrimSpace(item.ResData.OrderNum)
}
continue
}
outcomes[i] = Outcome{Success: item.Code == 0, Code: item.Code, Message: item.Message, OrderNum: item.ResData.OrderNum}
}
if pending {
// Confirmed cards are committed now. Later missing/pending responses cannot overwrite their terminal rows.
if err := repo.SaveSubBatchProgress(persistCtx, sub, outcomes); err != nil {
return err
}
state := "accepted"
if sub.JobID == "" {
state = "uncertain"
reason += "(缺少jobId,仅按operationId恢复,不重发)"
}
return e.deferResult(persistCtx, repo, sub, state, reason)
}
if err := repo.CompleteSubBatch(persistCtx, sub, outcomes); err != nil {
return err
}
return repo.FinalizeReadyBatch(persistCtx, sub.BatchNo)
}
func mapOutcomesFromKeys(keys []*model.SimKey, response []model.SimBaseRes[model.SimCardApnChangeOutputItem]) []Outcome {
details := make([]*Detail, 0, len(keys))
for _, key := range keys {
details = append(details, &Detail{ICCID: key.ICCID, MSISDN: key.MSISDN, IMSI: key.IMSI})
}
return mapOutcomes(details, response)
}
// RunReconciler uses a fixed global worker bound; waiting for a job never retains a worker slot.
func (e *executor) RunReconciler(ctx context.Context) error {
repo, ok := e.repository.(SubBatchRepository)
if !ok {
return nil
}
upstream, ok := e.upstream.(AsyncUpstream)
if !ok {
return nil
}
jobs := make(chan string, 64)
var inFlight sync.Map
var workers sync.WaitGroup
for i := 0; i < 20; i++ {
workers.Add(1)
go func() {
defer workers.Done()
for {
select {
case <-ctx.Done():
return
case id := <-jobs:
if err := e.processSubBatch(ctx, repo, upstream, id); err != nil {
g.Log().Errorf(ctx, "APN恢复失败 operationId=%s error=%v", id, err)
}
inFlight.Delete(id)
}
}
}()
}
defer workers.Wait()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
batches, err := repo.ListRecoverableBatches(ctx, 32)
if err != nil {
g.Log().Errorf(ctx, "读取APN恢复父批失败: %v", err)
} else {
for _, batchNo := range batches {
if err := e.Execute(ctx, batchNo); err != nil {
g.Log().Errorf(ctx, "恢复APN父批失败 batchNo=%s error=%v", batchNo, err)
}
}
}
ids, err := repo.ListDueSubBatches(ctx, time.Now(), 128)
if err != nil {
g.Log().Errorf(ctx, "读取APN补查子批失败: %v", err)
} else {
for _, id := range ids {
if _, loaded := inFlight.LoadOrStore(id, true); loaded {
continue
}
select {
case jobs <- id:
case <-ctx.Done():
inFlight.Delete(id)
return nil
default:
inFlight.Delete(id)
}
}
}
select {
case <-ctx.Done():
return nil
case <-ticker.C:
}
}
}
@@ -0,0 +1,126 @@
package data
import (
"context"
"errors"
"fmt"
"sync/atomic"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
type fairnessRepository struct{ *durableFake }
func (f *fairnessRepository) Load(context.Context, string) (*Task, error) {
return nil, errors.New("公平性测试不应准备父批")
}
func (f *fairnessRepository) SaveValidationFailure(context.Context, *Task) error { return nil }
func (f *fairnessRepository) SaveResult(context.Context, *Task, *Detail, Outcome) (Outcome, error) {
return Outcome{}, nil
}
func (f *fairnessRepository) Finalize(context.Context, *Task) error { return nil }
type fairnessUpstream struct {
asyncFake
rejected atomic.Int32
}
func (f *fairnessUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return nil, errors.New("调度不得走同步办理")
}
func (f *fairnessUpstream) CheckSimApnAsyncCapability(_ context.Context, in *model.SimCardApnChangeInput) error {
if in.SimKeys[0].ChanKey == "removed" {
f.rejected.Add(1)
return errors.New("通道已移除,无protocolVersion")
}
return nil
}
func TestDurableAPNPrecheckBackoffAllowsHealthy129thTask(t *testing.T) {
repo := newDurableFake()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for i := 0; i < 129; i++ {
sub := fixtureSub()
sub.OperationID = fmt.Sprintf("fair-%03d", i)
sub.Request.ReqData.OperationID = sub.OperationID
sub.ChanKey = "removed"
if i == 128 {
sub.ChanKey = "healthy"
}
for _, key := range sub.Request.SimKeys {
key.ChanKey = sub.ChanKey
}
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
t.Fatal(err)
}
}
completed := make(chan struct{}, 1)
upstream := &fairnessUpstream{}
upstream.submit = func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
if in.SimKeys[0].ChanKey != "healthy" {
t.Error("拒绝通道发出办理")
}
out := pendingResponse(in, 0)
for i := range out {
out[i].ResData.Pending = false
}
completed <- struct{}{}
return out, nil
}
e := &executor{repository: &fairnessRepository{repo}, upstream: upstream}
done := make(chan error, 1)
go func() { done <- e.RunReconciler(ctx) }()
select {
case <-completed:
case <-time.After(15 * time.Second):
cancel()
<-done
t.Fatal("前128个预检拒绝使第129健康任务饥饿")
}
// 健康提交可能早于已入队的拒绝任务完成;等待退避持久化后再取消调度器。
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
timeout := time.NewTimer(15 * time.Second)
defer timeout.Stop()
for {
repo.mu.Lock()
delayed := 0
for id, sub := range repo.batches {
if sub.ChanKey == "removed" && !repo.next[id].IsZero() {
delayed++
}
}
repo.mu.Unlock()
if delayed == 128 {
break
}
select {
case <-ticker.C:
case <-timeout.C:
cancel()
<-done
t.Fatalf("拒绝任务退避未全部持久化: delayed=%d rejected=%d", delayed, upstream.rejected.Load())
}
}
cancel()
if err := <-done; err != nil {
t.Fatal(err)
}
if upstream.submitCalls != 1 || upstream.rejected.Load() != 128 {
t.Fatalf("submit=%d rejected=%d", upstream.submitCalls, upstream.rejected.Load())
}
repo.mu.Lock()
defer repo.mu.Unlock()
for id, sub := range repo.batches {
if sub.ChanKey == "healthy" {
continue
}
if sub.Status != "prepared" || sub.PollCount != 0 || !sub.SubmittedAt.IsZero() || sub.LeaseToken != "" || repo.next[id].IsZero() {
t.Fatalf("只读退避改变办理意图: %+v", sub)
}
}
}
@@ -0,0 +1,201 @@
package data
import (
"context"
"errors"
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
// reviewBatchFake 复用 durableFake 的持久化语义,只补手动同步需要的仓储能力。
type reviewBatchFake struct {
*durableFake
candidates []string
finalized []string
}
func newReviewBatchFake(candidates ...string) *reviewBatchFake {
return &reviewBatchFake{durableFake: newDurableFake(), candidates: candidates}
}
func (f *reviewBatchFake) ListReviewSubBatches(_ context.Context, _ string) ([]string, error) {
return append([]string(nil), f.candidates...), nil
}
// 手动同步不触碰校验/终态写入路径,这里只补齐 Repository 契约。
func (f *reviewBatchFake) Load(context.Context, string) (*Task, error) { return &Task{}, nil }
func (f *reviewBatchFake) SaveValidationFailure(context.Context, *Task) error { return nil }
func (f *reviewBatchFake) SaveResult(context.Context, *Task, *Detail, Outcome) (Outcome, error) {
return Outcome{}, nil
}
func (f *reviewBatchFake) Finalize(context.Context, *Task) error { return nil }
// manualSyncUpstream 补上同步办理入口,确保手动同步一旦误走同步路径立即暴露。
type manualSyncUpstream struct {
*asyncFake
}
func (u *manualSyncUpstream) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return nil, errors.New("手动同步不得调用同步办理入口")
}
func (f *reviewBatchFake) FinalizeReadyBatch(_ context.Context, batchNo string) error {
f.finalized = append(f.finalized, batchNo)
return nil
}
func (f *reviewBatchFake) ClaimReviewSubBatch(_ context.Context, operationID, token string, now time.Time) (*SubBatch, error) {
f.mu.Lock()
defer f.mu.Unlock()
sub, ok := f.batches[operationID]
if !ok || (sub.Status != "review" && sub.Status != "review_query") {
return nil, nil
}
sub.Status = "review_query"
sub.LeaseToken = token
sub.LeaseUntil = now.Add(subBatchLease)
f.batches[operationID] = sub
out := sub
return &out, nil
}
// 手动同步只用既有受理号只读补查:不重发办理、不清零自动补查计数。
func TestManualSyncQueriesFrozenIntentsWithoutResubmitting(t *testing.T) {
ctx := context.Background()
sub := fixtureSub()
repo := newReviewBatchFake(sub.OperationID)
sub.Status = "review"
sub.JobID = "job-1"
sub.PollCount = 4
repo.batches[sub.OperationID] = sub
upstream := &manualSyncUpstream{asyncFake: &asyncFake{
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
t.Fatal("手动同步不得重发办理")
return nil, nil
},
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
if in.ReqData.QueryJobID != "job-1" {
t.Fatalf("手动同步必须按既有 jobId 查询: %+v", in.ReqData)
}
out := make([]model.SimBaseRes[model.SimCardApnChangeOutputItem], 0, len(in.SimKeys))
for _, key := range in.SimKeys {
out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: 0, ResData: model.SimCardApnChangeOutputItem{OrderNum: "job-1"}})
}
return out, nil
},
}}
executor := &executor{repository: repo, upstream: upstream}
queried, err := executor.QueryReviewBatch(ctx, " P-1 ")
if err != nil {
t.Fatalf("QueryReviewBatch() error = %v", err)
}
if queried != 1 {
t.Fatalf("queried = %d, want 1", queried)
}
if upstream.submitCalls != 0 {
t.Fatalf("submitCalls = %d, want 0", upstream.submitCalls)
}
got := repo.batches[sub.OperationID]
if got.Status != "done" {
t.Fatalf("状态 = %s, want done", got.Status)
}
if got.PollCount != 4 {
t.Fatalf("pollCount = %d, want 4(手动同步不得清零自动补查计数)", got.PollCount)
}
// 子批收敛与批量入口都会尝试汇总父批,重复汇总是幂等的。
if len(repo.finalized) == 0 || repo.finalized[0] != "P-1" {
t.Fatalf("父批未汇总: %+v", repo.finalized)
}
if len(repo.outcomes[sub.OperationID]) != len(sub.DetailIDs) {
t.Fatalf("逐卡结果未写回: %+v", repo.outcomes[sub.OperationID])
}
}
// 上游仍无结论时明细保持待核查,且可以再次手动同步。
func TestManualSyncKeepsUnknownWhenUpstreamStillPending(t *testing.T) {
ctx := context.Background()
sub := fixtureSub()
repo := newReviewBatchFake(sub.OperationID)
sub.Status = "review"
repo.batches[sub.OperationID] = sub
upstream := &manualSyncUpstream{asyncFake: &asyncFake{
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
t.Fatal("手动同步不得重发办理")
return nil, nil
},
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return pendingResponse(in, 14008), nil
},
}}
executor := &executor{repository: repo, upstream: upstream}
if _, err := executor.QueryReviewBatch(ctx, "P-1"); err != nil {
t.Fatalf("QueryReviewBatch() error = %v", err)
}
got := repo.batches[sub.OperationID]
if got.Status != "review" {
t.Fatalf("状态 = %s, want review(未确认不得记失败)", got.Status)
}
if got.JobID != "job-1" {
t.Fatalf("jobId = %s, want job-1", got.JobID)
}
if got.PollCount != 0 {
t.Fatalf("pollCount = %d, want 0(手动同步不得占用自动补查次数)", got.PollCount)
}
// 再次同步仍可用,说明没有被自动窗口锁死。
if _, err := executor.QueryReviewBatch(ctx, "P-1"); err != nil {
t.Fatalf("第二次 QueryReviewBatch() error = %v", err)
}
}
// 单个子批补查失败不影响其余子批,并把失败如实返回给调用方。
func TestManualSyncReportsPerIntentFailureAndContinues(t *testing.T) {
ctx := context.Background()
first := fixtureSub()
first.Status = "review"
first.JobID = "job-1"
second := fixtureSub()
second.OperationID = "intent-2"
second.Status = "review"
repo := newReviewBatchFake(first.OperationID, second.OperationID)
repo.batches[first.OperationID] = first
repo.batches[second.OperationID] = second
upstream := &manualSyncUpstream{asyncFake: &asyncFake{
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
t.Fatal("手动同步不得重发办理")
return nil, nil
},
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
if in.ReqData.RecoverOnly {
return nil, errors.New("上游查询不可用")
}
return pendingResponse(in, 0), nil
},
}}
executor := &executor{repository: repo, upstream: upstream}
queried, err := executor.QueryReviewBatch(ctx, "P-1")
if err == nil {
t.Fatal("QueryReviewBatch() error = nil, want error")
}
if queried != 1 {
t.Fatalf("queried = %d, want 1(成功一个,失败一个)", queried)
}
if upstream.submitCalls != 0 {
t.Fatalf("submitCalls = %d, want 0", upstream.submitCalls)
}
}
// 空批次号直接拒绝,避免误触发全量扫描。
func TestManualSyncRejectsBlankBatchNo(t *testing.T) {
executor := &executor{repository: newReviewBatchFake(), upstream: &manualSyncUpstream{asyncFake: &asyncFake{}}}
if _, err := executor.QueryReviewBatch(context.Background(), " "); err == nil {
t.Fatal("QueryReviewBatch() blank batchNo error = nil, want error")
}
}
+650
View File
@@ -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)
}
}
+93 -19
View File
@@ -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
}
+20 -1
View File
@@ -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("原始原因被覆盖")
}
}
+65 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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},
}
}
+40 -3
View File
@@ -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,
})
}
+152 -28
View File
@@ -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}}
+1 -1
View File
@@ -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 (
+10 -11
View File
@@ -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
}
+20 -1
View File
@@ -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("原始原因被覆盖")
}
}
+208
View File
@@ -0,0 +1,208 @@
package rolesync
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rolepolicy"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
"slices"
"strings"
)
func SyncInheritedRole(ctx context.Context, message *model.AgentRoleSyncMessage) error {
if message == nil || message.SourceRoleId <= 0 || message.SourceDeptId <= 0 || message.TenantId == "" {
return gerror.New("invalid agent role sync message")
}
template, templateErr := rolepolicy.Template(ctx)
if templateErr != nil {
return templateErr
}
if message.TemplateRole && (template == nil || message.SourceRoleId != template.RoleId) {
message.Superseded = true
return nil
}
if template != nil && message.SourceRoleId == template.RoleId && message.SourceDeptId == template.DeptId && (message.TenantId == template.TenantId || message.TenantId == "") {
return syncTemplateRoles(ctx, template, message)
}
if template != nil {
message.Superseded = true
return nil
}
var source *entity.SysRole
sourceModel := dao.SysRole.Ctx(ctx).Master().Where(dao.SysRole.Columns().RoleId, message.SourceRoleId)
if message.TenantId != "" {
sourceModel = sourceModel.Where(dao.SysRole.Columns().TenantId, message.TenantId)
}
if err := sourceModel.Scan(&source); err != nil {
return err
}
if source == nil || source.IsPass != 1 || source.DeletedAt != nil {
message.Superseded = true
return nil
}
if message.SourceDeptId > 0 && message.SourceDeptId != source.DeptId {
return gerror.New("role sync source department changed")
}
var children []*entity.SysDept
if err := dao.SysDept.Ctx(ctx).Master().
Where(dao.SysDept.Columns().TenantId, source.TenantId).
Where(dao.SysDept.Columns().ParentId, source.DeptId).
Where(dao.SysDept.Columns().DeptType, 1).
Scan(&children); err != nil {
return err
}
failed := 0
if len(message.ChildDeptIds) == 0 {
message.TotalCount = len(children)
message.SuccessCount = 0
}
failedDeptIds := make([]int64, 0)
var firstErr error
for _, child := range children {
if err := ctx.Err(); err != nil {
return err
}
if len(message.ChildDeptIds) > 0 && !slices.Contains(message.ChildDeptIds, child.DeptId) {
continue
}
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
return syncInheritedRoleChildrenTx(ctx, tx, message.SourceRoleId, source.TenantId, source.DeptId, message.ActorId, child.DeptId)
})
if err != nil {
failed++
failedDeptIds = append(failedDeptIds, child.DeptId)
if firstErr == nil {
firstErr = err
}
g.Log().Warningf(ctx, "agent role sync child failed, sourceRoleId:%d, childDeptId:%d, err:%v", source.RoleId, child.DeptId, err)
} else {
message.SuccessCount++
}
}
message.ChildDeptIds = failedDeptIds
message.FailedCount = len(failedDeptIds)
if firstErr != nil {
return gerror.Wrapf(firstErr, "agent role sync failed for %d of %d children", failed, len(children))
}
return nil
}
func syncInheritedRoleChildrenTx(ctx context.Context, tx gdb.TX, sourceRoleID int64, tenantId string, sourceDeptId, actorId, childDeptId int64) error {
if sourceRoleID == 0 {
return nil
}
var source *entity.SysRole
sourceModel := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where(dao.SysRole.Columns().RoleId, sourceRoleID)
if tenantId != "" {
sourceModel = sourceModel.Where(dao.SysRole.Columns().TenantId, tenantId)
}
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
sourceModel = sourceModel.LockUpdate()
}
if err := sourceModel.Scan(&source); err != nil {
return err
}
if source == nil || source.RoleId == 0 || source.DeletedAt != nil || source.IsPass != 1 {
return nil
}
if sourceDeptId > 0 && source.DeptId != sourceDeptId {
return gerror.New("role sync source department changed")
}
var childDepts []*entity.SysDept
childDeptModel := tx.Ctx(ctx).Model(dao.SysDept.Table()).Where(dao.SysDept.Columns().TenantId, source.TenantId).Where(dao.SysDept.Columns().ParentId, source.DeptId).Where(dao.SysDept.Columns().DeptType, 1).Where(dao.SysDept.Columns().DeptId, childDeptId)
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
childDeptModel = childDeptModel.LockUpdate()
}
if err := childDeptModel.Scan(&childDepts); err != nil {
return err
}
var menus []*entity.SysRoleMenu
if err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where(dao.SysRoleMenu.Columns().RoleId, sourceRoleID).OrderAsc(dao.SysRoleMenu.Columns().MenuId).Scan(&menus); err != nil {
return err
}
menuIDs := make([]int64, 0, len(menus))
for _, menu := range menus {
menuIDs = append(menuIDs, menu.MenuId)
}
allowed, err := rolepolicy.RestrictMenus(ctx, source.TenantId, source.DeptId, menuIDs)
if err != nil {
return err
}
template, templateErr := rolepolicy.Template(ctx)
if templateErr != nil {
return templateErr
}
filtered := make([]*entity.SysRoleMenu, 0, len(menus))
for _, menu := range menus {
if slices.Contains(allowed, menu.MenuId) {
copy := *menu
if template != nil {
copy.DataScope = consts.SysRoleMenuDataScopeRole
}
filtered = append(filtered, &copy)
}
}
menus = filtered
for _, childDept := range childDepts {
var children []*entity.SysRole
if err := tx.Ctx(ctx).Model(dao.SysRole.Table()+" r").
Fields("r.*").
Where("r.tenant_id", source.TenantId).
Where("r.dept_id", childDept.DeptId).
Where("r.created_dept", source.DeptId).
Where("r.role_id", childDept.DeptRole).
Scan(&children); err != nil {
return err
}
if len(children) > 1 {
return gerror.Newf("multiple inherited roles in department %d for role %d", childDept.DeptId, source.RoleId)
}
if len(children) == 0 {
var legacy *entity.SysRole
if childDept.DeptRole > 0 {
if err := tx.Ctx(ctx).Model(dao.SysRole.Table()).
Where(dao.SysRole.Columns().RoleId, childDept.DeptRole).
Where(dao.SysRole.Columns().DeptId, childDept.DeptId).
Where(dao.SysRole.Columns().TenantId, source.TenantId).
Scan(&legacy); err != nil {
return err
}
}
if legacy != nil && legacy.RoleId > 0 {
if legacy.CreatedDept != source.DeptId || legacy.RoleKey != source.RoleKey {
return gerror.Newf("cannot identify inherited role for department %d", childDept.DeptId)
}
children = []*entity.SysRole{legacy}
} else {
if childDept.DeptRole > 0 {
return gerror.Newf("missing default role for department %d", childDept.DeptId)
}
return gerror.Newf("missing default role for department %d", childDept.DeptId)
}
}
for _, child := range children {
if _, err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where(dao.SysRole.Columns().RoleId, child.RoleId).Data(do.SysRole{RoleName: strings.TrimSpace(source.RoleName), RoleKey: source.RoleKey, RoleSort: source.RoleSort, DataScope: source.DataScope, MenuCheckStrictly: source.MenuCheckStrictly, DeptCheckStrictly: source.DeptCheckStrictly, Status: source.Status, Remark: source.Remark, UpdatedBy: actorId}).OmitNil().Update(); err != nil {
return err
}
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where(dao.SysRoleMenu.Columns().RoleId, child.RoleId).Delete(); err != nil {
return err
}
menuData := make([]*do.SysRoleMenu, 0, len(menus))
for _, menu := range menus {
menuData = append(menuData, &do.SysRoleMenu{TenantId: source.TenantId, RoleId: child.RoleId, MenuId: menu.MenuId, DataScope: menu.DataScope})
}
if len(menuData) > 0 {
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Data(menuData).OmitNil().Batch(200).Insert(); err != nil {
return err
}
}
}
}
return nil
}
+283
View File
@@ -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)
}
+109
View File
@@ -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
}
+161
View File
@@ -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)
}
}
}
}
+185
View File
@@ -0,0 +1,185 @@
package rolesync
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/rolepolicy"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
"slices"
"strconv"
"strings"
)
func syncTemplateRoles(ctx context.Context, template *entity.SysRole, message *model.AgentRoleSyncMessage) error {
departments, err := templateDescendants(ctx, template)
if err != nil {
return err
}
retrying := len(message.ChildDeptIds) > 0
if !retrying {
message.TotalCount = len(departments)
message.SuccessCount = 0
}
pending := make(map[int64]bool, len(message.ChildDeptIds))
for _, deptID := range message.ChildDeptIds {
pending[deptID] = true
}
failed := make(map[int64]bool)
failedDeptIDs := make([]int64, 0)
var firstErr error
for _, child := range departments {
if err := ctx.Err(); err != nil {
return err
}
if retrying && !pending[child.DeptId] {
continue
}
delete(pending, child.DeptId)
var syncErr error
if failed[child.ParentId] {
syncErr = gerror.Newf("上级机构%d尚未同步成功", child.ParentId)
} else {
syncErr = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
return syncTemplateChildTx(ctx, tx, template, child, message.ActorId)
})
}
if syncErr != nil {
failed[child.DeptId] = true
failedDeptIDs = append(failedDeptIDs, child.DeptId)
if firstErr == nil {
firstErr = syncErr
}
g.Log().Errorf(ctx, "agent template sync child failed, deptId:%d, err:%v", child.DeptId, syncErr)
continue
}
message.SuccessCount++
}
for deptID := range pending {
failedDeptIDs = append(failedDeptIDs, deptID)
if firstErr == nil {
firstErr = gerror.Newf("待同步机构%d已不在当前模板的下级范围,请核对机构关系", deptID)
}
}
slices.Sort(failedDeptIDs)
message.ChildDeptIds = failedDeptIDs
message.FailedCount = len(failedDeptIDs)
if firstErr != nil {
return gerror.Wrapf(firstErr, "代理模板同步失败企业数:%d", len(failedDeptIDs))
}
return nil
}
func templateDescendants(ctx context.Context, template *entity.SysRole) ([]*entity.SysDept, error) {
queue := []int64{template.DeptId}
seen := map[int64]bool{template.DeptId: true}
departments := make([]*entity.SysDept, 0)
for len(queue) > 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
parentID := queue[0]
queue = queue[1:]
var children []*entity.SysDept
if err := dao.SysDept.Ctx(ctx).Master().Where("tenant_id", template.TenantId).Where("parent_id", parentID).Where("dept_type", 1).OrderAsc("dept_id").Scan(&children); err != nil {
return nil, err
}
for _, child := range children {
if seen[child.DeptId] {
return nil, gerror.New("机构上下级存在循环")
}
seen[child.DeptId] = true
queue = append(queue, child.DeptId)
departments = append(departments, child)
}
}
return departments, nil
}
func syncTemplateChildTx(ctx context.Context, tx gdb.TX, template *entity.SysRole, child *entity.SysDept, actorID int64) error {
if child.DeptRole <= 0 {
return gerror.New("下级机构缺失默认角色")
}
lock := tx.Ctx(ctx).Model(dao.SysDept.Table()).Where("dept_id", child.DeptId).Where("tenant_id", template.TenantId)
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
lock = lock.LockUpdate()
}
var locked *entity.SysDept
if err := lock.Scan(&locked); err != nil {
return err
}
if locked == nil || locked.DeptRole != child.DeptRole || locked.ParentId != child.ParentId || locked.DeptType != 1 {
return gerror.New("下级机构角色绑定已变化")
}
currentLock := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("role_id", child.DeptRole).Where("tenant_id", template.TenantId).Where("dept_id", child.DeptId)
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
currentLock = currentLock.LockUpdate()
}
var current *entity.SysRole
if err := currentLock.Scan(&current); err != nil {
return err
}
if current == nil || current.Status != "0" {
return gerror.New("下级机构默认角色不存在或已删除")
}
if current.CreatedDept != child.ParentId {
return gerror.New("下级机构默认角色非直属上级继承角色,请核对角色来源")
}
configLock := tx.Ctx(ctx).Model(dao.SysConfig.Table()).Where("tenant_id", template.TenantId).Where("config_key", rolepolicy.TemplateConfigKey)
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
configLock = configLock.LockUpdate()
}
var config *entity.SysConfig
if err := configLock.Scan(&config); err != nil {
return err
}
if config == nil || strings.TrimSpace(config.ConfigValue) != strconv.FormatInt(template.RoleId, 10) {
return gerror.New("模板已切换,请重新同步")
}
templateLock := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("role_id", template.RoleId)
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
templateLock = templateLock.LockUpdate()
}
var latest *entity.SysRole
if err := templateLock.Scan(&latest); err != nil {
return err
}
if latest == nil || latest.Status != "0" || latest.TenantId != template.TenantId || latest.DeptId != template.DeptId || latest.DataScope != consts.SysRoleDataScopeDeptAndBelow || (latest.CreatedDept > 0 && latest.CreatedDept != latest.DeptId) {
return gerror.New("模板不可用")
}
menuLock := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where("tenant_id", template.TenantId).Where("role_id", template.RoleId)
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
menuLock = menuLock.LockUpdate()
}
var menus []*entity.SysRoleMenu
if err := menuLock.Scan(&menus); err != nil {
return err
}
// Every linked default role receives the same template snapshot. Individual
// changes are intentionally replaced; unrelated roles are never updated.
if _, err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("tenant_id", template.TenantId).Where("role_id", current.RoleId).Data(do.SysRole{DataScope: consts.SysRoleDataScopeDeptAndBelow, UpdatedBy: actorID}).Update(); err != nil {
return err
}
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where("tenant_id", template.TenantId).Where("role_id", current.RoleId).Delete(); err != nil {
return err
}
menuData := make([]*do.SysRoleMenu, 0, len(menus))
for _, menu := range menus {
if menu.DataScope == consts.SysRoleDataScopeAll || menu.DataScope == consts.SysRoleDataScopeCustom {
return gerror.New("模板按钮数据范围已变化,请重新同步")
}
menuData = append(menuData, &do.SysRoleMenu{TenantId: template.TenantId, RoleId: current.RoleId, MenuId: menu.MenuId, DataScope: consts.SysRoleMenuDataScopeRole})
}
if len(menuData) > 0 {
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Data(menuData).Batch(200).Insert(); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,97 @@
package rolesync
import (
"context"
"reflect"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
func TestTemplateHierarchyRetriesFailedParentAndDescendants(t *testing.T) {
setupAgentRoleSyncTestDB(t)
ctx := context.Background()
for _, statement := range []string{
"INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,0),(3,'000000',2,1,31),(4,'000000',1,1,41),(5,'other',1,1,51),(6,'000000',1,0,61)",
"INSERT INTO sys_role(role_id,tenant_id,dept_id,status,is_pass,created_dept,data_scope) VALUES(10,'000000',1,'0',0,1,'4'),(21,'000000',2,'0',0,1,'4'),(31,'000000',3,'0',0,2,'4'),(41,'000000',4,'0',0,1,'4'),(51,'other',5,'0',0,1,'4'),(61,'000000',6,'0',0,1,'4')",
"INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')",
"INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',31,999,'0')",
} {
mustExecAgentRoleSyncTest(t, ctx, statement)
}
message := &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, TemplateRole: true}
if err := SyncInheritedRole(ctx, message); err == nil {
t.Fatal("missing parent default role must fail")
}
if !reflect.DeepEqual(message.ChildDeptIds, []int64{2, 3}) || message.TotalCount != 3 || message.SuccessCount != 1 || message.FailedCount != 2 {
t.Fatalf("unexpected failed hierarchy: %+v", message)
}
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=999", 1)
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(51,61)", 0)
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_dept SET dept_role=21 WHERE dept_id=2")
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',41,777,'0')")
if err := SyncInheritedRole(ctx, message); err != nil {
t.Fatal(err)
}
if message.SuccessCount != 3 || message.FailedCount != 0 || len(message.ChildDeptIds) != 0 {
t.Fatalf("retry counts: %+v", message)
}
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=100", 1)
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=31 AND menu_id=999", 0)
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=41 AND menu_id=777", 1)
message.ChildDeptIds = []int64{3}
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_dept SET parent_id=99 WHERE dept_id=3")
if err := SyncInheritedRole(ctx, message); err == nil || !reflect.DeepEqual(message.ChildDeptIds, []int64{3}) {
t.Fatalf("removed retry target must not be reported as successful: %+v %v", message, err)
}
}
func TestTemplateHierarchyRejectsCycleBeforeWriting(t *testing.T) {
setupAgentRoleSyncTestDB(t)
ctx := context.Background()
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',2,1,0),(2,'000000',1,1,21)")
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role(role_id,tenant_id,dept_id,status,is_pass,created_dept,data_scope) VALUES(10,'000000',1,'0',0,1,'4'),(21,'000000',2,'0',0,1,'4')")
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')")
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',21,999,'0')")
if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1}); err == nil {
t.Fatal("cycle must fail before updating roles")
}
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id=21 AND menu_id=999", 1)
}
func TestTemplateOverwriteResetsIndividualDefaultsOnly(t *testing.T) {
setupAgentRoleSyncTestDB(t)
ctx := context.Background()
for _, statement := range []string{
"INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,21),(3,'000000',2,1,31)",
"INSERT INTO sys_role(role_id,tenant_id,dept_id,status,is_pass,created_dept,data_scope) VALUES(10,'000000',1,'0',0,1,'4'),(21,'000000',2,'0',0,1,'4'),(31,'000000',3,'0',0,2,'4'),(22,'000000',2,'0',0,2,'3'),(32,'000000',3,'0',0,3,'5')",
"INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')",
"INSERT INTO sys_role_menu VALUES('000000',10,100,'0'),('000000',10,200,'0'),('000000',22,999,'3'),('000000',32,888,'5')",
"INSERT INTO sys_user_role(user_id,role_id) VALUES(99,21),(100,31),(101,22)",
} {
mustExecAgentRoleSyncTest(t, ctx, statement)
}
sync := func() {
t.Helper()
if err := SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: "000000", SourceRoleId: 10, SourceDeptId: 1, TemplateRole: true}); err != nil {
t.Fatal(err)
}
}
sync()
// Platform expands one role, removes a default permission from another, and
// gives unrelated staff roles independent permissions.
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu VALUES('000000',21,999,'0'),('000000',31,888,'0')")
mustExecAgentRoleSyncTest(t, ctx, "DELETE FROM sys_role_menu WHERE role_id=21 AND menu_id=200")
mustExecAgentRoleSyncTest(t, ctx, "DELETE FROM sys_role_menu WHERE role_id=10 AND menu_id=100")
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu VALUES('000000',10,300,'0')")
sync()
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31)", 4)
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31) AND menu_id IN(200,300)", 4)
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31) AND menu_id IN(100,888,999)", 0)
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE (role_id=22 AND menu_id=999 AND data_scope='3') OR (role_id=32 AND menu_id=888 AND data_scope='5')", 2)
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_user_role", 3)
// A repeated distribution must also replace individual default-role edits.
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role_menu VALUES('000000',31,999,'0')")
sync()
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN(21,31)", 4)
}
+13 -13
View File
@@ -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,
}
}
+1 -1
View File
@@ -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 {
+5 -5
View File
@@ -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",
+3 -3
View File
@@ -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"
)