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
+7 -1
View File
@@ -10,6 +10,7 @@ import (
batchdomain "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/batchfields"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
)
@@ -71,6 +72,11 @@ func (r *Repository) MarkProcessing(ctx context.Context, batchNo string, batchTy
Update(); updateErr != nil {
return gerror.Wrap(updateErr, "mark pending batch details running failed")
}
if batchType == 107 || batchType == 109 || batchType == 110 {
if _, insertErr := tx.Exec("INSERT INTO data_operation_parent (batch_no,ready) VALUES (?,0)", batchNo); insertErr != nil {
return gerror.Wrap(insertErr, "create durable APN parent marker failed")
}
}
claimed = true
return nil
})
@@ -114,7 +120,7 @@ func (r *Repository) MarkFailed(ctx context.Context, batchNo string, batchType i
Data(do.BatchMain{
BatchAsyncStatus: batchdomain.AsyncStatusFailed,
RunResult: batchdomain.RunResultFailed,
FailReason: reason,
FailReason: batchfields.FailureReason(reason),
EndTime: gtime.Now(),
}).
Update()
@@ -10,6 +10,7 @@ import (
batchdomain "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/batchfields"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
)
@@ -150,6 +151,26 @@ func TestMarkFailedTruncatesDetailReason(t *testing.T) {
}
}
func TestMarkFailedKeepsMainReasonWithinTextColumn(t *testing.T) {
ctx := context.Background()
repository := newTestRepository(t, ctx)
insertBatch(t, ctx, repository.db, "B-LONG", batchdomain.Type103)
if claimed, err := repository.MarkProcessing(ctx, "B-LONG", batchdomain.Type103); err != nil || !claimed {
t.Fatalf("claim batch = (%t, %v)", claimed, err)
}
reason := "database error: INSERT INTO batch_detail VALUES (" + strings.Repeat("错", 30000) + ")"
if marked, err := repository.MarkFailed(ctx, "B-LONG", batchdomain.Type103, reason); err != nil || !marked {
t.Fatalf("mark failed = (%t, %v)", marked, err)
}
var main entity.BatchMain
if err := repository.db.Model(dao.BatchMain.Table()).Ctx(ctx).Where(dao.BatchMain.Columns().BatchNo, "B-LONG").Scan(&main); err != nil {
t.Fatal(err)
}
if len(main.FailReason) > batchfields.FailureReasonByteLimit || strings.Contains(main.FailReason, "VALUES") {
t.Fatalf("unsafe failure reason: %q", main.FailReason)
}
}
func newTestRepository(t *testing.T, ctx context.Context) *Repository {
t.Helper()
db, err := gdb.New(gdb.ConfigNode{
@@ -192,6 +213,42 @@ func insertBatch(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, b
}
}
func TestMarkProcessingAPNMarkerIsAtomic(t *testing.T) {
for _, batchType := range []int{107, 109, 110} {
t.Run(string(rune('A'+batchType-107)), func(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t, ctx)
insertBatch(t, ctx, repo.db, "APN", batchType)
claimed, err := repo.MarkProcessing(ctx, "APN", batchType)
if err == nil || claimed {
t.Fatalf("缺少迁移必须安全失败: claimed=%t err=%v", claimed, err)
}
assertStatus(t, ctx, repo.db, "batch_main", "batch_async_status", batchdomain.AsyncStatusPending)
main, err := repo.FindMain(ctx, "APN")
if err != nil || main.StartTime != nil {
t.Fatalf("失败未回滚首发时间: %+v %v", main, err)
}
pending, err := repo.db.Model("batch_detail").Ctx(ctx).Where("detail_status", batchdomain.DetailStatusPending).Count()
if err != nil || pending != 1 {
t.Fatalf("明细没有安全回滚: %d %v", pending, err)
}
if _, err := repo.db.Exec(ctx, "CREATE TABLE data_operation_parent(batch_no TEXT PRIMARY KEY,ready INTEGER DEFAULT 0)"); err != nil {
t.Fatal(err)
}
if claimed, err := repo.MarkProcessing(ctx, "APN", batchType); err != nil || !claimed {
t.Fatalf("新批领取: %t %v", claimed, err)
}
n, err := repo.db.Model("data_operation_parent").Ctx(ctx).Where("batch_no", "APN").Where("ready", 0).Count()
if err != nil || n != 1 {
t.Fatalf("新批缺少原子凭据: %d %v", n, err)
}
if claimed, err := repo.MarkProcessing(ctx, "APN", batchType); err != nil || claimed {
t.Fatalf("重投不应重复创建凭据: %t %v", claimed, err)
}
})
}
}
func assertStatus(t *testing.T, ctx context.Context, db gdb.DB, table, column string, expected int) {
t.Helper()
actual, err := db.Model(table).Ctx(ctx).Value(column)
+1 -1
View File
@@ -230,7 +230,7 @@ func (r *Repository) SaveValidationFailure(ctx context.Context, task *cancelserv
}
reason := task.FailureReason
if reason == "" {
reason = "batch validation failed; cancellation was skipped"
reason = "批次校验未通过,已跳过取消操作"
}
now := gtime.Now()
return r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
+138 -26
View File
@@ -4,6 +4,7 @@ import (
"context"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
@@ -13,6 +14,7 @@ import (
"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/batchfields"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
@@ -36,14 +38,30 @@ func (r *Repository) database() gdb.DB {
}
func (r *Repository) Load(ctx context.Context, batchNo string) (*data.Task, error) {
return r.loadTask(ctx, batchNo, false)
}
func (r *Repository) loadTask(ctx context.Context, batchNo string, allDetails bool) (*data.Task, error) {
return r.loadTaskFiltered(ctx, batchNo, allDetails, nil)
}
func (r *Repository) loadTaskDetails(ctx context.Context, batchNo string, ids []int64) (*data.Task, error) {
return r.loadTaskFiltered(ctx, batchNo, true, ids)
}
func (r *Repository) loadTaskFiltered(ctx context.Context, batchNo string, allDetails bool, ids []int64) (*data.Task, error) {
if batchNo == "" {
return nil, gerror.New("data service batch number is empty")
}
batch := entity.BatchMain{}
statuses := []int{consts.BatchAsyncStatusProcessing}
if allDetails {
statuses = append(statuses, consts.BatchAsyncStatusReview)
}
if err := r.database().Model(dao.BatchMain.Table()).Safe().Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
WhereIn(dao.BatchMain.Columns().BatchType, []int{consts.BatchTypeDataCut, consts.BatchTypeDataStart, consts.BatchTypeDataStop}).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
WhereIn(dao.BatchMain.Columns().BatchAsyncStatus, statuses).
Scan(&batch); err != nil {
return nil, gerror.Wrap(err, "query data service batch")
}
@@ -56,16 +74,18 @@ func (r *Repository) Load(ctx context.Context, batchNo string) (*data.Task, erro
}
var batchDetails []*entity.BatchDetail
if err = r.database().Model(dao.BatchDetail.Table()).Safe().Ctx(ctx).
detailQuery := r.database().Model(dao.BatchDetail.Table()).Safe().Ctx(ctx).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().BatchType, batch.BatchType).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
OrderAsc(dao.BatchDetail.Columns().RowNo).
Scan(&batchDetails); err != nil {
return nil, gerror.Wrap(err, "query running data service details")
Where(dao.BatchDetail.Columns().BatchType, batch.BatchType)
if len(ids) > 0 {
detailQuery = detailQuery.WhereIn("id", ids)
}
if len(batchDetails) == 0 {
return nil, gerror.New("data service batch has no running details")
if !allDetails {
detailQuery = detailQuery.Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
Where("NOT EXISTS (SELECT 1 FROM data_operation_sub_detail assigned WHERE assigned.detail_id = batch_detail.id)")
}
if err = detailQuery.OrderAsc(dao.BatchDetail.Columns().RowNo).Scan(&batchDetails); err != nil {
return nil, gerror.Wrap(err, "query running data service details")
}
task := &data.Task{
@@ -88,10 +108,11 @@ func (r *Repository) Load(ctx context.Context, batchNo string) (*data.Task, erro
continue
}
detail := &data.Detail{
ID: batchDetail.Id,
RowNo: batchDetail.RowNo,
ICCID: strings.TrimSpace(batchDetail.ItemId),
Reason: strings.TrimSpace(batchDetail.ErrorReason),
ID: batchDetail.Id,
DetailStatus: batchDetail.DetailStatus,
RowNo: batchDetail.RowNo,
ICCID: strings.TrimSpace(batchDetail.ItemId),
Reason: strings.TrimSpace(batchDetail.ErrorReason),
}
if batchDetail.Snapshot != nil {
if value := strings.TrimSpace(batchDetail.Snapshot.Get("ICCID").String()); value != "" {
@@ -102,14 +123,17 @@ func (r *Repository) Load(ctx context.Context, batchNo string) (*data.Task, erro
detail.MSISDN = detail.InputMSISDN
detail.IMSI = detail.InputIMSI
detail.Authorized = batchDetail.Snapshot.Get("authorized").Bool()
detail.ValidationPassed = batchDetail.Snapshot.Get("validationPassed").Bool()
detail.ChanKey = strings.TrimSpace(batchDetail.Snapshot.Get("chanKey").String())
detail.TrafficCutoffCrossMonthHold = batchDetail.Snapshot.Get("trafficCutoffCrossMonthHold").Int()
detail.OrderNum = batchDetail.Snapshot.Get("orderNum").String()
detail.UpstreamCode = batchDetail.Snapshot.Get("upstreamCode").Int()
detail.UpstreamReason = batchDetail.Snapshot.Get("upstreamMessage").String()
detail.StatusBefore = batchDetail.Snapshot.Get("statusBefore").Int()
detail.StatusAfter = batchDetail.Snapshot.Get("statusAfter").Int()
}
task.Details = append(task.Details, detail)
}
if len(task.Details) == 0 {
return nil, gerror.New("data service batch has no readable running details")
}
if err = r.loadCards(ctx, task); err != nil {
return nil, err
}
@@ -229,6 +253,7 @@ func (r *Repository) SaveValidationFailure(ctx context.Context, task *data.Task)
if detail == nil {
continue
}
detail.DetailStatus = consts.BatchDetailStatusFailed
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().Id, detail.ID).
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
@@ -263,7 +288,7 @@ func (r *Repository) SaveResult(ctx context.Context, task *data.Task, detail *da
if resolved.Success {
if detail.Card == nil {
resolved.Success = false
resolved.Message = "SIM card record is missing"
resolved.Message = "卡记录不存在,未办理"
} else {
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
Where(dao.TSimCardInfo.Columns().Id, detail.Card.ID).
@@ -332,16 +357,37 @@ func dataServiceUpdateData(task *data.Task, detail *data.Detail) do.TSimCardTraf
}
func (r *Repository) Finalize(ctx context.Context, task *data.Task) error {
successCount := 0
successCount, unknownCount := 0, 0
for _, detail := range task.Details {
if detail != nil && detail.Reason == "" {
if detail != nil && detail.DetailStatus == consts.BatchDetailStatusUnknown {
unknownCount++
} else if detail != nil && detail.DetailStatus == consts.BatchDetailStatusSuccess {
successCount++
}
}
failCount := len(task.Details) - successCount
failCount := len(task.Details) - successCount - unknownCount
batchAsyncStatus, runResult := batchResult(successCount, failCount)
if task.FailureReason == "" && failCount > 0 {
task.FailureReason = strconv.Itoa(failCount) + " SIM cards failed"
if unknownCount > 0 {
batchAsyncStatus, runResult = consts.BatchAsyncStatusReview, consts.BatchRunResultReview
}
reasons := make([]string, 0, 3)
seenReasons := map[string]bool{}
for _, detail := range task.Details {
if detail != nil && detail.Reason != "" && !seenReasons[detail.Reason] && len(reasons) < 3 {
reasons = append(reasons, detail.Reason)
seenReasons[detail.Reason] = true
}
}
if len(task.Details) == 0 {
if task.FailureReason == "" {
task.FailureReason = "数据开停批次没有有效明细"
}
task.FailureReason = batchfields.FailureReason(task.FailureReason)
} else {
task.FailureReason = ""
if failCount > 0 || unknownCount > 0 {
task.FailureReason = batchfields.FailureReason("成功=" + strconv.Itoa(successCount) + ",明确失败=" + strconv.Itoa(failCount) + ",待核查=" + strconv.Itoa(unknownCount) + ";" + strings.Join(reasons, ";"))
}
}
validCount := 0
if task.ValidationOK {
@@ -353,6 +399,26 @@ func (r *Repository) Finalize(ctx context.Context, task *data.Task) error {
}
now := gtime.Now()
err = r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
var main struct {
Status int `orm:"batch_async_status"`
}
if err := r.subLock(tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("batch_async_status").Where("batch_no", task.Batch.BatchNo)).Scan(&main); err != nil {
return err
}
if main.Status != consts.BatchAsyncStatusProcessing && !(ctx.Value(finalizeTokenKey{}) != nil && main.Status == consts.BatchAsyncStatusReview) {
return gerror.New("data service batch is no longer processing")
}
if token, ok := ctx.Value(finalizeTokenKey{}).(string); ok {
var lease struct {
Token string `orm:"finalize_token"`
}
if err := r.subLock(tx.Ctx(ctx).Model(parentTable).Fields("finalize_token").Where("batch_no", task.Batch.BatchNo).WhereGT("finalize_until", time.Now())).Scan(&lease); err != nil {
return err
}
if lease.Token != token {
return gerror.New("APN最终汇总租约已过期")
}
}
ossID, err := tx.Ctx(ctx).Model(dao.SysOss.Table()).Data(do.SysOss{
TenantId: task.Batch.TenantID,
FileName: artifact.FileName,
@@ -399,9 +465,13 @@ func (r *Repository) Finalize(ctx context.Context, task *data.Task) error {
if err != nil {
return gerror.Wrap(err, "insert data service result download")
}
allowedStatuses := []int{consts.BatchAsyncStatusProcessing}
if ctx.Value(finalizeTokenKey{}) != nil {
allowedStatuses = append(allowedStatuses, consts.BatchAsyncStatusReview)
}
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
WhereIn(dao.BatchMain.Columns().BatchAsyncStatus, allowedStatuses).
Data(do.BatchMain{
BatchAsyncStatus: batchAsyncStatus,
ValidCount: validCount,
@@ -423,6 +493,10 @@ func (r *Repository) Finalize(ctx context.Context, task *data.Task) error {
if affected != 1 {
return gerror.New("data service batch is no longer processing")
}
if token, ok := ctx.Value(finalizeTokenKey{}).(string); ok {
_, err = tx.Ctx(ctx).Model(parentTable).Where("batch_no", task.Batch.BatchNo).Where("finalize_token", token).Data(g.Map{"finalize_token": gdb.Raw("NULL"), "finalize_until": gdb.Raw("NULL")}).Update()
return err
}
return nil
})
if err != nil {
@@ -432,6 +506,36 @@ func (r *Repository) Finalize(ctx context.Context, task *data.Task) error {
return nil
}
const subDetailOwnershipTable = "data_operation_sub_detail"
// markFrozenValidation 以“是否已被冻结进持久化子批”判定明细是否通过提交前校验。
// 只有通过校验的明细才会被冻结提交,因此冻结归属才是有效数的可信依据;
// authorized 只表示运营商授权范围,不能当作校验通过的证据。
func (r *Repository) markFrozenValidation(ctx context.Context, task *data.Task) error {
if task == nil || len(task.Details) == 0 {
return nil
}
var rows []struct {
DetailID int64 `orm:"detail_id"`
}
if err := r.database().Model(subDetailOwnershipTable).Ctx(ctx).
Fields("detail_id").Where("batch_no", task.Batch.BatchNo).Scan(&rows); err != nil {
return gerror.Wrap(err, "query frozen data service detail ownership")
}
frozen := make(map[int64]struct{}, len(rows))
for _, row := range rows {
frozen[row.DetailID] = struct{}{}
}
for _, detail := range task.Details {
if detail == nil {
continue
}
_, owned := frozen[detail.ID]
detail.ValidationPassed = owned
}
return nil
}
func validDetailCount(task *data.Task) int {
if task == nil {
return 0
@@ -460,12 +564,13 @@ func artifactRequest(task *data.Task) batchresult.Request {
}
rows = append(rows, batchresult.Row{
RowNo: detail.RowNo,
DetailStatus: detail.DetailStatus,
ICCID: detail.ICCID,
MSISDN: detail.MSISDN,
IMSI: detail.IMSI,
Operation: operationLabel(task.Operation),
ApnName: task.ApnName,
Success: detail.Reason == "",
Success: detail.DetailStatus == consts.BatchDetailStatusSuccess,
Reason: detail.Reason,
UpstreamCode: detail.UpstreamCode,
OrderNum: detail.OrderNum,
@@ -497,6 +602,13 @@ func buildBatchInfo(task *data.Task, ossID, downloadID int64) *gjson.Json {
info["operation"] = task.Operation.Name
info["operType"] = task.Operation.ApnOperType
info["apnName"] = task.ApnName
unknownCount := 0
for _, detail := range task.Details {
if detail != nil && detail.DetailStatus == consts.BatchDetailStatusUnknown {
unknownCount++
}
}
info["apnUnknownCount"] = unknownCount
info["resultFileOssId"] = ossID
info["resultDownloadId"] = downloadID
return gjson.New(info)
@@ -504,10 +616,10 @@ func buildBatchInfo(task *data.Task, ossID, downloadID int64) *gjson.Json {
func batchResult(successCount, failCount int) (int, int) {
switch {
case failCount == 0:
return consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess
case successCount == 0:
return consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed
case failCount == 0:
return consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess
default:
return consts.BatchAsyncStatusPartialSuccess, consts.BatchRunResultPartialSuccess
}
@@ -0,0 +1,237 @@
package data
import (
"context"
"fmt"
"path/filepath"
"testing"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"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"
service "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data"
)
func TestAccuracyEmptyValidationBatchFinalizesFailed(t *testing.T) {
for _, batchType := range []int{107, 109, 110} {
t.Run(fmt.Sprint(batchType), func(t *testing.T) {
repo, generator, ctx := accuracyTestRepository(t, batchType)
if err := service.NewExecutor(repo, &accuracyNoUpstream{t: t}).Execute(ctx, "ACCURACY"); err != nil {
t.Fatal(err)
}
row, err := repo.database().GetOne(ctx, "SELECT batch_async_status,run_result,valid_count,success_count,fail_count,fail_reason FROM batch_main WHERE batch_no='ACCURACY'")
if err != nil {
t.Fatal(err)
}
if row["batch_async_status"].Int() != consts.BatchAsyncStatusFailed || row["run_result"].Int() != consts.BatchRunResultFailed {
t.Fatalf("零明细校验失败被误报成功: %+v", row)
}
if row["valid_count"].Int() != 0 || row["success_count"].Int() != 0 || row["fail_count"].Int() != 0 || row["fail_reason"].String() == "" {
t.Fatalf("空批次不得虚构行数或丢失失败原因: %+v", row)
}
if len(generator.requests) != 1 || len(generator.requests[0].Rows) != 0 {
t.Fatalf("空批次结果文件行数错误: %+v", generator.requests)
}
download, err := repo.database().GetOne(ctx, "SELECT total_count,success_count,fail_count FROM t_resource_batch_download WHERE batch_no='ACCURACY'")
if err != nil || download["total_count"].Int() != 0 || download["success_count"].Int() != 0 || download["fail_count"].Int() != 0 {
t.Fatalf("空批次下载统计错误: %+v err=%v", download, err)
}
})
}
}
func TestAccuracyAuthorizedSnapshotDoesNotPassValidation(t *testing.T) {
repo, _, ctx := accuracyTestRepository(t, 110)
if _, err := repo.database().Exec(ctx, `INSERT INTO batch_detail (id,batch_no,batch_type,row_no,item_id,detail_status,snapshot) VALUES (1,'ACCURACY',110,1,'',1,'{"authorized":true}')`); err != nil {
t.Fatal(err)
}
task, err := repo.Load(ctx, "ACCURACY")
if err != nil {
t.Fatal(err)
}
if len(task.Details) != 1 || !task.Details[0].Authorized || task.Details[0].ValidationPassed {
t.Fatalf("授权标志不能被当成校验通过: %+v", task.Details)
}
}
func TestAccuracyFrozenOwnershipDrivesValidCount(t *testing.T) {
repo, _, ctx := accuracyTestRepository(t, 110)
// 冻结归属才代表已通过提交前校验:授权标记不等于校验通过,
// 未被冻结进子批的明细(校验被拒)也不得计入有效数。
if _, err := repo.database().Exec(ctx, "INSERT INTO data_operation_sub_detail (detail_id,operation_id,batch_no,iccid) VALUES (1,'op-1','ACCURACY','frozen-card')"); err != nil {
t.Fatal(err)
}
task := &service.Task{Batch: service.Batch{BatchNo: "ACCURACY", BatchType: 110}, Details: []*service.Detail{
{ID: 1, Authorized: true, DetailStatus: consts.BatchDetailStatusUnknown, Reason: "补查窗口耗尽,结果未知,禁止重发"},
{ID: 2, Authorized: true, DetailStatus: consts.BatchDetailStatusFailed, Reason: "ICCID does not exist in SIM card records"},
}}
if err := repo.markFrozenValidation(ctx, task); err != nil {
t.Fatal(err)
}
if !task.Details[0].ValidationPassed || task.Details[1].ValidationPassed {
t.Fatalf("冻结归属判定错误: %+v", task.Details)
}
task.ValidationOK = true
if count := validDetailCount(task); count != 1 {
t.Fatalf("恢复时有效数错误: got=%d want=1", count)
}
}
func TestAccuracyFinalizeEmptyBatchPreservesValidationReason(t *testing.T) {
repo, _, ctx := accuracyTestRepository(t, 109)
operation, err := service.OperationByBatchType(109)
if err != nil {
t.Fatal(err)
}
task := &service.Task{Batch: service.Batch{BatchNo: "ACCURACY", BatchType: 109}, Operation: operation, FailureReason: "导入文件没有可执行行"}
if err := repo.Finalize(ctx, task); err != nil {
t.Fatal(err)
}
row, err := repo.database().GetOne(ctx, "SELECT batch_async_status,run_result,fail_reason FROM batch_main WHERE batch_no='ACCURACY'")
if err != nil {
t.Fatal(err)
}
if row["batch_async_status"].Int() != consts.BatchAsyncStatusFailed || row["run_result"].Int() != consts.BatchRunResultFailed || row["fail_reason"].String() != "导入文件没有可执行行" || task.FailureReason != "导入文件没有可执行行" {
t.Fatalf("空批次原始校验原因被覆盖: %+v task=%+v", row, task)
}
}
func TestAccuracyValidationFailureKeepsPerRowReasons(t *testing.T) {
for _, batchType := range []int{107, 109, 110} {
t.Run(fmt.Sprint(batchType), func(t *testing.T) {
repo, generator, ctx := accuracyTestRepository(t, batchType)
operation, err := service.OperationByBatchType(batchType)
if err != nil {
t.Fatal(err)
}
task := &service.Task{Batch: service.Batch{BatchNo: "ACCURACY", BatchType: batchType}, Operation: operation, Details: []*service.Detail{
{ID: 1, RowNo: 1, ICCID: "same-card", Reason: "MSISDN does not match the SIM card record"},
{ID: 2, RowNo: 2, ICCID: "same-card", Reason: "IMSI does not match the SIM card record"},
}}
for _, detail := range task.Details {
if _, err := repo.database().Exec(ctx, "INSERT INTO batch_detail (id,batch_no,batch_type,row_no,item_id,detail_status) VALUES (?,'ACCURACY',?,?,?,1)", detail.ID, batchType, detail.RowNo, detail.ICCID); err != nil {
t.Fatal(err)
}
}
if err := repo.SaveValidationFailure(ctx, task); err != nil {
t.Fatal(err)
}
if err := repo.Finalize(ctx, task); err != nil {
t.Fatal(err)
}
if len(generator.requests) != 1 || len(generator.requests[0].Rows) != len(task.Details) {
t.Fatalf("逐行导出缺失: %+v", generator.requests)
}
for i, detail := range task.Details {
row, err := repo.database().GetOne(ctx, "SELECT detail_status,error_reason FROM batch_detail WHERE id=?", detail.ID)
if err != nil || row["detail_status"].Int() != consts.BatchDetailStatusFailed || row["error_reason"].String() != detail.Reason {
t.Fatalf("逐行原因落库错误: row=%+v detail=%+v err=%v", row, detail, err)
}
exported := generator.requests[0].Rows[i]
if exported.Success || exported.DetailStatus != consts.BatchDetailStatusFailed || exported.Reason != detail.Reason {
t.Fatalf("逐行原因导出被改写: %+v", exported)
}
}
})
}
}
func TestAccuracyFinalizeUnknownStillRequiresReview(t *testing.T) {
for _, statuses := range [][]int{{consts.BatchDetailStatusUnknown}, {consts.BatchDetailStatusSuccess, consts.BatchDetailStatusUnknown}, {consts.BatchDetailStatusFailed, consts.BatchDetailStatusUnknown}} {
t.Run(fmt.Sprint(statuses), func(t *testing.T) {
repo, _, ctx := accuracyTestRepository(t, 110)
task := &service.Task{Batch: service.Batch{BatchNo: "ACCURACY", BatchType: 110}}
for i, status := range statuses {
task.Details = append(task.Details, &service.Detail{ID: int64(i + 1), DetailStatus: status, Reason: "该卡结果待核查"})
}
if err := repo.Finalize(ctx, task); err != nil {
t.Fatal(err)
}
row, err := repo.database().GetOne(ctx, "SELECT batch_async_status,run_result,fail_count,batch_info FROM batch_main WHERE batch_no='ACCURACY'")
if err != nil {
t.Fatal(err)
}
wantFailed := 0
if statuses[0] == consts.BatchDetailStatusFailed {
wantFailed = 1
}
if row["batch_async_status"].Int() != consts.BatchAsyncStatusReview || row["run_result"].Int() != consts.BatchRunResultReview || row["fail_count"].Int() != wantFailed {
t.Fatalf("未知被误计成功或明确失败: %+v", row)
}
})
}
}
func TestAccuracyBatchResultCounts(t *testing.T) {
for _, tc := range []struct {
success, failed int
status, result int
}{{0, 0, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed}, {0, 2, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed}, {2, 0, consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess}, {1, 1, consts.BatchAsyncStatusPartialSuccess, consts.BatchRunResultPartialSuccess}} {
t.Run(fmt.Sprintf("success=%d/failed=%d", tc.success, tc.failed), func(t *testing.T) {
status, result := batchResult(tc.success, tc.failed)
if status != tc.status || result != tc.result {
t.Fatalf("汇总结果=(%d,%d),期望=(%d,%d)", status, result, tc.status, tc.result)
}
})
}
}
func accuracyTestRepository(t *testing.T, batchType int) (*Repository, *accuracyArtifactGenerator, context.Context) {
t.Helper()
ctx := context.Background()
db, err := gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(" + filepath.ToSlash(filepath.Join(t.TempDir(), "accuracy.db")) + ")"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(ctx); err != nil {
t.Error(err)
}
})
for _, statement := range []string{
"CREATE TABLE batch_main (id INTEGER PRIMARY KEY,batch_no TEXT,batch_type INTEGER,batch_async_status INTEGER,tenant_id TEXT,start_time DATETIME,created_by INTEGER,created_dept INTEGER,valid_count INTEGER,success_count INTEGER,fail_count INTEGER,run_result INTEGER,fail_reason TEXT,batch_info TEXT,end_time DATETIME,updated_by INTEGER)",
"CREATE TABLE batch_detail (id INTEGER PRIMARY KEY,batch_no TEXT,batch_type INTEGER,row_no INTEGER,item_id TEXT,detail_status INTEGER,error_code TEXT,error_reason TEXT,snapshot TEXT,updated_by INTEGER)",
"CREATE TABLE data_operation_parent (batch_no TEXT PRIMARY KEY,ready INTEGER DEFAULT 0,finalize_token TEXT,finalize_until DATETIME,last_reason TEXT)",
"CREATE TABLE data_operation_sub_batch (id INTEGER PRIMARY KEY,batch_no TEXT,status TEXT)",
"CREATE TABLE data_operation_sub_detail (detail_id INTEGER PRIMARY KEY, operation_id TEXT, batch_no TEXT, iccid TEXT, UNIQUE(batch_no,iccid))",
"CREATE TABLE sys_oss (id INTEGER PRIMARY KEY AUTOINCREMENT,tenant_id TEXT,file_name TEXT,original_name TEXT,file_suffix TEXT,path TEXT,url TEXT,created_dept INTEGER,created_by INTEGER,updated_by INTEGER,service TEXT,md5 TEXT,file_size INTEGER)",
"CREATE TABLE t_resource_batch_download (id INTEGER PRIMARY KEY AUTOINCREMENT,batch_no TEXT,task_name TEXT,resource_type INTEGER,export_scene INTEGER,total_count INTEGER,success_count INTEGER,fail_count INTEGER,download_status INTEGER,oss_id INTEGER,file_name TEXT,file_ext TEXT,file_url TEXT,file_path TEXT,file_size INTEGER,file_md5 TEXT,download_count INTEGER,max_download_count INTEGER,dept_id INTEGER,start_time DATETIME,finish_time DATETIME,tenant_id TEXT,created_dept INTEGER,created_by INTEGER,updated_by INTEGER)",
} {
if _, err := db.Exec(ctx, statement); err != nil {
t.Fatal(err)
}
}
if _, err := db.Exec(ctx, "INSERT INTO batch_main (id,batch_no,batch_type,batch_async_status,tenant_id) VALUES (1,'ACCURACY',?,2,'isolated')", batchType); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(ctx, "INSERT INTO data_operation_parent (batch_no,ready) VALUES ('ACCURACY',0)"); err != nil {
t.Fatal(err)
}
generator := &accuracyArtifactGenerator{}
return &Repository{db: db, resultFiles: generator}, generator, ctx
}
type accuracyArtifactGenerator struct{ requests []batchresult.Request }
func (g *accuracyArtifactGenerator) Create(_ context.Context, request batchresult.Request) (batchresult.Artifact, error) {
g.requests = append(g.requests, request)
return batchresult.Artifact{FileName: "accuracy.xlsx", OriginalName: request.BatchNo + "-result.xlsx", FileSuffix: ".xlsx", Path: "accuracy.xlsx", URL: "/accuracy.xlsx"}, nil
}
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
}
+841
View File
@@ -0,0 +1,841 @@
package data
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"reflect"
"sort"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/google/uuid"
"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/batchfields"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
data "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data"
)
const subTable = "data_operation_sub_batch"
const claimTable = "data_operation_card_claim"
const parentTable = "data_operation_parent"
const subLease = 5 * time.Minute
type finalizeTokenKey struct{}
type preparingKey struct{}
func (r *Repository) subLock(q *gdb.Model) *gdb.Model {
if strings.EqualFold(r.database().GetConfig().Type, "sqlite") {
return q
}
return q.LockUpdate()
}
func (r *Repository) lockSubCards(ctx context.Context, tx gdb.TX, tenantID string, keys []*model.SimKey) ([]cardClaim, error) {
keys = append([]*model.SimKey(nil), keys...)
sort.Slice(keys, func(i, j int) bool { return keys[i].ICCID < keys[j].ICCID })
claims := make([]cardClaim, len(keys))
for i, key := range keys {
if err := r.subLock(tx.Ctx(ctx).Model(claimTable).Where("tenant_id", tenantID).Where("iccid", key.ICCID)).Scan(&claims[i]); err != nil {
return nil, err
}
}
return claims, nil
}
func noRows(err error) error {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return err
}
func (r *Repository) ignoreInsert(ctx context.Context, tx gdb.TX, table string, values g.Map) error {
_, err := tx.Ctx(ctx).Model(table).Data(values).InsertIgnore()
return err
}
func (r *Repository) durableTx(ctx context.Context, fn func(context.Context, gdb.TX) error) error {
var err error
for attempt := 0; attempt < 3; attempt++ {
err = r.database().Transaction(ctx, fn)
if err == nil {
return nil
}
text := err.Error()
if !strings.Contains(text, "1213") && !strings.Contains(text, "1205") && !strings.Contains(text, "database is locked") {
return err
}
if ctx.Err() != nil {
return ctx.Err()
}
}
return err
}
type subRow struct {
ID int64 `orm:"id"`
BatchNo string `orm:"batch_no"`
BatchType int `orm:"batch_type"`
ChanKey string `orm:"chan_key"`
OperationID string `orm:"operation_id"`
DetailIDs string `orm:"detail_ids"`
RequestData string `orm:"request_data"`
JobID string `orm:"job_id"`
Status string `orm:"status"`
PollCount int `orm:"poll_count"`
SubmittedAt *time.Time `orm:"submitted_at"`
NextCheckAt *time.Time `orm:"next_check_at"`
LeaseToken string `orm:"lease_token"`
LeaseUntil *time.Time `orm:"lease_until"`
CreatedAt *time.Time `orm:"created_at"`
}
type cardClaim struct {
BatchID int64 `orm:"batch_id"`
OperationID string `orm:"operation_id"`
ActiveBatchID int64 `orm:"active_batch_id"`
ActiveOperationID string `orm:"active_operation_id"`
}
func decodeSub(row subRow) (data.SubBatch, error) {
out := data.SubBatch{ID: row.ID, BatchNo: row.BatchNo, BatchType: row.BatchType, ChanKey: row.ChanKey, OperationID: row.OperationID, JobID: row.JobID, Status: row.Status, PollCount: row.PollCount, LeaseToken: row.LeaseToken}
if row.SubmittedAt != nil {
out.SubmittedAt = *row.SubmittedAt
}
if row.NextCheckAt != nil {
out.NextCheckAt = *row.NextCheckAt
}
if row.LeaseUntil != nil {
out.LeaseUntil = *row.LeaseUntil
}
if err := json.Unmarshal([]byte(row.DetailIDs), &out.DetailIDs); err != nil {
return out, err
}
if err := json.Unmarshal([]byte(row.RequestData), &out.Request); err != nil {
return out, err
}
if len(out.DetailIDs) == 0 || len(out.DetailIDs) != len(out.Request.SimKeys) {
return out, fmt.Errorf("APN冻结请求与明细数不匹配: %s", row.OperationID)
}
return out, nil
}
func (r *Repository) HasDurableParent(ctx context.Context, batchNo string) (bool, error) {
n, err := r.database().Model(parentTable).Ctx(ctx).Where("batch_no", batchNo).Count()
return n == 1, err
}
// ResumeOrPrepare只恢复由Pending领取事务写入的凭据,绝不接管无映射的历史同步批。
func (r *Repository) ResumeOrPrepare(ctx context.Context, batchNo string, prepare func(context.Context) error) error {
if ctx.Value(preparingKey{}) != nil {
return prepare(ctx)
}
err := r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error {
var main struct {
ID int64 `orm:"id"`
Status int `orm:"batch_async_status"`
}
if err := noRows(r.subLock(tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("id,batch_async_status").Where("batch_no", batchNo)).Scan(&main)); err != nil {
return err
}
if main.ID == 0 || main.Status != consts.BatchAsyncStatusProcessing {
return nil
}
var parent *struct {
Ready int `orm:"ready"`
}
if err := noRows(r.subLock(tx.Ctx(ctx).Model(parentTable).Where("batch_no", batchNo)).Scan(&parent)); err != nil {
return err
}
if parent == nil {
return fmt.Errorf("历史APN批次缺少持久化凭据,须人工核查,禁止自动提交: %s", batchNo)
}
if parent.Ready == 1 {
return nil
}
if err := prepare(context.WithValue(ctx, preparingKey{}, true)); err != nil {
return err
}
_, err := tx.Ctx(ctx).Model(parentTable).Where("batch_no", batchNo).Data(g.Map{"ready": 1}).Update()
return err
})
if err != nil {
return err
}
return r.FinalizeReadyBatch(ctx, batchNo)
}
func (r *Repository) PrepareSubBatch(ctx context.Context, batch data.SubBatch) error {
return r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error {
var main struct {
ID int64 `orm:"id"`
TenantID string `orm:"tenant_id"`
}
if err := r.subLock(tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("id,tenant_id").Where("batch_no", batch.BatchNo).Where("batch_async_status", consts.BatchAsyncStatusProcessing)).Scan(&main); err != nil {
return err
}
if main.ID == 0 || len(batch.DetailIDs) == 0 || len(batch.DetailIDs) > 100 || len(batch.DetailIDs) != len(batch.Request.SimKeys) {
return fmt.Errorf("APN准备参数或父批状态不合法")
}
keys := append([]*model.SimKey(nil), batch.Request.SimKeys...)
for _, key := range keys {
if key == nil {
return fmt.Errorf("APN卡标识为空")
}
}
sort.Slice(keys, func(i, j int) bool { return keys[i].ICCID < keys[j].ICCID })
seen := map[string]bool{}
for _, key := range keys {
if key == nil || key.ICCID == "" || key.ChanKey != batch.ChanKey || seen[key.ICCID] {
return fmt.Errorf("APN子批卡重复或跨通道")
}
seen[key.ICCID] = true
if err := r.ignoreInsert(ctx, tx, claimTable, g.Map{"tenant_id": main.TenantID, "iccid": key.ICCID, "batch_id": 0, "operation_id": "", "active_batch_id": 0, "active_operation_id": ""}); err != nil {
return err
}
var claim cardClaim
if err := r.subLock(tx.Ctx(ctx).Model(claimTable).Where("tenant_id", main.TenantID).Where("iccid", key.ICCID)).Scan(&claim); err != nil {
return err
}
if claim.BatchID < main.ID {
if _, err := tx.Ctx(ctx).Model(claimTable).Where("tenant_id", main.TenantID).Where("iccid", key.ICCID).WhereLT("batch_id", main.ID).Data(g.Map{"batch_id": main.ID, "operation_id": batch.OperationID}).Update(); err != nil {
return err
}
} else if claim.BatchID == main.ID && claim.OperationID != batch.OperationID {
return fmt.Errorf("同批ICCID重复,禁止二次提交: %s", key.ICCID)
}
}
var old subRow
if err := noRows(r.subLock(tx.Ctx(ctx).Model(subTable).Where("operation_id", batch.OperationID)).Scan(&old)); err != nil {
return err
}
if old.ID != 0 {
stored, err := decodeSub(old)
if err != nil {
return err
}
if stored.BatchNo != batch.BatchNo || stored.ChanKey != batch.ChanKey || !reflect.DeepEqual(stored.Request, batch.Request) || !reflect.DeepEqual(stored.DetailIDs, batch.DetailIDs) {
return fmt.Errorf("operationId绑定了不同请求: %s", batch.OperationID)
}
return nil
}
ids, err := json.Marshal(batch.DetailIDs)
if err != nil {
return err
}
request, err := json.Marshal(batch.Request)
if err != nil {
return err
}
if _, err = tx.Ctx(ctx).Model(subTable).Data(g.Map{"batch_no": batch.BatchNo, "batch_type": batch.BatchType, "chan_key": batch.ChanKey, "operation_id": batch.OperationID, "detail_ids": string(ids), "request_data": string(request), "status": "prepared", "next_check_at": time.Now()}).Insert(); err != nil {
return err
}
for i, detailID := range batch.DetailIDs {
if _, err := tx.Ctx(ctx).Model("data_operation_sub_detail").Data(g.Map{"detail_id": detailID, "operation_id": batch.OperationID, "batch_no": batch.BatchNo, "iccid": batch.Request.SimKeys[i].ICCID}).Insert(); err != nil {
return fmt.Errorf("明细已归属其他冻结意图: %w", err)
}
}
return nil
})
}
func (r *Repository) GetSubBatch(ctx context.Context, operationID string) (*data.SubBatch, error) {
var row subRow
if err := noRows(r.database().Model(subTable).Ctx(ctx).Where("operation_id", operationID).Scan(&row)); err != nil {
return nil, err
}
if row.ID == 0 {
return nil, nil
}
sub, err := decodeSub(row)
return &sub, err
}
func (r *Repository) DelaySubBatchPrecheck(ctx context.Context, snapshot data.SubBatch, next time.Time, reason string) error {
if snapshot.Status == "done" || snapshot.Status == "review" {
return nil
}
q := r.database().Model(subTable).Ctx(ctx).Where("operation_id", snapshot.OperationID).Where("status", snapshot.Status).Where("COALESCE(lease_token,'')=?", snapshot.LeaseToken).Where("lease_until IS NULL OR lease_until<=?", time.Now())
if snapshot.NextCheckAt.IsZero() {
q = q.WhereNull("next_check_at")
} else {
q = q.Where("next_check_at", snapshot.NextCheckAt)
}
if snapshot.LeaseUntil.IsZero() {
q = q.WhereNull("lease_until")
} else {
q = q.Where("lease_until", snapshot.LeaseUntil)
}
_, err := q.Data(g.Map{"next_check_at": next, "last_reason": truncateReason("只读预检未就绪:"+reason, 1024)}).Update()
return err
}
func (r *Repository) ReleaseUnsentSubBatch(ctx context.Context, batch data.SubBatch, reason string) error {
return r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error {
var owner struct {
TenantID string `orm:"tenant_id"`
}
if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("tenant_id").Where("batch_no", batch.BatchNo).Scan(&owner); err != nil {
return err
}
if _, err := r.lockSubCards(ctx, tx, owner.TenantID, batch.Request.SimKeys); err != nil {
return err
}
var row subRow
if err := r.subLock(tx.Ctx(ctx).Model(subTable).Where("id", batch.ID)).Scan(&row); err != nil {
return err
}
if row.LeaseToken != batch.LeaseToken || row.LeaseUntil == nil || !row.LeaseUntil.After(time.Now()) {
return fmt.Errorf("未发送回退租约失效")
}
changes := g.Map{"status": batch.Status, "last_reason": truncateReason("确定未发送:"+reason, 1024), "next_check_at": time.Now().Add(15 * time.Second), "lease_token": gdb.Raw("NULL"), "lease_until": gdb.Raw("NULL")}
if batch.Status == "prepared" {
changes["submitted_at"] = gdb.Raw("NULL")
var main struct {
TenantID string `orm:"tenant_id"`
}
if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("tenant_id").Where("batch_no", batch.BatchNo).Scan(&main); err != nil {
return err
}
keys := append([]*model.SimKey(nil), batch.Request.SimKeys...)
sort.Slice(keys, func(i, j int) bool { return keys[i].ICCID < keys[j].ICCID })
for _, key := range keys {
if _, err := tx.Ctx(ctx).Model(claimTable).Where("tenant_id", main.TenantID).Where("iccid", key.ICCID).Where("active_operation_id", batch.OperationID).Data(g.Map{"active_operation_id": "", "active_batch_id": 0}).Update(); err != nil {
return err
}
}
} else if batch.PollCount > 0 {
changes["poll_count"] = batch.PollCount - 1
}
_, err := tx.Ctx(ctx).Model(subTable).Where("id", batch.ID).Where("lease_token", batch.LeaseToken).Data(changes).Update()
return err
})
}
func (r *Repository) SubBatchChannel(ctx context.Context, operationID string) (string, error) {
row, err := r.database().GetOne(ctx, "SELECT chan_key FROM data_operation_sub_batch WHERE operation_id=?", operationID)
if err != nil {
return "", err
}
if len(row) == 0 {
return "", fmt.Errorf("APN子批不存在")
}
return row["chan_key"].String(), nil
}
func (r *Repository) ListDueSubBatches(ctx context.Context, now time.Time, limit int) ([]string, error) {
var rows []struct {
OperationID string `orm:"operation_id"`
}
err := r.database().Model(subTable).Ctx(ctx).Fields("operation_id").WhereIn("status", []string{"prepared", "submitting", "accepted", "uncertain"}).WhereLTE("next_check_at", now).Where("lease_until IS NULL OR lease_until <= ?", now).Where("EXISTS (SELECT 1 FROM data_operation_parent p WHERE p.batch_no=data_operation_sub_batch.batch_no AND p.ready=1)").OrderAsc("next_check_at").Limit(limit).Scan(&rows)
ids := make([]string, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.OperationID)
}
return ids, err
}
func (r *Repository) ClaimSubBatch(ctx context.Context, operationID, token string, now time.Time) (*data.SubBatch, error) {
var out *data.SubBatch
err := r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error {
out = nil
var hint subRow
if err := noRows(tx.Ctx(ctx).Model(subTable).Where("operation_id", operationID).Scan(&hint)); err != nil {
return err
}
if hint.ID == 0 {
return nil
}
var parent struct {
Ready int `orm:"ready"`
}
if err := noRows(r.subLock(tx.Ctx(ctx).Model(parentTable).Where("batch_no", hint.BatchNo)).Scan(&parent)); err != nil {
return err
}
if parent.Ready != 1 {
return nil
}
var main struct {
ID int64 `orm:"id"`
TenantID string `orm:"tenant_id"`
Status int `orm:"batch_async_status"`
}
if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("id,tenant_id,batch_async_status").Where("batch_no", hint.BatchNo).Scan(&main); err != nil {
return err
}
if main.Status != consts.BatchAsyncStatusProcessing {
return nil
}
frozen, err := decodeSub(hint)
if err != nil {
return err
}
claims, err := r.lockSubCards(ctx, tx, main.TenantID, frozen.Request.SimKeys)
if err != nil {
return err
}
var row subRow
if err := r.subLock(tx.Ctx(ctx).Model(subTable).Where("id", hint.ID)).Scan(&row); err != nil {
return err
}
if row.Status == "done" || row.Status == "review" || row.Status == "review_query" || (row.NextCheckAt != nil && row.NextCheckAt.After(now)) || (row.LeaseUntil != nil && row.LeaseUntil.After(now)) {
return nil
}
sub, err := decodeSub(row)
if err != nil {
return err
}
if row.Status == "prepared" {
keys := append([]*model.SimKey(nil), sub.Request.SimKeys...)
sort.Slice(keys, func(i, j int) bool { return keys[i].ICCID < keys[j].ICCID })
for _, claim := range claims {
if claim.ActiveOperationID != "" && claim.ActiveOperationID != operationID {
var previous subRow
if err := noRows(tx.Ctx(ctx).Model(subTable).Fields("status").Where("operation_id", claim.ActiveOperationID).Scan(&previous)); err != nil {
return err
}
if previous.Status == "review" || (row.CreatedAt != nil && !row.CreatedAt.Add(15*time.Minute).After(now)) {
reason := "未提交:同卡前序办理仍在途或结果未知,须人工核对前序后重新发起"
if _, err := tx.Ctx(ctx).Model(subTable).Where("id", row.ID).Data(g.Map{"status": "review", "last_reason": reason, "next_check_at": gdb.Raw("NULL")}).Update(); err != nil {
return err
}
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).WhereIn("id", sub.DetailIDs).Where("detail_status", consts.BatchDetailStatusRunning).Data(g.Map{"detail_status": consts.BatchDetailStatusUnknown, "error_code": "NOT_SUBMITTED", "error_reason": reason}).Update()
return err
}
_, err := tx.Ctx(ctx).Model(subTable).Where("id", row.ID).Data(g.Map{"last_reason": "同卡前序办理在途或结果未知,等待核对后才能提交", "next_check_at": now.Add(15 * time.Second)}).Update()
return err
}
}
// 父批创建ID决定已冻结意图的队序;不能用可能交错的冻结ID替代。
for _, key := range keys {
query := "SELECT s.operation_id FROM batch_main m JOIN data_operation_sub_detail sd ON sd.batch_no=m.batch_no AND sd.iccid=? JOIN data_operation_sub_batch s ON s.operation_id=sd.operation_id JOIN batch_detail d ON d.id=sd.detail_id WHERE m.tenant_id=? AND m.id<? AND d.detail_status=? AND s.status IN ('prepared','submitting','accepted','uncertain') LIMIT 1"
if !strings.EqualFold(r.database().GetConfig().Type, "sqlite") {
// 父ID先限tenant和队序,再锁对应冻结映射;不锁父表,保留RR当前读。
query = strings.Replace(query, "SELECT s.operation_id FROM batch_main m", "SELECT STRAIGHT_JOIN s.operation_id FROM batch_main m FORCE INDEX (PRIMARY)", 1)
query += " FOR UPDATE OF sd,s,d"
}
previous, err := tx.GetAll(query, key.ICCID, main.TenantID, main.ID, consts.BatchDetailStatusRunning)
if err != nil {
return err
}
if len(previous) == 0 {
continue
}
if row.CreatedAt != nil && !row.CreatedAt.Add(15*time.Minute).After(now) {
reason := "未提交:等待同卡更早冻结意图超过窗口,须核对前序后重新发起"
if _, err := tx.Ctx(ctx).Model(subTable).Where("id", row.ID).Data(g.Map{"status": "review", "last_reason": reason, "next_check_at": gdb.Raw("NULL")}).Update(); err != nil {
return err
}
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).WhereIn("id", sub.DetailIDs).Where("detail_status", consts.BatchDetailStatusRunning).Data(g.Map{"detail_status": consts.BatchDetailStatusUnknown, "error_code": "NOT_SUBMITTED", "error_reason": reason}).Update()
return err
}
_, err = tx.Ctx(ctx).Model(subTable).Where("id", row.ID).Data(g.Map{"last_reason": "同卡存在更早冻结的办理意图,按先后顺序等待提交", "next_check_at": now.Add(15 * time.Second)}).Update()
return err
}
for _, key := range keys {
if _, err := tx.Ctx(ctx).Model(claimTable).Where("tenant_id", main.TenantID).Where("iccid", key.ICCID).Where("active_operation_id", "").Data(g.Map{"active_batch_id": main.ID, "active_operation_id": operationID}).Update(); err != nil {
return err
}
}
} else if row.PollCount >= 4 {
if _, err := tx.Ctx(ctx).Model(subTable).Where("id", row.ID).Data(g.Map{"status": "review", "last_reason": "补查窗口耗尽,结果未知,禁止重发", "next_check_at": gdb.Raw("NULL")}).Update(); err != nil {
return err
}
_, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).WhereIn("id", sub.DetailIDs).Where("detail_status", consts.BatchDetailStatusRunning).Data(g.Map{"detail_status": consts.BatchDetailStatusUnknown, "error_code": "UNKNOWN", "error_reason": "补查窗口耗尽,结果未知,禁止重发"}).Update()
return err
}
changes := g.Map{"lease_token": token, "lease_until": now.Add(subLease), "next_check_at": now.Add(subLease)}
if row.Status == "prepared" {
changes["status"] = "submitting"
changes["submitted_at"] = now
sub.SubmittedAt = now
} else {
changes["poll_count"] = row.PollCount + 1
sub.PollCount++
}
result, err := tx.Ctx(ctx).Model(subTable).Where("id", row.ID).Where("status", row.Status).Where("lease_until IS NULL OR lease_until <= ?", now).Data(changes).Update()
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return fmt.Errorf("APN租约CAS失败")
}
sub.LeaseToken = token
out = &sub
return nil
})
return out, err
}
func (r *Repository) ClaimReviewSubBatch(ctx context.Context, operationID, token string, now time.Time) (*data.SubBatch, error) {
var out *data.SubBatch
err := r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error {
out = nil
var hint subRow
if err := noRows(tx.Ctx(ctx).Model(subTable).Where("operation_id", operationID).Scan(&hint)); err != nil {
return err
}
if hint.ID == 0 {
return nil
}
var main struct {
Status int `orm:"batch_async_status"`
}
if err := noRows(r.subLock(tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("batch_async_status").Where("batch_no", hint.BatchNo)).Scan(&main)); err != nil {
return err
}
if main.Status != consts.BatchAsyncStatusReview && main.Status != consts.BatchAsyncStatusProcessing {
return nil
}
var parent struct {
Ready int `orm:"ready"`
FinalizeUntil *time.Time `orm:"finalize_until"`
}
if err := noRows(r.subLock(tx.Ctx(ctx).Model(parentTable).Where("batch_no", hint.BatchNo)).Scan(&parent)); err != nil {
return err
}
if parent.Ready != 1 || (parent.FinalizeUntil != nil && parent.FinalizeUntil.After(now)) {
return nil
}
var row subRow
if err := r.subLock(tx.Ctx(ctx).Model(subTable).Where("id", hint.ID)).Scan(&row); err != nil {
return err
}
if (row.Status != "review" && row.Status != "review_query") || row.SubmittedAt == nil || (row.LeaseUntil != nil && row.LeaseUntil.After(now)) {
return nil
}
sub, err := decodeSub(row)
if err != nil {
return err
}
result, err := tx.Ctx(ctx).Model(subTable).Where("id", row.ID).Where("status", row.Status).Where("lease_until IS NULL OR lease_until <= ?", now).Data(g.Map{"status": "review_query", "lease_token": token, "lease_until": now.Add(subLease)}).Update()
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil || n != 1 {
return err
}
sub.Status = "review_query"
sub.LeaseToken = token
sub.LeaseUntil = now.Add(subLease)
out = &sub
return nil
})
return out, err
}
// ListReviewSubBatches 只列出该父批下已冻结、仍待核查的子批.
// 手动同步据此按既有 operationId 查询上游,不重发办理,也不重开自动补查窗口。
func (r *Repository) ListReviewSubBatches(ctx context.Context, batchNo string) ([]string, error) {
var rows []struct {
OperationID string `orm:"operation_id"`
}
if err := r.database().Model(subTable).Ctx(ctx).
Fields("operation_id").
Where("batch_no", batchNo).
WhereIn("status", []string{"review", "review_query"}).
Where("lease_until IS NULL OR lease_until <= ?", time.Now()).
Order("id").
Scan(&rows); err != nil {
return nil, err
}
out := make([]string, 0, len(rows))
for _, row := range rows {
if id := strings.TrimSpace(row.OperationID); id != "" {
out = append(out, id)
}
}
return out, nil
}
func (r *Repository) UpdateSubBatch(ctx context.Context, batch data.SubBatch, status, jobID, reason string, next time.Time, pollCount int) error {
return r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error {
var nextAt any = gdb.Raw("NULL")
if !next.IsZero() {
nextAt = next
}
result, err := tx.Ctx(ctx).Model(subTable).Where("id", batch.ID).Where("lease_token", batch.LeaseToken).WhereGT("lease_until", time.Now()).WhereIn("status", []string{"submitting", "accepted", "uncertain", "review_query"}).Data(g.Map{"status": status, "job_id": jobID, "last_reason": truncateReason(reason, 1024), "next_check_at": nextAt, "poll_count": pollCount, "lease_token": gdb.Raw("NULL"), "lease_until": gdb.Raw("NULL")}).Update()
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return fmt.Errorf("APN租约已失效: %s", batch.OperationID)
}
if status == "review" {
if _, err = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).Where("batch_no", batch.BatchNo).WhereIn("id", batch.DetailIDs).WhereIn("detail_status", []int{consts.BatchDetailStatusRunning, consts.BatchDetailStatusUnknown}).Data(g.Map{"detail_status": consts.BatchDetailStatusUnknown, "error_code": "UNKNOWN", "error_reason": batchfields.DetailReason(reason)}).Update(); err != nil {
return err
}
}
if status != "review" && status != "done" {
return nil
}
// 手动补查把 review_query 子批改成终态时父批可能已是待核查,需重新纳入汇总。
return r.releaseReviewParentWhenSettled(ctx, tx, batch.BatchNo)
})
}
func truncateReason(value string, n int) string {
runes := []rune(value)
if len(runes) > n {
return string(runes[:n])
}
return value
}
func (r *Repository) SaveSubBatchProgress(ctx context.Context, batch data.SubBatch, outcomes []data.Outcome) error {
return r.saveProgress(ctx, batch, outcomes, false)
}
func (r *Repository) CompleteSubBatch(ctx context.Context, batch data.SubBatch, outcomes []data.Outcome) error {
return r.saveProgress(ctx, batch, outcomes, true)
}
func (r *Repository) saveProgress(ctx context.Context, batch data.SubBatch, outcomes []data.Outcome, complete bool) error {
if len(outcomes) != len(batch.DetailIDs) {
return fmt.Errorf("APN明细结果数量不符")
}
return r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error {
var owner struct {
TenantID string `orm:"tenant_id"`
}
if err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("tenant_id").Where("batch_no", batch.BatchNo).Scan(&owner); err != nil {
return err
}
if _, err := r.lockSubCards(ctx, tx, owner.TenantID, batch.Request.SimKeys); err != nil {
return err
}
var current subRow
if err := r.subLock(tx.Ctx(ctx).Model(subTable).Where("id", batch.ID)).Scan(&current); err != nil {
return err
}
if current.LeaseToken != batch.LeaseToken || current.LeaseUntil == nil || !current.LeaseUntil.After(time.Now()) || current.Status == "done" || current.Status == "review" {
return fmt.Errorf("APN进度租约失效")
}
if batch.JobID != "" {
if _, err := tx.Ctx(ctx).Model(subTable).Where("id", batch.ID).Where("lease_token", batch.LeaseToken).Data(g.Map{"job_id": batch.JobID}).Update(); err != nil {
return err
}
}
task, err := r.loadTaskDetails(ctx, batch.BatchNo, batch.DetailIDs)
if err != nil {
return err
}
byID := map[int64]*data.Detail{}
for _, detail := range task.Details {
byID[detail.ID] = detail
}
order := make([]int, len(batch.DetailIDs))
for i := range order {
order[i] = i
}
sort.Slice(order, func(i, j int) bool {
return batch.Request.SimKeys[order[i]].ICCID < batch.Request.SimKeys[order[j]].ICCID
})
for _, i := range order {
outcome := outcomes[i]
if outcome.Pending {
continue
}
detail := byID[batch.DetailIDs[i]]
if detail == nil {
return fmt.Errorf("缺少APN明细")
}
if detail.DetailStatus != consts.BatchDetailStatusRunning && !(batch.Status == "review_query" && detail.DetailStatus == consts.BatchDetailStatusUnknown) {
continue
}
var claim cardClaim
if err := r.subLock(tx.Ctx(ctx).Model(claimTable).Where("tenant_id", task.Batch.TenantID).Where("iccid", detail.ICCID)).Scan(&claim); err != nil {
return err
}
previousStatus := detail.DetailStatus
detail.ApplyOutcome(outcome)
detail.Reason = batchfields.DetailReason(detail.Reason)
if detail.Card != nil {
detail.StatusBefore = detail.Card.DataServiceStatus
detail.StatusAfter = detail.StatusBefore
}
status := consts.BatchDetailStatusFailed
if outcome.Success {
status = consts.BatchDetailStatusSuccess
detail.StatusAfter = task.Operation.TargetStatus
}
if outcome.Success && detail.Card != nil && claim.ActiveOperationID == batch.OperationID {
if _, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).Where("id", detail.Card.ID).Where("tenant_id", task.Batch.TenantID).Data(dataServiceUpdateData(task, detail)).Update(); err != nil {
return err
}
}
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).Where("id", detail.ID).Where("detail_status", previousStatus).Data(g.Map{"detail_status": status, "error_code": fmt.Sprint(outcome.Code), "error_reason": detail.Reason, "snapshot": detail.Snapshot(task.Operation, task.ApnName).String(), "updated_by": task.Batch.CreatedBy}).Update()
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return fmt.Errorf("APN明细终态CAS失败")
}
if _, err := tx.Ctx(ctx).Model(claimTable).Where("tenant_id", task.Batch.TenantID).Where("iccid", detail.ICCID).Where("active_operation_id", batch.OperationID).Data(g.Map{"active_operation_id": "", "active_batch_id": 0}).Update(); err != nil {
return err
}
}
if complete {
n, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).WhereIn("id", batch.DetailIDs).WhereIn("detail_status", []int{consts.BatchDetailStatusRunning, consts.BatchDetailStatusUnknown}).Count()
if err != nil {
return err
}
if n != 0 {
return fmt.Errorf("APN子批仍有未确认明细,不能完成")
}
if _, err = tx.Ctx(ctx).Model(subTable).Where("id", batch.ID).Where("lease_token", batch.LeaseToken).Data(g.Map{"status": "done", "job_id": batch.JobID, "next_check_at": gdb.Raw("NULL"), "lease_token": gdb.Raw("NULL"), "lease_until": gdb.Raw("NULL")}).Update(); err != nil {
return err
}
return r.releaseReviewParentWhenSettled(ctx, tx, batch.BatchNo)
}
return nil
})
}
// releaseReviewParentWhenSettled 在同一事务里:当子批转入终态、且该父批已无未终结子批时,
// 把父批从“待核查”退回“处理中”,交给既有恢复扫描重新触发汇总。
// 修复“子批与明细都已终态,但汇总失败或进程退出后父批永久挂在待核查”的死角。
// 只在确有终态转移处调用,因此合法的待核查批次不会被反复捞起空转。
// 状态读取不加锁,最后用带状态的 CAS 更新收口,避免在已持有卡/子批锁的事务里再锁父批造成锁序反转。
func (r *Repository) releaseReviewParentWhenSettled(ctx context.Context, tx gdb.TX, batchNo string) error {
var main struct {
Status int `orm:"batch_async_status"`
}
if err := noRows(tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("batch_async_status").Where("batch_no", batchNo).Scan(&main)); err != nil {
return err
}
if main.Status != consts.BatchAsyncStatusReview {
return nil
}
// 只有已完成准备(ready=1)的父批才允许回到处理中,避免恢复扫描重跑准备阶段产生重复提交。
var parent struct {
Ready int `orm:"ready"`
}
if err := noRows(tx.Ctx(ctx).Model(parentTable).Fields("ready").Where("batch_no", batchNo).Scan(&parent)); err != nil {
return err
}
if parent.Ready != 1 {
return nil
}
// 待核查父批的子批只可能是终态或在途手动补查:在途补查不阻塞退回,
// 因为 FinalizeReadyBatch 会再校验一次;不依赖一致读快照,避免并发补查互相看不见对方的终态。
pending, err := tx.Ctx(ctx).Model(subTable).Where("batch_no", batchNo).WhereNotIn("status", []string{"done", "review", "review_query"}).Count()
if err != nil || pending != 0 {
return err
}
_, err = tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where("batch_no", batchNo).Where("batch_async_status", consts.BatchAsyncStatusReview).Data(g.Map{"batch_async_status": consts.BatchAsyncStatusProcessing}).Update()
return err
}
func (r *Repository) FinalizeReadyBatch(ctx context.Context, batchNo string) error {
token := uuid.NewString()
claimed := false
err := r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error {
claimed = false
var main struct {
Status int `orm:"batch_async_status"`
}
if err := noRows(r.subLock(tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("batch_async_status").Where("batch_no", batchNo)).Scan(&main)); err != nil {
return err
}
if main.Status != consts.BatchAsyncStatusProcessing && main.Status != consts.BatchAsyncStatusReview {
return nil
}
var parent struct {
Ready int `orm:"ready"`
}
if err := noRows(r.subLock(tx.Ctx(ctx).Model(parentTable).Where("batch_no", batchNo)).Scan(&parent)); err != nil {
return err
}
if parent.Ready != 1 {
return nil
}
n, err := tx.Ctx(ctx).Model(subTable).Where("batch_no", batchNo).WhereNotIn("status", []string{"done", "review"}).Count()
if err != nil || n != 0 {
return err
}
n, err = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).Where("batch_no", batchNo).WhereIn("detail_status", []int{consts.BatchDetailStatusPending, consts.BatchDetailStatusRunning}).Count()
if err != nil || n != 0 {
return err
}
n, err = tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where("batch_no", batchNo).WhereIn("batch_async_status", []int{consts.BatchAsyncStatusProcessing, consts.BatchAsyncStatusReview}).Count()
if err != nil || n == 0 {
return err
}
result, err := tx.Ctx(ctx).Model(parentTable).Where("batch_no", batchNo).Where("finalize_until IS NULL OR finalize_until <= ?", time.Now()).Data(g.Map{"finalize_token": token, "finalize_until": time.Now().Add(subLease)}).Update()
if err != nil {
return err
}
count, err := result.RowsAffected()
claimed = count == 1
return err
})
if err != nil || !claimed {
return err
}
finalCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 4*time.Minute)
defer cancel()
task, err := r.loadTask(finalCtx, batchNo, true)
if err == nil {
// 冻结归属决定有效数:只有通过校验的明细才会进入子批。
if err = r.markFrozenValidation(finalCtx, task); err == nil {
task.ValidationOK = true
err = r.Finalize(context.WithValue(finalCtx, finalizeTokenKey{}, token), task)
}
}
if err != nil {
_, _ = r.database().Model(parentTable).Ctx(finalCtx).Where("batch_no", batchNo).Where("finalize_token", token).Data(g.Map{"finalize_token": gdb.Raw("NULL"), "finalize_until": gdb.Raw("NULL"), "last_reason": truncateReason(err.Error(), 1024)}).Update()
}
return err
}
func (r *Repository) ListRecoverableBatches(ctx context.Context, limit int) ([]string, error) {
var rows []struct {
BatchNo string `orm:"batch_no"`
}
// 未准备父批与可最终化父批轮转领取;在途父批交给子任务扫描,不挤占准备恢复窗口。
err := r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error {
rows = nil
q := tx.Ctx(ctx).Model(dao.BatchMain.Table()+" m").InnerJoin(parentTable+" p", "p.batch_no=m.batch_no").Fields("m.batch_no").WhereIn("m.batch_type", []int{107, 109, 110}).Where("m.batch_async_status", consts.BatchAsyncStatusProcessing).Where("m.start_time IS NULL OR m.start_time <= ?", time.Now().Add(-30*time.Second)).Where("p.ready=0 OR (NOT EXISTS (SELECT 1 FROM data_operation_sub_batch s WHERE s.batch_no=m.batch_no AND s.status NOT IN ('done','review')) AND NOT EXISTS (SELECT 1 FROM batch_detail d WHERE d.batch_no=m.batch_no AND d.detail_status IN (0,1)))").Order("COALESCE(p.recovery_attempt_at,'1970-01-01'),m.id").Limit(limit)
if err := q.Scan(&rows); err != nil {
return err
}
for _, row := range rows {
if _, err := tx.Ctx(ctx).Model(parentTable).Where("batch_no", row.BatchNo).Data(g.Map{"recovery_attempt_at": time.Now()}).Update(); err != nil {
return err
}
}
return nil
})
out := make([]string, 0, len(rows))
for _, row := range rows {
out = append(out, row.BatchNo)
}
return out, err
}
func (r *Repository) ProtectExecutionFailure(ctx context.Context, batchNo, reason string) error {
return r.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error {
var main struct {
Status int `orm:"batch_async_status"`
}
if err := noRows(r.subLock(tx.Ctx(ctx).Model(dao.BatchMain.Table()).Fields("batch_async_status").Where("batch_no", batchNo)).Scan(&main)); err != nil {
return err
}
if main.Status != consts.BatchAsyncStatusProcessing {
return nil
}
_, err := tx.Ctx(ctx).Model(parentTable).Where("batch_no", batchNo).Data(g.Map{"last_reason": truncateReason("执行中断,交持久化调度恢复:"+reason, 1024)}).Update()
return err
})
}
@@ -0,0 +1,523 @@
package data
import (
"context"
"fmt"
"os"
"sync"
"sync/atomic"
"github.com/gogf/gf/v2/database/gdb"
"testing"
"time"
service "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data"
)
func TestSubBatchPrecheckDelayCASNeverOverwritesClaimOrCompletion(t *testing.T) {
repo, ctx := testSubBatchDB(t)
b := testSub("S-1", "precheck-cas", "a", []int64{1}, []string{"A"})
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
snapshot, err := repo.GetSubBatch(ctx, b.OperationID)
if err != nil {
t.Fatal(err)
}
next := time.Now().Add(30 * time.Second)
if err := repo.DelaySubBatchPrecheck(ctx, *snapshot, next, "能力拒绝"); err != nil {
t.Fatal(err)
}
stored, err := repo.GetSubBatch(ctx, b.OperationID)
if err != nil || stored.NextCheckAt.Sub(next).Abs() > time.Millisecond {
t.Fatalf("退避未保存: %+v %v", stored, err)
}
if stored.Status != "prepared" || stored.PollCount != 0 || !stored.SubmittedAt.IsZero() || stored.LeaseToken != "" {
t.Fatalf("退避改变状态: %+v", stored)
}
if _, err := repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET next_check_at=NULL WHERE operation_id=?", b.OperationID); err != nil {
t.Fatal(err)
}
snapshot, _ = repo.GetSubBatch(ctx, b.OperationID)
claimed, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner", time.Now())
if err != nil || claimed == nil {
t.Fatalf("领取: %+v %v", claimed, err)
}
if err := repo.DelaySubBatchPrecheck(ctx, *snapshot, time.Now().Add(time.Hour), "陈旧拒绝"); err != nil {
t.Fatal(err)
}
stored, _ = repo.GetSubBatch(ctx, b.OperationID)
if stored.Status != "submitting" || stored.LeaseToken != "owner" || stored.NextCheckAt.After(time.Now().Add(6*time.Minute)) {
t.Fatalf("陈旧预检覆盖领取: %+v", stored)
}
if _, err := repo.database().Exec(ctx, "UPDATE batch_detail SET item_id='A' WHERE id=1"); err != nil {
t.Fatal(err)
}
if err := repo.CompleteSubBatch(ctx, *claimed, []service.Outcome{{Code: 9999, Message: "明确失败"}}); err != nil {
t.Fatal(err)
}
if err := repo.DelaySubBatchPrecheck(ctx, *snapshot, time.Now().Add(time.Hour), "更晚拒绝"); err != nil {
t.Fatal(err)
}
stored, _ = repo.GetSubBatch(ctx, b.OperationID)
if stored.Status != "done" || !stored.NextCheckAt.IsZero() {
t.Fatalf("陈旧预检覆盖完成: %+v", stored)
}
}
func TestSubBatchConcurrentPredecessorCompletionAndNewClaim(t *testing.T) {
repo, base := testSubBatchDB(t)
ctx, cancel := context.WithTimeout(base, 20*time.Second)
defer cancel()
if _, err := repo.database().Exec(ctx, "UPDATE batch_detail SET item_id='A',batch_type=CASE WHEN batch_no='S-2' THEN 109 ELSE 110 END"); err != nil {
t.Fatal(err)
}
older := testSub("S-1", "concurrent-old", "a", []int64{1}, []string{"A"})
newer := testSub("S-2", "concurrent-new", "a", []int64{3}, []string{"A"})
newer.BatchType = 109
for _, b := range []service.SubBatch{older, newer} {
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
}
first, err := repo.ClaimSubBatch(ctx, older.OperationID, "old", time.Now())
if err != nil || first == nil {
t.Fatalf("旧批领取: %+v %v", first, err)
}
start := make(chan struct{})
errs := make(chan error, 2)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
<-start
errs <- repo.CompleteSubBatch(ctx, *first, []service.Outcome{{Success: true}})
}()
go func() {
defer wg.Done()
<-start
_, err := repo.ClaimSubBatch(ctx, newer.OperationID, "new", time.Now())
errs <- err
}()
close(start)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
stored, _ := repo.GetSubBatch(ctx, newer.OperationID)
if stored.LeaseToken == "" {
if _, err := repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET next_check_at=NULL WHERE operation_id=?", newer.OperationID); err != nil {
t.Fatal(err)
}
got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "after", time.Now())
if err != nil || got == nil {
t.Fatal(fmt.Errorf("旧批完成后新批不可领取: %+v %v", got, err))
}
}
row, err := repo.database().GetOne(ctx, "SELECT active_operation_id FROM data_operation_card_claim WHERE iccid='A'")
if err != nil || row["active_operation_id"].String() != newer.OperationID {
t.Fatalf("顺序屏障结果: %v %v", row, err)
}
}
func TestSubBatchPrecheckBackoffMakes129thDatabaseTaskDue(t *testing.T) {
repo, ctx := testSubBatchDB(t)
base := time.Now().Add(-time.Minute)
for i := 0; i < 129; i++ {
id := int64(1000 + i)
if _, err := repo.database().Exec(ctx, "INSERT INTO batch_detail(id,batch_no,detail_status)VALUES(?,'S-1',1)", id); err != nil {
t.Fatal(err)
}
channel := "removed"
if i == 128 {
channel = "healthy"
}
b := testSub("S-1", fmt.Sprintf("db-fair-%03d", i), channel, []int64{id}, []string{fmt.Sprintf("fake-%03d", i)})
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
if _, err := repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET next_check_at=? WHERE operation_id=?", base.Add(time.Duration(i)*time.Millisecond), b.OperationID); err != nil {
t.Fatal(err)
}
}
ids, err := repo.ListDueSubBatches(ctx, time.Now(), 128)
if err != nil || len(ids) != 128 {
t.Fatalf("首窗口: %d %v", len(ids), err)
}
for _, id := range ids {
snapshot, err := repo.GetSubBatch(ctx, id)
if err != nil {
t.Fatal(err)
}
if snapshot.ChanKey != "removed" {
t.Fatal("健康任务不应处于首128")
}
if err := repo.DelaySubBatchPrecheck(ctx, *snapshot, time.Now().Add(30*time.Second), "通道已移除"); err != nil {
t.Fatal(err)
}
}
ids, err = repo.ListDueSubBatches(ctx, time.Now(), 128)
if err != nil || len(ids) != 1 || ids[0] != "db-fair-128" {
t.Fatalf("健康第129仍被饿死: %v %v", ids, err)
}
if got, err := repo.ClaimSubBatch(ctx, ids[0], "healthy", time.Now()); err != nil || got == nil {
t.Fatalf("健康任务无法领取: %+v %v", got, err)
}
n, err := repo.database().Model(subTable).Ctx(ctx).Where("chan_key", "removed").Where("status", "prepared").Where("poll_count", 0).WhereNull("submitted_at").WhereNull("lease_token").Count()
if err != nil || n != 128 {
t.Fatalf("预检失败意图被消耗: %d %v", n, err)
}
n, err = repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id != ''").Where("active_operation_id != ?", "db-fair-128").Count()
if err != nil || n != 0 {
t.Fatalf("预检退避错误占active: %d %v", n, err)
}
}
func TestSubBatchMySQLCardWaitThenConcurrentReleaseAndReverseClaim(t *testing.T) {
if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" {
t.Skip("仅隔离MySQL验证真实锁等待和两卡反向顺序")
}
repo, base := testSubBatchDB(t)
ctx, cancel := context.WithTimeout(base, 20*time.Second)
defer cancel()
if _, err := repo.database().Exec(ctx, "INSERT INTO batch_detail(id,batch_no,detail_status)VALUES(4,'S-2',1)"); err != nil {
t.Fatal(err)
}
older := testSub("S-1", "wait-old-BA", "a", []int64{1, 2}, []string{"B", "A"})
newer := testSub("S-2", "wait-new-AB", "a", []int64{3, 4}, []string{"A", "B"})
for _, b := range []service.SubBatch{older, newer} {
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
}
claimed, err := repo.ClaimSubBatch(ctx, older.OperationID, "old-owner", time.Now())
if err != nil || claimed == nil {
t.Fatalf("旧批领取: %+v %v", claimed, err)
}
holder, err := repo.database().Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer holder.Rollback()
if _, err := holder.GetAll("SELECT iccid FROM data_operation_card_claim WHERE tenant_id='tenant' AND iccid='A' FOR UPDATE"); err != nil {
t.Fatal(err)
}
start := make(chan struct{})
errs := make(chan error, 2)
go func() {
<-start
errs <- repo.ReleaseUnsentSubBatch(ctx, *claimed, "确定未发送")
}()
go func() {
<-start
_, err := repo.ClaimSubBatch(ctx, newer.OperationID, "new-owner", time.Now())
errs <- err
}()
close(start)
// 实际观察InnoDB锁等待,而非只靠两goroutine恰好同时运行。
waiting := false
deadline := time.After(5 * time.Second)
tick := time.NewTicker(10 * time.Millisecond)
defer tick.Stop()
observe:
for {
select {
case <-deadline:
break observe
case <-tick.C:
value, err := repo.database().GetValue(ctx, "SELECT COUNT(*) FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='data_operation_card_claim'")
if err != nil {
t.Fatal(err)
}
if value.Int() >= 2 {
waiting = true
break observe
}
}
}
if err := holder.Commit(); err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := <-errs; err != nil {
t.Fatal(err)
}
}
if !waiting {
t.Fatal("未实际观察到两条同卡交易的InnoDB锁等待")
}
stored, err := repo.GetSubBatch(ctx, older.OperationID)
if err != nil || stored.Status != "prepared" || !stored.SubmittedAt.IsZero() {
t.Fatalf("确定未发送回退状态: %+v %v", stored, err)
}
n, err := repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id != ''").Count()
if err != nil || n != 0 {
t.Fatalf("旧prepared未完成而新批抢先或残留部分active: %d %v", n, err)
}
t.Log("已观察两条事务等待同卡锁,释放后均提交成功,两卡输入BA/AB未形成反向环")
}
func TestSubBatchMySQLClaimDoesNotLockBusinessParent(t *testing.T) {
if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" {
t.Skip("仅隔离MySQL验证前序JOIN不锁业务父批")
}
repo, base := testSubBatchDB(t)
ctx, cancel := context.WithTimeout(base, 10*time.Second)
defer cancel()
older := testSub("S-1", "main-lock-old", "a", []int64{1}, []string{"A"})
newer := testSub("S-2", "main-lock-new", "a", []int64{3}, []string{"A"})
for _, b := range []service.SubBatch{older, newer} {
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
}
holder, err := repo.database().Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer holder.Rollback()
if _, err := holder.GetAll("SELECT id FROM batch_main WHERE batch_no='S-1' FOR UPDATE"); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "new-owner", time.Now())
if err == nil && got != nil {
err = fmt.Errorf("新批越过旧批")
}
done <- err
}()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
t.Fatal("Claim持card等待业务main,存在main→card反向环")
}
t.Log("旧业务main保持FOR UPDATE期间,新批前序当前读完成且未越序")
}
func TestSubBatchMySQLCrossTenantSameCardsDoNotLockOtherSub(t *testing.T) {
if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" {
t.Skip("仅隔离MySQL验证跨租户同ICCID双卡并发")
}
repo, base := testSubBatchDB(t)
ctx, cancel := context.WithTimeout(base, 15*time.Second)
defer cancel()
for _, q := range []string{
"UPDATE batch_main SET tenant_id='other-tenant' WHERE batch_no='S-2'",
"INSERT INTO batch_detail(id,batch_no,detail_status)VALUES(4,'S-2',1)",
} {
if _, err := repo.database().Exec(ctx, q); err != nil {
t.Fatal(err)
}
}
first := testSub("S-1", "tenant-one-BA", "a", []int64{1, 2}, []string{"B", "A"})
second := testSub("S-2", "tenant-two-AB", "a", []int64{3, 4}, []string{"A", "B"})
for _, b := range []service.SubBatch{first, second} {
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
}
start := make(chan struct{})
errs := make(chan error, 2)
for _, b := range []service.SubBatch{first, second} {
go func(b service.SubBatch) {
<-start
got, err := repo.ClaimSubBatch(ctx, b.OperationID, b.OperationID, time.Now())
if err == nil && got == nil {
err = fmt.Errorf("跨租户意图不应互相阻断: %s", b.OperationID)
}
errs <- err
}(b)
}
close(start)
for i := 0; i < 2; i++ {
if err := <-errs; err != nil {
t.Fatal(err)
}
}
n, err := repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id != ''").Count()
if err != nil || n != 4 {
t.Fatalf("跨tenant同ICCID active归属丢失: %d %v", n, err)
}
}
func TestSubBatchMySQLCrossTenantForeignDetailLockDoesNotBlock(t *testing.T) {
if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" {
t.Skip("仅隔离MySQL验证异租户相同ICCID不锁候选范围")
}
repo, base := testSubBatchDB(t)
ctx, cancel := context.WithTimeout(base, 15*time.Second)
defer cancel()
if _, err := repo.database().Exec(ctx, "UPDATE batch_main SET tenant_id='other-tenant' WHERE batch_no='S-2'"); err != nil {
t.Fatal(err)
}
foreign := testSub("S-1", "foreign-A", "a", []int64{1}, []string{"A"})
local := testSub("S-2", "local-A", "a", []int64{3}, []string{"A"})
for _, b := range []service.SubBatch{foreign, local} {
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
}
holder, err := repo.database().Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer holder.Rollback()
if _, err := holder.GetAll("SELECT detail_id FROM data_operation_sub_detail WHERE operation_id=? FOR UPDATE", foreign.OperationID); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
got, err := repo.ClaimSubBatch(ctx, local.OperationID, "other-tenant-owner", time.Now())
if err == nil && got == nil {
err = fmt.Errorf("异租户独立意图未领取")
}
result <- err
}()
blocked := false
var claimErr error
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
wait := time.After(2 * time.Second)
loop:
for {
select {
case claimErr = <-result:
break loop
case <-wait:
blocked = true
break loop
case <-ticker.C:
value, err := repo.database().GetValue(ctx, "SELECT COUNT(*) FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='data_operation_sub_detail'")
if err != nil {
t.Fatal(err)
}
if value.Int() > 0 {
blocked = true
break loop
}
}
}
if err := holder.Commit(); err != nil {
t.Fatal(err)
}
if blocked {
claimErr = <-result
}
if claimErr != nil {
t.Fatal(claimErr)
}
if blocked {
t.Fatal("异租户旧明细持锁无谓阻塞新租户同ICCID Claim")
}
}
func TestSubBatchMySQLRepeatableReadStillSeesCommittedPredecessor(t *testing.T) {
if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" {
t.Skip("仅隔离MySQL验证RR快照之后的前序当前读")
}
repo, base := testSubBatchDB(t)
ctx, cancel := context.WithTimeout(base, 15*time.Second)
defer cancel()
older := testSub("S-1", "rr-old-stop", "a", []int64{1}, []string{"A"})
newer := testSub("S-2", "rr-new-open", "a", []int64{3}, []string{"A"})
if err := repo.PrepareSubBatch(ctx, newer); err != nil {
t.Fatal(err)
}
// 外层事务先建立无旧意图的RR快照,再由独立连接提交旧父批的冻结意图。
tx, err := repo.database().Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
if _, err := tx.GetAll("SELECT operation_id FROM data_operation_sub_batch"); err != nil {
t.Fatal(err)
}
prepared := make(chan error, 1)
go func() { prepared <- repo.PrepareSubBatch(base, older) }()
if err := <-prepared; err != nil {
t.Fatal(err)
}
rows, err := tx.GetAll("SELECT operation_id FROM data_operation_sub_batch WHERE operation_id=?", older.OperationID)
if err != nil || len(rows) != 0 {
t.Fatalf("未实际建立看不到新提交前序的RR快照: %v %v", rows, err)
}
if gdb.TXFromCtx(tx.GetCtx(), repo.database().GetGroup()) == nil {
t.Fatal("外层RR事务未绑定上下文")
}
got, err := repo.ClaimSubBatch(tx.GetCtx(), newer.OperationID, "rr-owner", time.Now())
if err != nil || got != nil {
t.Fatalf("当前读遗漏已提交前序: %+v %v", got, err)
}
t.Log("普通RR快照仍看不到旧意图,Claim的锁定当前读已阻止新批越序")
}
func TestSubBatchMySQLDeadlockRetriesWholeTransaction(t *testing.T) {
if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" {
t.Skip("仅隔离MySQL验证真实1213回滚重试")
}
repo, base := testSubBatchDB(t)
ctx, cancel := context.WithTimeout(base, 15*time.Second)
defer cancel()
if _, err := repo.database().Exec(ctx, "INSERT INTO data_operation_card_claim(tenant_id,iccid,batch_id,operation_id)VALUES('tenant','dead-A',0,''),('tenant','dead-B',0,'')"); err != nil {
t.Fatal(err)
}
locked := make(chan struct{}, 2)
proceed := make(chan struct{})
errs := make(chan error, 2)
var total atomic.Int32
for _, keys := range [][2]string{{"dead-A", "dead-B"}, {"dead-B", "dead-A"}} {
go func(keys [2]string) {
attempt := 0
errs <- repo.durableTx(ctx, func(ctx context.Context, tx gdb.TX) error {
attempt++
total.Add(1)
if _, err := tx.Exec("UPDATE data_operation_card_claim SET batch_id=batch_id+1 WHERE tenant_id='tenant' AND iccid=?", keys[0]); err != nil {
return err
}
if attempt == 1 {
locked <- struct{}{}
select {
case <-proceed:
case <-ctx.Done():
return ctx.Err()
}
}
_, err := tx.Exec("UPDATE data_operation_card_claim SET batch_id=batch_id+1 WHERE tenant_id='tenant' AND iccid=?", keys[1])
return err
})
}(keys)
}
for i := 0; i < 2; i++ {
select {
case <-locked:
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
close(proceed)
for i := 0; i < 2; i++ {
if err := <-errs; err != nil {
t.Fatal(err)
}
}
if total.Load() < 3 {
t.Fatal("未触发真实死锁重试")
}
rows, err := repo.database().GetAll(ctx, "SELECT batch_id FROM data_operation_card_claim WHERE iccid IN ('dead-A','dead-B')")
if err != nil || len(rows) != 2 {
t.Fatalf("结果: %v %v", rows, err)
}
for _, row := range rows {
if row["batch_id"].Int() != 2 {
t.Fatalf("回滚后重复或遗漏更新: %v", rows)
}
}
t.Logf("两事务成功提交,实际执行轮数=%d", total.Load())
}
@@ -0,0 +1,232 @@
package data
import (
"context"
"database/sql"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/gogf/gf/v2/os/gtime"
mysql "github.com/go-sql-driver/mysql"
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"github.com/gogf/gf/v2/database/gdb"
"gopkg.in/yaml.v3"
)
// 仅显式启用时使用已核验的本机专用测试库;认证始终仅在内存中处理。
func testSubBatchMySQL(t *testing.T) gdb.DB {
t.Helper()
if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" {
t.Fatal("未显式启用隔离MySQL测试")
}
_, file, _, _ := runtime.Caller(0)
root := filepath.Clean(filepath.Join(filepath.Dir(file), "../../../.."))
raw, err := os.ReadFile(filepath.Join(root, "iotlcm-sms/deploy/config.yaml"))
if err != nil {
t.Fatal("读取本机认证配置失败,未打印凭据")
}
var config map[string]any
if err := yaml.Unmarshal(raw, &config); err != nil {
t.Fatal("解析本机认证配置失败,未打印内容")
}
var dsn string
var visit func(map[string]any)
visit = func(m map[string]any) {
for key, value := range m {
if key == "sceneDbDsn" {
if text, ok := value.(string); ok {
dsn = text
}
}
if nested, ok := value.(map[string]any); ok {
visit(nested)
}
}
}
visit(config)
parsed, err := mysql.ParseDSN(dsn)
if err != nil {
t.Fatal("本机DSN格式不正确,未打印内容")
}
if parsed.Net != "tcp" || (parsed.Addr != "127.0.0.1:3306" && parsed.Addr != "localhost:3306") {
t.Fatal("认证配置并非允许的本机3306,拒绝连接")
}
const database = "iotdb_apn_worker_test_20261015"
parsed.DBName = database
connection, err := sql.Open("mysql", parsed.FormatDSN())
if err != nil {
t.Fatal("创建隔离连接失败")
}
defer connection.Close()
ctx := context.Background()
var uuid, datadir, name, version string
var port int
if err := connection.QueryRowContext(ctx, "SELECT @@server_uuid,@@datadir,@@port,DATABASE(),VERSION()").Scan(&uuid, &datadir, &port, &name, &version); err != nil {
t.Fatal("只读核验本机实例身份失败")
}
normalized := strings.TrimRight(strings.ReplaceAll(datadir, "\\", "/"), "/")
if uuid != "4fa545af-ab36-11f1-be02-040e3c9de361" || port != 3306 || name != database || !strings.EqualFold(normalized, "D:/MySQL/MySQL Server 8.0/Data") {
t.Fatal("实例身份不匹配,拒绝任何写入")
}
t.Logf("隔离MySQL身份:version=%s uuid=%s port=%d database=%s", version, uuid, port, name)
db, err := gdb.New(gdb.ConfigNode{Type: "mysql", Host: "127.0.0.1", Port: "3306", User: parsed.User, Pass: parsed.Passwd, Name: database, Charset: "utf8mb4", Timezone: "Asia/Shanghai", Extra: "parseTime=true"})
if err != nil {
t.Fatal("创建GoFrame隔离连接失败,未打印认证")
}
// 仅重建此专用库中的固定fixture表,不读取或修改任何业务库。
for _, table := range []string{"data_operation_sub_detail", "data_operation_sub_batch", "data_operation_card_claim", "data_operation_parent", "batch_detail", "batch_main", "t_sim_card_info", "sys_oss", "t_resource_batch_download"} {
if _, err := db.Exec(ctx, "DROP TABLE IF EXISTS `"+database+"`.`"+table+"`"); err != nil {
t.Fatalf("重建专用fixture失败:%s", table)
}
}
migration, err := os.ReadFile(filepath.Join(root, "iotlcm-database/SQL/20261015_data_operation_sub_batch.sql"))
if err != nil {
t.Fatal("读取worker迁移失败")
}
for _, statement := range strings.Split(string(migration), ";") {
statement = strings.TrimSpace(statement)
if statement == "" {
continue
}
if _, err := db.Exec(ctx, statement); err != nil {
t.Fatalf("执行专用库worker迁移失败:%v", err)
}
}
return db
}
func TestSubBatchMySQLMigrationRepeatPreservesAcceptedIntent(t *testing.T) {
if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" {
t.Skip("仅隔离MySQL验证真实迁移重复执行")
}
repo, ctx := testSubBatchDB(t)
b := testSub("S-1", "migration-accepted", "a", []int64{1}, []string{"fake-A"})
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
claimed, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner", time.Now())
if err != nil || claimed == nil {
t.Fatalf("领取: %+v %v", claimed, err)
}
if err := repo.UpdateSubBatch(ctx, *claimed, "accepted", "preserved-job", "已受理", time.Now(), 2); err != nil {
t.Fatal(err)
}
query, err := repo.ClaimSubBatch(ctx, b.OperationID, "preserved-query-owner", time.Now().Add(time.Second))
if err != nil || query == nil {
t.Fatalf("领取在途查询租约: %+v %v", query, err)
}
before, err := repo.database().GetOne(ctx, "SELECT request_data,detail_ids,job_id,poll_count,submitted_at,status,lease_token,lease_until,next_check_at FROM data_operation_sub_batch WHERE operation_id=?", b.OperationID)
if err != nil {
t.Fatal(err)
}
if before["status"].String() != "accepted" || before["job_id"].String() != "preserved-job" || before["poll_count"].Int() != 3 || before["lease_token"].String() != "preserved-query-owner" || before["lease_until"].IsNil() {
t.Fatalf("未实际构造accepted在途租约: %v", before)
}
_, file, _, _ := runtime.Caller(0)
path := filepath.Join(filepath.Dir(file), "../../../../iotlcm-database/SQL/20261015_data_operation_sub_batch.sql")
migration, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for repeat := 0; repeat < 2; repeat++ {
for _, statement := range strings.Split(string(migration), ";") {
if strings.TrimSpace(statement) == "" {
continue
}
if _, err := repo.database().Exec(ctx, statement); err != nil {
t.Fatal(err)
}
}
}
after, err := repo.database().GetOne(ctx, "SELECT request_data,detail_ids,job_id,poll_count,submitted_at,status,lease_token,lease_until,next_check_at FROM data_operation_sub_batch WHERE operation_id=?", b.OperationID)
if err != nil {
t.Fatal(err)
}
for key, value := range before {
if value.String() != after[key].String() {
t.Fatalf("重复迁移改变受理字段: %s", key)
}
}
n, err := repo.database().Model("data_operation_sub_detail").Ctx(ctx).Where("operation_id", b.OperationID).Count()
if err != nil || n != 1 {
t.Fatalf("重复迁移丢明细归属: %d %v", n, err)
}
n, err = repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id", b.OperationID).Count()
if err != nil || n != 1 {
t.Fatalf("重复迁移丢active: %d %v", n, err)
}
}
func TestSubBatchMySQLDifferentDatabaseDefaultCollation(t *testing.T) {
if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" {
t.Skip("仅隔离MySQL验证不同默认排序规则")
}
repo, ctx := testSubBatchDB(t)
value, err := repo.database().GetValue(ctx, "SELECT DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME=DATABASE()")
if err != nil || value.String() != "utf8mb4_0900_ai_ci" {
t.Fatalf("未实际覆盖不同DB默认排序规则: %v %v", value, err)
}
rows, err := repo.database().GetAll(ctx, "SELECT TABLE_NAME,COLLATION_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND COLUMN_NAME='batch_no' AND TABLE_NAME IN ('batch_main','batch_detail','data_operation_parent','data_operation_sub_batch','data_operation_sub_detail')")
if err != nil || len(rows) != 5 {
t.Fatalf("关联列元数据: %v %v", rows, err)
}
for _, row := range rows {
if row["COLLATION_NAME"].String() != "utf8mb4_general_ci" {
t.Fatalf("关联列未匹配旧基线: %v", rows)
}
}
b := testSub("S-1", "collation-real-join", "a", []int64{1}, []string{"fake-A"})
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
if ids, err := repo.ListDueSubBatches(ctx, time.Now(), 128); err != nil || len(ids) != 1 {
t.Fatalf("跨新旧表due查询: %v %v", ids, err)
}
if ids, err := repo.ListRecoverableBatches(ctx, 128); err != nil {
t.Fatalf("跨新旧表恢复查询: %v %v", ids, err)
}
if got, err := repo.ClaimSubBatch(ctx, b.OperationID, "collation-owner", time.Now()); err != nil || got == nil {
t.Fatalf("跨新旧表前序锁定JOIN: %+v %v", got, err)
}
indexRows, err := repo.database().GetAll(ctx, "EXPLAIN SELECT STRAIGHT_JOIN s.operation_id FROM batch_main m FORCE INDEX(PRIMARY) JOIN data_operation_sub_detail sd ON sd.batch_no=m.batch_no AND sd.iccid='fake-A' JOIN data_operation_sub_batch s ON s.operation_id=sd.operation_id JOIN batch_detail d ON d.id=sd.detail_id WHERE m.tenant_id='tenant' AND m.id<11 AND d.detail_status=1 AND s.status IN ('prepared','submitting','accepted','uncertain') LIMIT 1 FOR UPDATE OF sd,s,d")
if err != nil || len(indexRows) < 2 || indexRows[0]["table"].String() != "m" || indexRows[1]["table"].String() != "sd" || indexRows[1]["key"].String() != "uk_data_sub_batch_card" {
t.Fatalf("前序锁定查询未按父批→同卡映射驱动: %v %v", indexRows, err)
}
t.Logf("前序EXPLAIN首表=%s key=%s;第二表=%s key=%s", indexRows[0]["table"].String(), indexRows[0]["key"].String(), indexRows[1]["table"].String(), indexRows[1]["key"].String())
t.Logf("DB默认=%s,五张新旧表batch_no均general_ci,真实due/recovery/claim JOIN成功", value.String())
}
func logMySQLLease(t *testing.T, repo *Repository, batchID int64, token string) {
t.Helper()
if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" {
return
}
ctx := context.Background()
raw, err := repo.database().GetOne(ctx, "SELECT CAST(lease_until AS CHAR) AS raw_lease, CAST(NOW(6) AS CHAR) AS db_now, CAST(UTC_TIMESTAMP(6) AS CHAR) AS db_utc, @@session.time_zone AS session_zone FROM data_operation_sub_batch WHERE id=?", batchID)
if err != nil {
t.Fatal(err)
}
var row subRow
if err := repo.database().Model(subTable).Ctx(ctx).Where("id", batchID).Scan(&row); err != nil {
t.Fatal(err)
}
var alternate struct {
LeaseUntil *gtime.Time `orm:"lease_until"`
}
if err := repo.database().Model(subTable).Ctx(ctx).Where("id", batchID).Scan(&alternate); err != nil {
t.Fatal(err)
}
now := time.Now()
t.Logf("租约原值 raw=%s dbNow=%s dbUTC=%s sessionZone=%s tokenMatch=%t status=%s nil=%t", raw["raw_lease"].String(), raw["db_now"].String(), raw["db_utc"].String(), raw["session_zone"].String(), row.LeaseToken == token, row.Status, row.LeaseUntil == nil)
if row.LeaseUntil != nil {
t.Logf("time扫描=%s location=%s unixNano=%d; now=%s location=%s unixNano=%d", row.LeaseUntil.Format(time.RFC3339Nano), row.LeaseUntil.Location(), row.LeaseUntil.UnixNano(), now.Format(time.RFC3339Nano), now.Location(), now.UnixNano())
}
if alternate.LeaseUntil != nil {
t.Logf("gtime扫描=%s location=%s unixNano=%d", alternate.LeaseUntil.Format("c"), alternate.LeaseUntil.Time.Location(), alternate.LeaseUntil.UnixNano())
}
}
@@ -0,0 +1,123 @@
package data
import (
"testing"
"time"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
service "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data"
)
func TestSubBatchFrozenOrderNewClaimFirstDoesNotCancelOlderCards(t *testing.T) {
repo, ctx := testSubBatchDB(t)
for _, q := range []string{
`UPDATE batch_detail SET item_id=CASE id WHEN 2 THEN 'B' ELSE 'A' END,snapshot='{"authorized":true}'`,
`UPDATE batch_detail SET batch_type=109 WHERE batch_no='S-2'`,
`INSERT INTO t_sim_card_info(id,tenant_id,iccid,data_service_status)VALUES(1,'tenant','A',1),(2,'tenant','B',1)`,
} {
if _, err := repo.database().Exec(ctx, q); err != nil {
t.Fatal(err)
}
}
older := testSub("S-1", "frozen-stop-AB", "a", []int64{1, 2}, []string{"A", "B"})
newer := testSub("S-2", "frozen-open-A", "a", []int64{3}, []string{"A"})
newer.BatchType = 109
newer.Request.ReqData.OperType = "0"
for _, sub := range []service.SubBatch{older, newer} {
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
t.Fatal(err)
}
}
if got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "new-first", time.Now()); err != nil || got != nil {
t.Fatalf("newer overtook frozen predecessor: %+v %v", got, err)
}
row, err := repo.database().GetOne(ctx, "SELECT status FROM data_operation_sub_batch WHERE operation_id=?", older.OperationID)
if err != nil || row["status"].String() != "prepared" {
t.Fatalf("older work cancelled: %v %v", row, err)
}
first, err := repo.ClaimSubBatch(ctx, older.OperationID, "old-first", time.Now())
if err != nil || first == nil {
t.Fatalf("older not claimable: %+v %v", first, err)
}
if err := repo.CompleteSubBatch(ctx, *first, []service.Outcome{{Success: true}, {Success: true}}); err != nil {
t.Fatal(err)
}
rows, err := repo.database().GetAll(ctx, "SELECT iccid,data_service_status FROM t_sim_card_info ORDER BY iccid")
if err != nil || len(rows) != 2 || rows[0]["data_service_status"].Int() != 0 || rows[1]["data_service_status"].Int() != 0 {
t.Fatalf("stop A/B not persisted: %v %v", rows, err)
}
if _, err := repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET next_check_at=NULL WHERE operation_id=?", newer.OperationID); err != nil {
t.Fatal(err)
}
second, err := repo.ClaimSubBatch(ctx, newer.OperationID, "new-after-old", time.Now())
if err != nil || second == nil {
t.Fatalf("newer not released after old terminal: %+v %v", second, err)
}
if err := repo.CompleteSubBatch(ctx, *second, []service.Outcome{{Success: true}}); err != nil {
t.Fatal(err)
}
rows, err = repo.database().GetAll(ctx, "SELECT iccid,data_service_status FROM t_sim_card_info ORDER BY iccid")
if err != nil || rows[0]["data_service_status"].Int() != 1 || rows[1]["data_service_status"].Int() != 0 {
t.Fatalf("reverse should reopen only A, keep B stopped: %v %v", rows, err)
}
details, err := repo.database().GetAll(ctx, "SELECT detail_status FROM batch_detail ORDER BY id")
if err != nil || len(details) != 3 {
t.Fatalf("details: %v %v", details, err)
}
for _, d := range details {
if d["detail_status"].Int() != consts.BatchDetailStatusSuccess {
t.Fatalf("card was omitted or cancelled: %v", details)
}
}
}
func TestSubBatchParentOrderSurvivesReverseFreezeOrder(t *testing.T) {
repo, ctx := testSubBatchDB(t)
older := testSub("S-1", "parent-old-freeze-second", "a", []int64{1, 2}, []string{"A", "B"})
newer := testSub("S-2", "parent-new-freeze-first", "a", []int64{3}, []string{"A"})
for _, b := range []service.SubBatch{newer, older} {
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
}
oldStored, _ := repo.GetSubBatch(ctx, older.OperationID)
newStored, _ := repo.GetSubBatch(ctx, newer.OperationID)
if oldStored.ID <= newStored.ID {
t.Fatal("未实际复现父批与冻结ID相反顺序")
}
if got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "new-first", time.Now()); err != nil || got != nil {
t.Fatalf("新批越过已冻结的旧父批: %+v %v", got, err)
}
if got, err := repo.ClaimSubBatch(ctx, older.OperationID, "old-first", time.Now()); err != nil || got == nil {
t.Fatalf("旧停AB应完整可领取: %+v %v", got, err)
}
n, err := repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id", older.OperationID).Count()
if err != nil || n != 2 {
t.Fatalf("旧批不能遗漏B: %d %v", n, err)
}
}
func TestSubBatchPreparedPredecessorWaitHasFiniteUnsubmittedOutcome(t *testing.T) {
repo, ctx := testSubBatchDB(t)
older := testSub("S-1", "prepared-old", "a", []int64{1}, []string{"A"})
newer := testSub("S-2", "prepared-new", "a", []int64{3}, []string{"A"})
for _, sub := range []service.SubBatch{older, newer} {
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
t.Fatal(err)
}
}
if _, err := repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET created_at=?,next_check_at=NULL WHERE operation_id=?", time.Now().Add(-16*time.Minute), newer.OperationID); err != nil {
t.Fatal(err)
}
if got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "expired", time.Now()); err != nil || got != nil {
t.Fatalf("expired later intent was submitted: %+v %v", got, err)
}
row, err := repo.database().GetOne(ctx, "SELECT status,submitted_at,poll_count FROM data_operation_sub_batch WHERE operation_id=?", newer.OperationID)
if err != nil || row["status"].String() != "review" || !row["submitted_at"].IsNil() || row["poll_count"].Int() != 0 {
t.Fatalf("later intent not finitely closed unsent: %v %v", row, err)
}
row, err = repo.database().GetOne(ctx, "SELECT status FROM data_operation_sub_batch WHERE operation_id=?", older.OperationID)
if err != nil || row["status"].String() != "prepared" {
t.Fatalf("older intent cancelled: %v %v", row, err)
}
}
+781
View File
@@ -0,0 +1,781 @@
package data
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gogf/gf/v2/database/gdb"
"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"
service "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data"
)
func testSubBatchDB(t *testing.T) (*Repository, context.Context) {
t.Helper()
ctx := context.Background()
var db gdb.DB
var err error
mysqlTest := os.Getenv("APN_WORKER_MYSQL_TEST") == "1"
if mysqlTest {
db = testSubBatchMySQL(t)
} else {
db, err = gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(" + t.TempDir() + "/apn.db)"})
}
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(ctx); err != nil {
t.Error(err)
}
})
if !mysqlTest {
if _, err := db.Exec(ctx, "PRAGMA journal_mode=WAL"); err != nil {
t.Fatal(err)
}
}
for _, statement := range []string{
"CREATE TABLE batch_main (id INTEGER PRIMARY KEY, batch_no TEXT, batch_type INTEGER, batch_async_status INTEGER, tenant_id TEXT, start_time DATETIME, updated_at DATETIME, created_by INTEGER, created_dept INTEGER, valid_count INTEGER, success_count INTEGER, fail_count INTEGER, run_result INTEGER, fail_reason TEXT, batch_info TEXT, end_time DATETIME, updated_by INTEGER)",
"CREATE TABLE data_operation_parent (batch_no TEXT PRIMARY KEY, ready INTEGER DEFAULT 0, finalize_token TEXT, finalize_until DATETIME, recovery_attempt_at DATETIME, last_reason TEXT)",
"INSERT INTO data_operation_parent (batch_no,ready) VALUES ('S-1',1),('S-2',1)",
"CREATE TABLE t_sim_card_info (id INTEGER PRIMARY KEY, tenant_id TEXT, iccid TEXT, msisdn TEXT, imsi TEXT, chan_key TEXT, data_service_status INTEGER, status_sync_time DATETIME, traffic_cutoff_triggered_at DATETIME, updated_by INTEGER)",
"CREATE TABLE batch_detail (id INTEGER PRIMARY KEY, batch_no TEXT, batch_type INTEGER DEFAULT 110, row_no INTEGER, item_id TEXT, detail_status INTEGER, error_code TEXT, error_reason TEXT, snapshot TEXT, updated_by INTEGER)",
"CREATE TABLE sys_oss (id INTEGER PRIMARY KEY AUTOINCREMENT,tenant_id TEXT,file_name TEXT,original_name TEXT,file_suffix TEXT,path TEXT,url TEXT,created_dept INTEGER,created_by INTEGER,updated_by INTEGER,service TEXT,md5 TEXT,file_size INTEGER)",
"CREATE TABLE t_resource_batch_download (id INTEGER PRIMARY KEY AUTOINCREMENT,batch_no TEXT,task_name TEXT,resource_type INTEGER,export_scene INTEGER,total_count INTEGER,success_count INTEGER,fail_count INTEGER,download_status INTEGER,oss_id INTEGER,file_name TEXT,file_ext TEXT,file_url TEXT,file_path TEXT,file_size INTEGER,file_md5 TEXT,download_count INTEGER,max_download_count INTEGER,dept_id INTEGER,start_time DATETIME,finish_time DATETIME,tenant_id TEXT,created_dept INTEGER,created_by INTEGER,updated_by INTEGER)",
"CREATE TABLE data_operation_sub_batch (id INTEGER PRIMARY KEY AUTOINCREMENT, batch_no TEXT, batch_type INTEGER, chan_key TEXT, operation_id TEXT UNIQUE, detail_ids TEXT, request_data TEXT, job_id TEXT, status TEXT, poll_count INTEGER DEFAULT 0, next_check_at DATETIME, submitted_at DATETIME, lease_token TEXT, lease_until DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_reason TEXT)",
"CREATE TABLE data_operation_sub_detail (detail_id INTEGER PRIMARY KEY, operation_id TEXT, batch_no TEXT, iccid TEXT, UNIQUE(batch_no,iccid))",
"CREATE TABLE data_operation_card_claim (tenant_id TEXT, iccid TEXT, batch_id INTEGER, operation_id TEXT, active_batch_id INTEGER DEFAULT 0, active_operation_id TEXT DEFAULT '', PRIMARY KEY(tenant_id,iccid))",
"INSERT INTO batch_main (id,batch_no,batch_type,batch_async_status,tenant_id) VALUES (10,'S-1',110,2,'tenant'),(11,'S-2',109,2,'tenant')",
"INSERT INTO batch_detail (id,batch_no,detail_status) VALUES (1,'S-1',1),(2,'S-1',1),(3,'S-2',1)",
} {
if mysqlTest {
if strings.HasPrefix(statement, "CREATE TABLE data_operation_") {
continue
}
statement = strings.ReplaceAll(statement, "INTEGER PRIMARY KEY AUTOINCREMENT", "BIGINT PRIMARY KEY AUTO_INCREMENT")
statement = strings.ReplaceAll(statement, " TEXT", " VARCHAR(512)")
statement = strings.ReplaceAll(statement, "snapshot VARCHAR(512)", "snapshot JSON")
statement = strings.ReplaceAll(statement, "batch_info VARCHAR(512)", "batch_info JSON")
if strings.HasPrefix(statement, "CREATE TABLE ") {
statement += " DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"
}
}
if _, err := db.Exec(ctx, statement); err != nil {
t.Fatalf("setup %q: %v", statement, err)
}
}
return &Repository{db: db}, ctx
}
func testSub(batchNo, operationID, chanKey string, detailIDs []int64, iccids []string) service.SubBatch {
keys := make([]*model.SimKey, 0, len(detailIDs))
for i, id := range detailIDs {
_ = id
keys = append(keys, &model.SimKey{ICCID: iccids[i], ChanKey: chanKey, MSISDN: fmt.Sprint(i + 1)})
}
return service.SubBatch{BatchNo: batchNo, BatchType: 110, ChanKey: chanKey, OperationID: operationID, DetailIDs: detailIDs, Request: model.SimCardApnChangeInput{SimKeys: keys, ReqData: model.SimCardApnChangeInputItem{OperType: "1", OperationID: operationID, SubmitOnly: true}}}
}
func TestSubBatchDurableClaimCASAndReview(t *testing.T) {
repo, ctx := testSubBatchDB(t)
b := testSub("S-1", "intent-1", "a", []int64{1, 2}, []string{"A", "B"})
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatalf("幂等准备失败: %v", err)
}
first, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner1", time.Now())
if err != nil {
t.Fatal(err)
}
if first == nil || first.Status != "prepared" {
t.Fatalf("claim=%+v", first)
}
second, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner2", time.Now())
if err != nil {
t.Fatal(err)
}
if second != nil {
t.Fatal("并发claim不应成功")
}
if err := repo.UpdateSubBatch(ctx, *first, "accepted", "job-1", "已受理", time.Now().Add(time.Minute), 0); err != nil {
t.Fatal(err)
}
if err := repo.UpdateSubBatch(ctx, *first, "done", "", "stale", time.Time{}, 0); err == nil {
t.Fatal("旧lease竟能覆盖")
}
row, err := repo.database().GetOne(ctx, "SELECT status,job_id FROM data_operation_sub_batch WHERE operation_id=?", b.OperationID)
if err != nil {
t.Fatal(err)
}
if row["status"].String() != "accepted" || row["job_id"].String() != "job-1" {
t.Fatalf("jobId未立即落库: %+v", row)
}
if _, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner3", time.Now()); err != nil {
t.Fatal(err)
}
if second, err = repo.ClaimSubBatch(ctx, b.OperationID, "owner3", time.Now().Add(2*time.Minute)); err != nil {
t.Fatal(err)
}
if second == nil || second.JobID != "job-1" {
t.Fatalf("无法恢复已受理jobId: %+v", second)
}
if err := repo.UpdateSubBatch(ctx, *second, "review", "job-1", "结果未确认", time.Time{}, 4); err != nil {
t.Fatal(err)
}
for _, id := range b.DetailIDs {
row, err := repo.database().GetOne(ctx, "SELECT detail_status,error_reason FROM batch_detail WHERE id=?", id)
if err != nil {
t.Fatal(err)
}
if row["detail_status"].Int() != consts.BatchDetailStatusUnknown || row["error_reason"].String() != "结果未确认" {
t.Fatalf("未知状态落库失败 id=%d row=%+v", id, row)
}
}
if ids, err := repo.ListDueSubBatches(ctx, time.Now().Add(time.Hour), 10); err != nil {
t.Fatal(err)
} else if len(ids) != 0 {
t.Fatalf("review不应自动重试: %+v", ids)
}
}
func TestSubBatchPreparationRollbackAndReadyBarrier(t *testing.T) {
repo, ctx := testSubBatchDB(t)
b := testSub("S-1", "barrier", "a", []int64{1}, []string{"A"})
_, _ = repo.database().Exec(ctx, "UPDATE data_operation_parent SET ready=0 WHERE batch_no='S-1'")
interrupted := fmt.Errorf("模拟准备中断")
err := repo.ResumeOrPrepare(ctx, "S-1", func(prep context.Context) error {
if err := repo.PrepareSubBatch(prep, b); err != nil {
return err
}
return interrupted
})
if err == nil {
t.Fatal("中断应回滚")
}
n, _ := repo.database().Model(subTable).Ctx(ctx).Count()
if n != 0 {
t.Fatal("部分准备未回滚")
}
if err := repo.ResumeOrPrepare(ctx, "S-1", func(prep context.Context) error { return repo.PrepareSubBatch(prep, b) }); err != nil {
t.Fatal(err)
}
_, _ = repo.database().Exec(ctx, "UPDATE data_operation_parent SET ready=0 WHERE batch_no='S-1'")
if got, err := repo.ClaimSubBatch(ctx, b.OperationID, "premature", time.Now()); err != nil || got != nil {
t.Fatalf("ready前可领取: %+v %v", got, err)
}
ids, err := repo.ListDueSubBatches(ctx, time.Now().Add(time.Minute), 10)
if err != nil || len(ids) != 0 {
t.Fatalf("ready前可扫描: %v %v", ids, err)
}
}
func TestSubBatchActiveBlocksReverseAndReviewTerminatesUnsubmitted(t *testing.T) {
repo, ctx := testSubBatchDB(t)
older := testSub("S-1", "active-old", "a", []int64{1}, []string{"A"})
newer := testSub("S-2", "queued-new", "a", []int64{3}, []string{"A"})
if err := repo.PrepareSubBatch(ctx, older); err != nil {
t.Fatal(err)
}
first, err := repo.ClaimSubBatch(ctx, older.OperationID, "owner1", time.Now())
if err != nil || first == nil {
t.Fatalf("%+v %v", first, err)
}
if err := repo.PrepareSubBatch(ctx, newer); err != nil {
t.Fatal(err)
}
if got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "reverse", time.Now()); err != nil || got != nil {
t.Fatalf("在途前序未阻断反向: %+v %v", got, err)
}
row, _ := repo.database().GetOne(ctx, "SELECT active_operation_id,operation_id FROM data_operation_card_claim WHERE iccid='A'")
if row["active_operation_id"].String() != older.OperationID || row["operation_id"].String() != newer.OperationID {
t.Fatalf("queued抢占active: %v", row)
}
if err := repo.UpdateSubBatch(ctx, *first, "review", "", "前序未知", time.Time{}, 4); err != nil {
t.Fatal(err)
}
_, _ = repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET next_check_at=NULL WHERE operation_id=?", newer.OperationID)
if got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "reverse", time.Now()); err != nil || got != nil {
t.Fatalf("未知前序未阻断: %+v %v", got, err)
}
row, _ = repo.database().GetOne(ctx, "SELECT status,submitted_at,poll_count FROM data_operation_sub_batch WHERE operation_id=?", newer.OperationID)
if row["status"].String() != "review" || !row["submitted_at"].IsNil() || row["poll_count"].Int() != 0 {
t.Fatalf("未提交后继应有限收尾: %v", row)
}
row, _ = repo.database().GetOne(ctx, "SELECT active_operation_id FROM data_operation_card_claim WHERE iccid='A'")
if row["active_operation_id"].String() != older.OperationID {
t.Fatal("未知active被释放")
}
}
func TestSubBatchBlockedGroupNeverAcquiresPartialActive(t *testing.T) {
repo, ctx := testSubBatchDB(t)
old := testSub("S-1", "old-B", "a", []int64{1}, []string{"B"})
if err := repo.PrepareSubBatch(ctx, old); err != nil {
t.Fatal(err)
}
if _, err := repo.ClaimSubBatch(ctx, old.OperationID, "old", time.Now()); err != nil {
t.Fatal(err)
}
_, _ = repo.database().Exec(ctx, "INSERT INTO batch_detail(id,batch_no,detail_status)VALUES(4,'S-2',1)")
newer := testSub("S-2", "AB", "a", []int64{3, 4}, []string{"A", "B"})
if err := repo.PrepareSubBatch(ctx, newer); err != nil {
t.Fatal(err)
}
if got, err := repo.ClaimSubBatch(ctx, newer.OperationID, "new", time.Now()); err != nil || got != nil {
t.Fatalf("阻塞组竟领取: %+v %v", got, err)
}
row, _ := repo.database().GetOne(ctx, "SELECT active_operation_id FROM data_operation_card_claim WHERE iccid='A'")
if row["active_operation_id"].String() != "" {
t.Fatal("部分卡占active产生死锁")
}
_, _ = repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET created_at=?,next_check_at=NULL WHERE operation_id=?", time.Now().Add(-16*time.Minute), newer.OperationID)
if _, err := repo.ClaimSubBatch(ctx, newer.OperationID, "deadline", time.Now()); err != nil {
t.Fatal(err)
}
row, _ = repo.database().GetOne(ctx, "SELECT status FROM data_operation_sub_batch WHERE operation_id=?", newer.OperationID)
if row["status"].String() != "review" {
t.Fatal("在途阻塞超过窗口仍永久Running")
}
}
func TestSubBatchRecoveryWindowIsFair(t *testing.T) {
repo, ctx := testSubBatchDB(t)
for i := 0; i < 35; i++ {
no := fmt.Sprintf("R-%02d", i)
if _, err := repo.database().Exec(ctx, "INSERT INTO batch_main(id,batch_no,batch_type,batch_async_status,tenant_id)VALUES(?,?,109,2,'tenant')", 100+i, no); err != nil {
t.Fatal(err)
}
if _, err := repo.database().Exec(ctx, "INSERT INTO data_operation_parent(batch_no,ready)VALUES(?,0)", no); err != nil {
t.Fatal(err)
}
}
seen := map[string]bool{}
for i := 0; i < 3; i++ {
ids, err := repo.ListRecoverableBatches(ctx, 16)
if err != nil {
t.Fatal(err)
}
for _, id := range ids {
seen[id] = true
}
}
for i := 0; i < 35; i++ {
if !seen[fmt.Sprintf("R-%02d", i)] {
t.Fatalf("固定恢复窗口饥饿: %d", i)
}
}
}
type durableGenerator struct {
calls atomic.Int32
create func(context.Context, batchresult.Request) (batchresult.Artifact, error)
request batchresult.Request
}
func (f *durableGenerator) Create(ctx context.Context, request batchresult.Request) (batchresult.Artifact, error) {
n := f.calls.Add(1)
f.request = request
if f.create != nil {
return f.create(ctx, request)
}
return batchresult.Artifact{FileName: fmt.Sprintf("%s-%d.xlsx", request.BatchNo, n), OriginalName: request.BatchNo + ".xlsx"}, nil
}
func TestSubBatchProgressIsMonotonicAndMissingCardSafe(t *testing.T) {
repo, ctx := testSubBatchDB(t)
_, _ = repo.database().Exec(ctx, "UPDATE batch_detail SET item_id=CASE id WHEN 1 THEN 'A' ELSE 'B' END,snapshot='{\"authorized\":true,\"statusBefore\":1}' WHERE batch_no='S-1'")
_, _ = repo.database().Exec(ctx, "INSERT INTO t_sim_card_info(id,tenant_id,iccid,data_service_status)VALUES(1,'tenant','A',1)")
b := testSub("S-1", "partial", "a", []int64{1, 2}, []string{"A", "B"})
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
claimed, err := repo.ClaimSubBatch(ctx, b.OperationID, "first", time.Now())
if err != nil || claimed == nil {
t.Fatalf("claim: %v %v", claimed, err)
}
claimed.JobID = "job-partial"
if err := repo.SaveSubBatchProgress(ctx, *claimed, []service.Outcome{{Success: true, OrderNum: "order-A"}, {Pending: true}}); err != nil {
t.Fatal(err)
}
row, _ := repo.database().GetOne(ctx, "SELECT job_id FROM data_operation_sub_batch WHERE operation_id=?", b.OperationID)
if row["job_id"].String() != "job-partial" {
t.Fatal("jobId未随部分结果落库")
}
// A已明确成功,后续缺失不得覆盖;B无卡实体也不能panic丢掉整组结果。
if err := repo.SaveSubBatchProgress(ctx, *claimed, []service.Outcome{{Pending: true}, {Success: false, Code: 42, Message: "明确拒绝"}}); err != nil {
t.Fatal(err)
}
if err := repo.UpdateSubBatch(ctx, *claimed, "review", claimed.JobID, "窗口结束", time.Time{}, 4); err != nil {
t.Fatal(err)
}
rows, err := repo.database().GetAll(ctx, "SELECT detail_status,error_reason FROM batch_detail WHERE batch_no='S-1' ORDER BY id")
if err != nil {
t.Fatal(err)
}
if rows[0]["detail_status"].Int() != consts.BatchDetailStatusSuccess || rows[1]["detail_status"].Int() != consts.BatchDetailStatusFailed || rows[1]["error_reason"].String() != "明确拒绝" {
t.Fatalf("review覆盖逐卡终态: %v", rows)
}
gen := &durableGenerator{}
repo.resultFiles = gen
if err := repo.FinalizeReadyBatch(ctx, "S-1"); err != nil {
t.Fatal(err)
}
if len(gen.request.Rows) != 2 || !gen.request.Rows[0].Success || gen.request.Rows[1].Success {
t.Fatalf("真实DB导出接线错误: %+v", gen.request)
}
}
func TestSubBatchUnknownOnlyChangesUnconfirmedRows(t *testing.T) {
repo, ctx := testSubBatchDB(t)
_, _ = repo.database().Exec(ctx, "UPDATE batch_detail SET item_id=CASE id WHEN 1 THEN 'A' ELSE 'B' END,snapshot='{\"authorized\":true}' WHERE batch_no='S-1'")
b := testSub("S-1", "unknown", "a", []int64{1, 2}, []string{"A", "B"})
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
claimed, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner", time.Now())
if err != nil {
t.Fatal(err)
}
if err := repo.SaveSubBatchProgress(ctx, *claimed, []service.Outcome{{Success: true, OrderNum: "confirmed"}, {Pending: true}}); err != nil {
t.Fatal(err)
}
if err := repo.UpdateSubBatch(ctx, *claimed, "review", "job", "具体超时原因", time.Time{}, 4); err != nil {
t.Fatal(err)
}
gen := &durableGenerator{}
repo.resultFiles = gen
if err := repo.FinalizeReadyBatch(ctx, "S-1"); err != nil {
t.Fatal(err)
}
main, _ := repo.database().GetOne(ctx, "SELECT batch_async_status,success_count,fail_count,valid_count,batch_info FROM batch_main WHERE batch_no='S-1'")
if main["batch_async_status"].Int() != consts.BatchAsyncStatusReview || main["success_count"].Int() != 1 || main["fail_count"].Int() != 0 || main["valid_count"].Int() != 2 {
t.Fatalf("未知误计失败或清零有效数: %v", main)
}
if len(gen.request.Rows) != 2 || gen.request.Rows[1].DetailStatus != consts.BatchDetailStatusUnknown || gen.request.Rows[1].Reason != "具体超时原因" {
t.Fatalf("待核查导出原因丢失: %+v", gen.request)
}
}
func TestSubBatchZeroChildrenFinalizeLeaseRecoveryAndConcurrency(t *testing.T) {
repo, ctx := testSubBatchDB(t)
_, _ = repo.database().Exec(ctx, "UPDATE batch_detail SET detail_status=3,error_reason='校验不通过' WHERE batch_no='S-1'")
started := make(chan struct{})
release := make(chan struct{})
gen := &durableGenerator{create: func(context.Context, batchresult.Request) (batchresult.Artifact, error) {
close(started)
<-release
return batchresult.Artifact{FileName: "unique.xlsx"}, nil
}}
repo.resultFiles = gen
var wg sync.WaitGroup
wg.Add(1)
var firstErr error
go func() { defer wg.Done(); firstErr = repo.FinalizeReadyBatch(ctx, "S-1") }()
<-started
if err := repo.FinalizeReadyBatch(ctx, "S-1"); err != nil {
t.Fatal(err)
}
close(release)
wg.Wait()
if firstErr != nil {
t.Fatal(firstErr)
}
if gen.calls.Load() != 1 {
t.Fatal("并发最终化重复生成")
}
n, _ := repo.database().Model("sys_oss").Ctx(ctx).Count()
if n != 1 {
t.Fatal("重复发布OSS")
}
n, _ = repo.database().Model("t_resource_batch_download").Ctx(ctx).Count()
if n != 1 {
t.Fatal("重复下载记录")
}
// 真正零明细父批,以及前任已过期租约应能恢复。
_, _ = repo.database().Exec(ctx, "DELETE FROM batch_detail WHERE batch_no='S-2'")
_, _ = repo.database().Exec(ctx, "UPDATE data_operation_parent SET finalize_token='crashed',finalize_until=? WHERE batch_no='S-2'", time.Now().Add(-time.Minute))
repo.resultFiles = &durableGenerator{}
if err := repo.FinalizeReadyBatch(ctx, "S-2"); err != nil {
t.Fatalf("零明细/过期租约未恢复: %v", err)
}
row, _ := repo.database().GetOne(ctx, "SELECT batch_async_status FROM batch_main WHERE batch_no='S-2'")
if row["batch_async_status"].Int() == consts.BatchAsyncStatusProcessing {
t.Fatal("零子批不收尾")
}
}
func TestSubBatchFinalizeFailureReleasesLease(t *testing.T) {
repo, ctx := testSubBatchDB(t)
_, _ = repo.database().Exec(ctx, "UPDATE batch_detail SET detail_status=3 WHERE batch_no='S-1'")
repo.resultFiles = &durableGenerator{create: func(context.Context, batchresult.Request) (batchresult.Artifact, error) {
return batchresult.Artifact{}, errors.New("模拟文件生成失败")
}}
if err := repo.FinalizeReadyBatch(ctx, "S-1"); err == nil {
t.Fatal("应暴露生成失败")
}
repo.resultFiles = &durableGenerator{}
if err := repo.FinalizeReadyBatch(ctx, "S-1"); err != nil {
t.Fatal(err)
}
}
func TestSubBatchExplicitFailureReleasesActiveForReverse(t *testing.T) {
repo, ctx := testSubBatchDB(t)
_, _ = repo.database().Exec(ctx, "UPDATE batch_detail SET item_id='A' WHERE id=1")
old := testSub("S-1", "failed-old", "a", []int64{1}, []string{"A"})
if err := repo.PrepareSubBatch(ctx, old); err != nil {
t.Fatal(err)
}
claimed, err := repo.ClaimSubBatch(ctx, old.OperationID, "owner", time.Now())
if err != nil || claimed == nil {
t.Fatalf("claim: %+v %v", claimed, err)
}
logMySQLLease(t, repo, claimed.ID, claimed.LeaseToken)
if err := repo.CompleteSubBatch(ctx, *claimed, []service.Outcome{{Code: 9999, Message: "任务状态4,已完成且明确失败"}}); err != nil {
t.Fatal(err)
}
row, _ := repo.database().GetOne(ctx, "SELECT detail_status FROM batch_detail WHERE id=1")
if row["detail_status"].Int() != consts.BatchDetailStatusFailed {
t.Fatal("明确9999未持久终态")
}
row, _ = repo.database().GetOne(ctx, "SELECT active_operation_id FROM data_operation_card_claim WHERE iccid='A'")
if row["active_operation_id"].String() != "" {
t.Fatal("明确失败仍保留active")
}
reverse := testSub("S-2", "reverse-after-failed", "a", []int64{3}, []string{"A"})
if err := repo.PrepareSubBatch(ctx, reverse); err != nil {
t.Fatal(err)
}
if got, err := repo.ClaimSubBatch(ctx, reverse.OperationID, "reverse", time.Now()); err != nil || got == nil {
t.Fatalf("明确失败后反向被阻塞: %+v %v", got, err)
}
}
func TestSubBatchTypedUnsentReleasesOnlyOwnActiveAndRestoresPrepared(t *testing.T) {
repo, ctx := testSubBatchDB(t)
b := testSub("S-1", "unsent", "a", []int64{1, 2}, []string{"A", "B"})
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
claimed, err := repo.ClaimSubBatch(ctx, b.OperationID, "first", time.Now())
if err != nil || claimed == nil {
t.Fatalf("%+v %v", claimed, err)
}
if err := repo.ReleaseUnsentSubBatch(ctx, *claimed, "第二次能力探测拒绝"); err != nil {
t.Fatal(err)
}
row, _ := repo.database().GetOne(ctx, "SELECT status,submitted_at,poll_count,lease_token FROM data_operation_sub_batch WHERE operation_id=?", b.OperationID)
if row["status"].String() != "prepared" || !row["submitted_at"].IsNil() || row["poll_count"].Int() != 0 || row["lease_token"].String() != "" {
t.Fatalf("回退prepared失败: %v", row)
}
n, _ := repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id", b.OperationID).Count()
if n != 0 {
t.Fatal("未发送仍占active")
}
if err := repo.ReleaseUnsentSubBatch(ctx, *claimed, "陈旧回退"); err == nil {
t.Fatal("陈旧lease可重放回退")
}
_, _ = repo.database().Exec(ctx, "UPDATE data_operation_sub_batch SET next_check_at=NULL WHERE operation_id=?", b.OperationID)
retry, err := repo.ClaimSubBatch(ctx, b.OperationID, "retry", time.Now())
if err != nil || retry == nil || retry.Status != "prepared" {
t.Fatalf("回退后无法唯一首发: %+v %v", retry, err)
}
if err := repo.UpdateSubBatch(ctx, *retry, "accepted", "job", "已受理", time.Now(), 0); err != nil {
t.Fatal(err)
}
query, err := repo.ClaimSubBatch(ctx, b.OperationID, "query", time.Now().Add(time.Second))
if err != nil || query == nil {
t.Fatalf("%+v %v", query, err)
}
submitted := query.SubmittedAt
if err := repo.ReleaseUnsentSubBatch(ctx, *query, "查询探测拒绝"); err != nil {
t.Fatal(err)
}
stored, err := repo.GetSubBatch(ctx, b.OperationID)
if err != nil || stored.PollCount != 0 || stored.Status != "accepted" || !stored.SubmittedAt.Equal(submitted) {
t.Fatalf("未发查询消耗轮次或丢首发时点: %+v %v", stored, err)
}
n, _ = repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id", b.OperationID).Count()
if n != 2 {
t.Fatal("已真实提交的active被查询门禁释放")
}
}
type prepareOnlyAPN struct{}
func (prepareOnlyAPN) ChangeSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return nil, errors.New("准备阶段不得调用上游")
}
func (prepareOnlyAPN) SubmitSimApnFunction(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return nil, errors.New("准备阶段不得首发")
}
func (prepareOnlyAPN) QuerySimApnFunctionResult(context.Context, *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
return nil, errors.New("准备阶段不得补查")
}
func TestSubBatchCutoffDoesNotSkipDisabledCardWithActiveOpen(t *testing.T) {
repo, ctx := testSubBatchDB(t)
if _, err := repo.database().Exec(ctx, "UPDATE batch_main SET batch_type=109 WHERE batch_no='S-1'"); err != nil {
t.Fatal(err)
}
if _, err := repo.database().Exec(ctx, "UPDATE batch_main SET batch_type=107 WHERE batch_no='S-2'"); err != nil {
t.Fatal(err)
}
if _, err := repo.database().Exec(ctx, "UPDATE batch_detail SET batch_type=107,item_id='A',snapshot='{\"authorized\":true}' WHERE id=3"); err != nil {
t.Fatal(err)
}
if _, err := repo.database().Exec(ctx, "INSERT INTO t_sim_card_info(id,tenant_id,iccid,chan_key,data_service_status)VALUES(1,'tenant','A','a',?)", consts.SimDataServiceStatusDisabled); err != nil {
t.Fatal(err)
}
old := testSub("S-1", "opening", "a", []int64{1}, []string{"A"})
old.BatchType = 109
old.Request.ReqData.OperType = consts.SimApnOperTypeOpen
if err := repo.PrepareSubBatch(ctx, old); err != nil {
t.Fatal(err)
}
if got, err := repo.ClaimSubBatch(ctx, old.OperationID, "open-owner", time.Now()); err != nil || got == nil {
t.Fatalf("开通领取: %+v %v", got, err)
}
if _, err := repo.database().Exec(ctx, "UPDATE data_operation_parent SET ready=0 WHERE batch_no='S-2'"); err != nil {
t.Fatal(err)
}
if err := service.NewExecutor(repo, prepareOnlyAPN{}).Execute(ctx, "S-2"); err != nil {
t.Fatal(err)
}
row, err := repo.database().GetOne(ctx, "SELECT operation_id,status FROM data_operation_sub_batch WHERE batch_no='S-2'")
if err != nil || len(row) == 0 {
t.Fatalf("本地disabled绕过冻结: %v %v", row, err)
}
detail, _ := repo.database().GetOne(ctx, "SELECT detail_status FROM batch_detail WHERE id=3")
if detail["detail_status"].Int() != consts.BatchDetailStatusRunning {
t.Fatal("开在途时达量停被提前成功")
}
if got, err := repo.ClaimSubBatch(ctx, row["operation_id"].String(), "cutoff", time.Now()); err != nil || got != nil {
t.Fatalf("达量停绕过前序active: %+v %v", got, err)
}
}
func TestSubBatchLegacyProcessingWithoutParentNeverCreatesIntent(t *testing.T) {
repo, ctx := testSubBatchDB(t)
if _, err := repo.database().Exec(ctx, "DELETE FROM data_operation_parent WHERE batch_no='S-1'"); err != nil {
t.Fatal(err)
}
ids, err := repo.ListRecoverableBatches(ctx, 128)
if err != nil {
t.Fatal(err)
}
for _, id := range ids {
if id == "S-1" {
t.Fatal("恢复扫描捡起无凭据旧批")
}
}
called := false
if err := repo.ResumeOrPrepare(ctx, "S-1", func(context.Context) error { called = true; return nil }); err == nil || called {
t.Fatalf("旧批进入准备/提交: called=%t err=%v", called, err)
}
if err := repo.ProtectExecutionFailure(ctx, "S-1", "旧MQ重投失败"); err != nil {
t.Fatal(err)
}
if exists, err := repo.HasDurableParent(ctx, "S-1"); err != nil || exists {
t.Fatalf("失败保护或扫描凭空补凭据: exists=%t err=%v", exists, err)
}
n, err := repo.database().Model(subTable).Ctx(ctx).Where("batch_no", "S-1").Count()
if err != nil || n != 0 {
t.Fatalf("旧批产生新提交意图: %d %v", n, err)
}
}
func TestSubBatchDetailAndCardClaimProtectNewerOperation(t *testing.T) {
repo, ctx := testSubBatchDB(t)
older := testSub("S-1", "intent-old", "a", []int64{1}, []string{"A"})
newer := testSub("S-2", "intent-new", "a", []int64{3}, []string{"A"})
if err := repo.PrepareSubBatch(ctx, older); err != nil {
t.Fatal(err)
}
if err := repo.PrepareSubBatch(ctx, newer); err != nil {
t.Fatal(err)
}
claim, err := repo.database().GetOne(ctx, "SELECT batch_id,operation_id FROM data_operation_card_claim WHERE tenant_id='tenant' AND iccid='A'")
if err != nil {
t.Fatal(err)
}
if claim["batch_id"].Int() != 11 || claim["operation_id"].String() != newer.OperationID {
t.Fatalf("后续反向操作claim未生效: %+v", claim)
}
// Even with a differently named intent, the same detail cannot join another submission.
duplicate := testSub("S-1", "intent-duplicate", "a", []int64{1}, []string{"A"})
if err := repo.PrepareSubBatch(ctx, duplicate); err == nil {
t.Fatal("同一明细重新归属另一提交意图应失败")
}
count, err := repo.database().GetOne(ctx, "SELECT COUNT(*) AS n FROM data_operation_sub_batch WHERE batch_no='S-1'")
if err != nil {
t.Fatal(err)
}
if count["n"].Int() != 1 {
t.Fatalf("失败准备未回滚: %+v", count)
}
}
// 并发的两次手动补查(不同 operationId)不得互相阻塞退回:
// 在途 review_query 子批不是“未终结”,否则两边都会以为对方还没完成而同时放弃退回。
func TestSubBatchReviewParentReturnsDespiteInFlightManualQuery(t *testing.T) {
repo, ctx := testSubBatchDB(t)
inflight := testSub("S-1", "inflight", "a", []int64{1}, []string{"A"})
settle := testSub("S-1", "settle", "a", []int64{2}, []string{"B"})
for _, sub := range []service.SubBatch{inflight, settle} {
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
t.Fatal(err)
}
}
first, err := repo.ClaimSubBatch(ctx, inflight.OperationID, "owner-a", time.Now())
if err != nil || first == nil {
t.Fatalf("claim A: %+v %v", first, err)
}
if err := repo.UpdateSubBatch(ctx, *first, "review", "job-a", "补查窗口耗尽", time.Time{}, 4); err != nil {
t.Fatal(err)
}
second, err := repo.ClaimSubBatch(ctx, settle.OperationID, "owner-b", time.Now())
if err != nil || second == nil {
t.Fatalf("claim B: %+v %v", second, err)
}
if _, err := repo.database().Exec(ctx, "UPDATE batch_main SET batch_async_status=? WHERE batch_no='S-1'", consts.BatchAsyncStatusReview); err != nil {
t.Fatal(err)
}
// A 处于已提交的在途补查状态;B 此时写回终态,仍必须能退回处理中。
if manual, err := repo.ClaimReviewSubBatch(ctx, inflight.OperationID, "manual", time.Now()); err != nil || manual == nil {
t.Fatalf("A 在途补查领取失败: %+v %v", manual, err)
}
if err := repo.UpdateSubBatch(ctx, *second, "done", "job-b", "", time.Time{}, second.PollCount); err != nil {
t.Fatal(err)
}
main, _ := repo.database().GetOne(ctx, "SELECT batch_async_status FROM batch_main WHERE batch_no='S-1'")
if main["batch_async_status"].Int() != consts.BatchAsyncStatusProcessing {
t.Fatalf("在途补查阻塞了退回,父批将永久挂待核查: %v", main)
}
}
// 子批全部终结后父批必须能回到恢复扫描重新汇总:
// 修复“手动补查已把子批写成终态,但汇总失败/进程退出后父批永久挂待核查”的死角,
// 同时保证合法停查的批次不会因此被反复捞起空转。
func TestSubBatchReviewParentReentersRecoveryOnceSettled(t *testing.T) {
repo, ctx := testSubBatchDB(t)
_, _ = repo.database().Exec(ctx, "UPDATE batch_detail SET item_id=CASE id WHEN 1 THEN 'A' ELSE 'B' END,snapshot='{\"authorized\":true}' WHERE batch_no='S-1'")
b := testSub("S-1", "settle", "a", []int64{1, 2}, []string{"A", "B"})
if err := repo.PrepareSubBatch(ctx, b); err != nil {
t.Fatal(err)
}
claimed, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner", time.Now())
if err != nil || claimed == nil {
t.Fatalf("claim: %+v %v", claimed, err)
}
if err := repo.UpdateSubBatch(ctx, *claimed, "review", "job-1", "补查窗口耗尽", time.Time{}, 4); err != nil {
t.Fatal(err)
}
// 模拟此前一次汇总已把父批停在待核查。
if _, err := repo.database().Exec(ctx, "UPDATE batch_main SET batch_async_status=? WHERE batch_no='S-1'", consts.BatchAsyncStatusReview); err != nil {
t.Fatal(err)
}
// 手动同步领取并写回终态:这正是汇总失败/进程退出前最后一步。
manual, err := repo.ClaimReviewSubBatch(ctx, b.OperationID, "manual", time.Now())
if err != nil || manual == nil {
t.Fatalf("手动补查领取失败: %+v %v", manual, err)
}
if err := repo.UpdateSubBatch(ctx, *manual, "done", "job-1", "", time.Time{}, manual.PollCount); err != nil {
t.Fatal(err)
}
main, _ := repo.database().GetOne(ctx, "SELECT batch_async_status FROM batch_main WHERE batch_no='S-1'")
if main["batch_async_status"].Int() != consts.BatchAsyncStatusProcessing {
t.Fatalf("终态后未退回处理中,父批将永久挂待核查: %v", main)
}
if !recoverableContains(t, repo, ctx, "S-1") {
t.Fatal("退回处理中后仍未被恢复扫描领取,无法自动汇总")
}
// 汇总成功后若仍有未知明细,父批回到待核查且不再被捞起,避免每 5 秒空转生成结果文件。
repo.resultFiles = &durableGenerator{}
if err := repo.FinalizeReadyBatch(ctx, "S-1"); err != nil {
t.Fatal(err)
}
main, _ = repo.database().GetOne(ctx, "SELECT batch_async_status FROM batch_main WHERE batch_no='S-1'")
if main["batch_async_status"].Int() != consts.BatchAsyncStatusReview {
t.Fatalf("待核查明细应仍停在待核查: %v", main)
}
if recoverableContains(t, repo, ctx, "S-1") {
t.Fatal("合法待核查批次被反复纳入恢复扫描,将导致空转汇总")
}
}
func recoverableContains(t *testing.T, repo *Repository, ctx context.Context, batchNo string) bool {
t.Helper()
ids, err := repo.ListRecoverableBatches(ctx, 128)
if err != nil {
t.Fatal(err)
}
for _, id := range ids {
if id == batchNo {
return true
}
}
return false
}
// 手动同步只能看到该父批下仍待核查的子批,且不会因为已完成的子批而误触发。
func TestSubBatchListReviewSubBatchesForManualSync(t *testing.T) {
repo, ctx := testSubBatchDB(t)
review := testSub("S-1", "intent-review", "a", []int64{1}, []string{"A"})
done := testSub("S-1", "intent-done", "a", []int64{2}, []string{"B"})
other := testSub("S-2", "intent-other", "a", []int64{3}, []string{"C"})
for _, sub := range []service.SubBatch{review, done, other} {
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
t.Fatal(err)
}
claimed, err := repo.ClaimSubBatch(ctx, sub.OperationID, "owner-"+sub.OperationID, time.Now())
if err != nil || claimed == nil {
t.Fatalf("claim失败 operationId=%s err=%v", sub.OperationID, err)
}
status, jobID, reason := "review", "", "补查窗口耗尽,结果未知"
if sub.OperationID == done.OperationID {
status, jobID, reason = "done", "job-done", ""
}
if err := repo.UpdateSubBatch(ctx, *claimed, status, jobID, reason, time.Time{}, 4); err != nil {
t.Fatal(err)
}
}
ids, err := repo.ListReviewSubBatches(ctx, "S-1")
if err != nil {
t.Fatal(err)
}
if len(ids) != 1 || ids[0] != review.OperationID {
t.Fatalf("手动同步候选错误: %+v", ids)
}
// 租约未过期的在途补查不能被第二个请求抢走。
if _, err := repo.database().Exec(ctx,
"UPDATE data_operation_sub_batch SET status='review_query', lease_token='owner', lease_until=? WHERE operation_id=?",
time.Now().Add(time.Minute), review.OperationID,
); err != nil {
t.Fatal(err)
}
if ids, err = repo.ListReviewSubBatches(ctx, "S-1"); err != nil || len(ids) != 0 {
t.Fatalf("在途补查不应进入手动同步: %+v err=%v", ids, err)
}
// 租约过期后允许恢复,避免卡在 review_query 永不收敛。
if _, err := repo.database().Exec(ctx,
"UPDATE data_operation_sub_batch SET lease_until=? WHERE operation_id=?",
time.Now().Add(-time.Minute), review.OperationID,
); err != nil {
t.Fatal(err)
}
if ids, err = repo.ListReviewSubBatches(ctx, "S-1"); err != nil || len(ids) != 1 || ids[0] != review.OperationID {
t.Fatalf("过期租约应可恢复: %+v err=%v", ids, err)
}
}
+11 -11
View File
@@ -127,8 +127,8 @@ func TestApplyBatchMixedGroups(t *testing.T) {
repo := NewRepository()
if err := repo.ApplyBatch(ctx, []Result{
{CardID: 1, Synced: true, ValueChanged: true, Imei: "865466070817587"}, // 值变更
{CardID: 2, Synced: true, ValueChanged: false}, // 心跳
{CardID: 3, Synced: false}, // 失败跳过
{CardID: 2, Synced: true, ValueChanged: false}, // 心跳
{CardID: 3, Synced: false}, // 失败跳过
{CardID: 4, Synced: true, ValueChanged: true, Imei: "111222333444555"}, // 从空到有值
}); err != nil {
t.Fatalf("ApplyBatch: %v", err)
@@ -156,12 +156,12 @@ func TestApplyBatchRejectsInvalidImeiValue(t *testing.T) {
repo := NewRepository()
malicious := []string{
"865466070817587'", // 引号逃逸
"865466070817587'; DROP TABLE x; --", // 注入尝试
"865466070817587 OR 1=1", // 逻辑注入
"abc", // 非数字
"865466070817587;999999999999999", // 分号拼接(本链路不支持多值)
strings.Repeat("9", 33), // 超长
"865466070817587'", // 引号逃逸
"865466070817587'; DROP TABLE x; --", // 注入尝试
"865466070817587 OR 1=1", // 逻辑注入
"abc", // 非数字
"865466070817587;999999999999999", // 分号拼接(本链路不支持多值)
strings.Repeat("9", 33), // 超长
}
for _, value := range malicious {
err := repo.ApplyBatch(ctx, []Result{
@@ -185,9 +185,9 @@ func TestApplyBatchAcceptsBoundaryLengths(t *testing.T) {
repo := NewRepository()
values := []string{
"865466070817587", // 15 位
"86546607081758712", // 17 位
strings.Repeat("9", 32), // varchar(32) 上限
"865466070817587", // 15 位
"86546607081758712", // 17 位
strings.Repeat("9", 32), // varchar(32) 上限
}
for i, value := range values {
cardID := int64(i + 1)
+55 -6
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
@@ -14,6 +15,7 @@ import (
"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/batchfields"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
@@ -385,10 +387,23 @@ func (r *Repository) findExistingStocksByField(ctx context.Context, field, label
}
func (r *Repository) SaveValidationFailure(ctx context.Context, task *inbound.Task) error {
return r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
err := r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
batchInfo := buildInboundBatchInfo(task)
task.Batch.BatchInfo = batchInfo
details := buildBatchDetails(task, consts.BatchDetailStatusFailed)
failCount := 0
detailIndex := 0
for _, row := range task.Rows {
if row == nil {
continue
}
if inbound.IsCascadeFailure(row) {
details[detailIndex].DetailStatus = consts.BatchDetailStatusNotExecuted
} else {
failCount++
}
detailIndex++
}
if err := insertChunkedRows(ctx, tx, dao.BatchDetail.Table(), "inbound detail", details, inboundWriteBatchSize(ctx)); err != nil {
return err
}
@@ -397,14 +412,38 @@ func (r *Repository) SaveValidationFailure(ctx context.Context, task *inbound.Ta
TotalCount: len(task.Rows),
ValidCount: task.ValidCount,
SuccessCount: 0,
FailCount: len(task.Rows),
FailCount: failCount,
RunResult: consts.BatchRunResultFailed,
FailReason: task.FailureReason,
FailReason: batchfields.FailureReason(task.FailureReason),
BatchInfo: batchInfo,
EndTime: gtime.Now(),
UpdatedBy: task.Batch.CreatedBy,
})
})
if err == nil {
return nil
}
g.Log().Errorf(ctx, "save inbound validation details failed, batchNo:%s, err:%s", task.Batch.BatchNo, batchfields.FailureReason(err.Error()))
finalizationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
fallbackErr := r.database().Transaction(finalizationCtx, func(ctx context.Context, tx gdb.TX) error {
return updateBatchMain(ctx, tx, task, do.BatchMain{
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
TotalCount: len(task.Rows),
ValidCount: task.ValidCount,
SuccessCount: 0,
FailCount: len(task.Rows),
RunResult: consts.BatchRunResultFailed,
FailReason: batchfields.FailureReason("错误明细保存失败,本次整批未入库"),
BatchInfo: task.Batch.BatchInfo,
EndTime: gtime.Now(),
UpdatedBy: task.Batch.CreatedBy,
})
})
if fallbackErr != nil {
return gerror.Wrapf(fallbackErr, "save inbound failure details failed: %v; finalize batch failed", err)
}
return nil
}
func (r *Repository) SaveSuccess(ctx context.Context, task *inbound.Task) error {
@@ -481,8 +520,8 @@ func batchDetailRow(task *inbound.Task, row *inbound.Row, status int) do.BatchDe
DetailStatus: status,
BlankOutStatus: 1,
RowNo: row.RowNo,
ItemId: row.ICCID,
ErrorReason: row.Reason,
ItemId: batchfields.ItemID(row.ICCID),
ErrorReason: batchfields.DetailReason(row.Reason),
Snapshot: rowSnapshot(row),
TenantId: task.Batch.TenantID,
CreatedDept: task.Batch.CreatedDept,
@@ -526,7 +565,7 @@ func (r *Repository) SaveResultFile(ctx context.Context, task *inbound.Task, req
ExportScene: task.Batch.BatchType,
TotalCount: len(task.Rows),
SuccessCount: successRows(task.Rows),
FailCount: len(task.Rows) - successRows(task.Rows),
FailCount: failedRows(task.Rows),
DownloadStatus: consts.DownloadStatusGenerated,
OssId: ossID,
FileName: artifact.FileName,
@@ -863,3 +902,13 @@ func successRows(rows []*inbound.Row) int {
}
return count
}
func failedRows(rows []*inbound.Row) int {
count := 0
for _, row := range rows {
if row != nil && row.Reason != "" && !inbound.IsCascadeFailure(row) {
count++
}
}
return count
}
+83 -3
View File
@@ -1,16 +1,96 @@
package inbound
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
serviceinbound "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/inbound"
_ "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/frame/g"
"github.com/gogf/gf/v2/os/gcfg"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/batchfields"
serviceinbound "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/inbound"
)
func TestBatchDetailRowClampsInvalidIdentifiersAndReasons(t *testing.T) {
task := &serviceinbound.Task{Batch: serviceinbound.Batch{BatchNo: "INB-TEST", BatchType: 103}}
row := &serviceinbound.Row{RowNo: 2, ICCID: strings.Repeat("9", 100), Reason: strings.Repeat("错", 1000)}
detail := batchDetailRow(task, row, consts.BatchDetailStatusFailed)
if detail.ItemId != "" || len([]rune(detail.ErrorReason.(string))) != batchfields.DetailReasonLimit {
t.Fatalf("invalid detail: itemID=%v, reason=%v", detail.ItemId, detail.ErrorReason)
}
if row.ICCID != strings.Repeat("9", 100) || row.Reason != strings.Repeat("错", 1000) {
t.Fatal("source row was mutated; full value must remain available to result export")
}
}
func TestSaveValidationFailureHandlesOversizedDetailsAndFallback(t *testing.T) {
ctx := context.Background()
oldAdapter := g.Cfg().GetAdapter()
adapter, err := gcfg.NewAdapterContent("batch:\n inbound:\n writeBatchSize: 1000\n")
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
defer g.Cfg().SetAdapter(oldAdapter)
for _, test := range []struct {
name string
constraint string
wantFallback bool
}{
{name: "oversized invalid row is saved", constraint: "CHECK(length(item_id) <= 64 AND length(error_reason) <= 512)"},
{name: "detail write fails", constraint: "CHECK(error_reason = 'allowed')", wantFallback: true},
} {
t.Run(test.name, func(t *testing.T) {
db, err := gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close(ctx) })
statements := []string{
"CREATE TABLE batch_main (batch_no TEXT PRIMARY KEY, batch_type INTEGER, batch_async_status INTEGER, total_count INTEGER, valid_count INTEGER, success_count INTEGER, fail_count INTEGER, run_result INTEGER, fail_reason TEXT, batch_info TEXT, end_time DATETIME, updated_by INTEGER)",
"CREATE TABLE batch_detail (id INTEGER PRIMARY KEY AUTOINCREMENT, batch_no TEXT, batch_type INTEGER, detail_status INTEGER, blank_out_status INTEGER, row_no INTEGER, item_id TEXT, error_reason TEXT " + test.constraint + ", snapshot TEXT, tenant_id TEXT, created_dept INTEGER, created_by INTEGER, updated_by INTEGER)",
"INSERT INTO batch_main (batch_no, batch_type, batch_async_status) VALUES ('INB-TEST', 103, 2)",
}
for _, statement := range statements {
if _, err := db.Exec(ctx, statement); err != nil {
t.Fatalf("fixture failed: %v", err)
}
}
task := &serviceinbound.Task{
Batch: serviceinbound.Batch{BatchNo: "INB-TEST", BatchType: 103},
Rows: []*serviceinbound.Row{{RowNo: 2, ICCID: strings.Repeat("9", 100), Reason: strings.Repeat("错", 1000)}},
FailureReason: "输入格式不正确",
}
if err := (&Repository{db: db}).SaveValidationFailure(ctx, task); err != nil {
t.Fatalf("failed batch must be finalized: %v", err)
}
status, err := db.Model("batch_main").Ctx(ctx).Where("batch_no", "INB-TEST").Value("batch_async_status")
if err != nil || status.Int() != consts.BatchAsyncStatusFailed {
t.Fatalf("status = %v, err = %v", status, err)
}
reason, err := db.Model("batch_main").Ctx(ctx).Where("batch_no", "INB-TEST").Value("fail_reason")
if err != nil || strings.Contains(reason.String(), "错误明细保存失败") != test.wantFallback {
t.Fatalf("fallback reason = %v, err = %v", reason, err)
}
detailCount, err := db.Model("batch_detail").Ctx(ctx).Where("batch_no", "INB-TEST").Count()
wantDetails := 1
if test.wantFallback {
wantDetails = 0
}
if err != nil || detailCount != wantDetails {
t.Fatalf("details = %d, want %d, err = %v", detailCount, wantDetails, err)
}
})
}
}
func TestResolveLocalOssPath(t *testing.T) {
uploadRoot := t.TempDir()
expectedPath := filepath.Join(uploadRoot, "000000", "user", "file", "20260721", "source.xlsx")
@@ -0,0 +1,70 @@
package lifecycle
import (
"fmt"
"sort"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
lifecycle "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/lifecycle"
)
// batchFailureSummary 生成中文失败汇总,替代原先的英文「N SIM cards failed」。
// 形如:共50张,成功28张,失败22张;主要失败原因:当前卡状态不支持该操作
func batchFailureSummary(total, success, fail int, reasons []string) string {
var builder strings.Builder
fmt.Fprintf(&builder, "共%d张,成功%d张,失败%d张", total, success, fail)
if reason := dominantReason(reasons); reason != "" {
builder.WriteString(";主要失败原因:")
builder.WriteString(reason)
}
return builder.String()
}
// dominantReason 取出现次数最多的失败原因;次数相同时按字典序,保证结果稳定可测。
func dominantReason(reasons []string) string {
counts := make(map[string]int, len(reasons))
for _, reason := range reasons {
trimmed := strings.TrimSpace(reason)
if trimmed == "" {
continue
}
counts[trimmed]++
}
if len(counts) == 0 {
return ""
}
keys := make([]string, 0, len(counts))
for key := range counts {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if counts[keys[i]] != counts[keys[j]] {
return counts[keys[i]] > counts[keys[j]]
}
return keys[i] < keys[j]
})
return keys[0]
}
// lifecycleFailureSummary 依据内存中的任务明细生成汇总。
func lifecycleFailureSummary(details []*lifecycle.Detail, success, fail int) string {
reasons := make([]string, 0, fail)
for _, detail := range details {
if detail != nil && strings.TrimSpace(detail.Reason) != "" {
reasons = append(reasons, detail.Reason)
}
}
return batchFailureSummary(len(details), success, fail, reasons)
}
// storedFailureSummary 依据已落库的批次明细生成汇总(重启批次的复机阶段使用)。
func storedFailureSummary(details []*entity.BatchDetail, success, fail int) string {
reasons := make([]string, 0, fail)
for _, detail := range details {
if detail != nil && strings.TrimSpace(detail.ErrorReason) != "" {
reasons = append(reasons, detail.ErrorReason)
}
}
return batchFailureSummary(len(details), success, fail, reasons)
}
@@ -0,0 +1,73 @@
package lifecycle
import (
"strings"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
lifecycle "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/lifecycle"
)
// TestBatchFailureSummaryUsesChineseAndCounts 固化用户要求的失败文案:
// 必须是中文、带成功失败张数,并附带最主要的失败原因。
func TestBatchFailureSummaryUsesChineseAndCounts(t *testing.T) {
got := batchFailureSummary(50, 28, 22, []string{
"当前卡状态不支持该操作",
"当前卡状态不支持该操作",
"请求上游超时",
})
want := "共50张,成功28张,失败22张;主要失败原因:当前卡状态不支持该操作"
if got != want {
t.Fatalf("失败汇总=%q,期望%q", got, want)
}
if strings.Contains(got, "SIM cards failed") {
t.Fatal("仍包含英文文案")
}
}
// TestBatchFailureSummaryWithoutReason 覆盖没有具体原因的情况,不能拼出空的原因段。
func TestBatchFailureSummaryWithoutReason(t *testing.T) {
if got := batchFailureSummary(3, 1, 2, nil); got != "共3张,成功1张,失败2张" {
t.Fatalf("无原因时=%q", got)
}
if got := batchFailureSummary(3, 1, 2, []string{"", " "}); got != "共3张,成功1张,失败2张" {
t.Fatalf("空白原因应被忽略,实际=%q", got)
}
}
// TestDominantReasonStableOnTie 同频时按字节序取最小值,保证同一批次文案稳定。
// 注意这里是字节序而非拼音序:「乙」的 UTF-8 编码小于「甲」,所以同频时取「乙原因」。
func TestDominantReasonStableOnTie(t *testing.T) {
if got := dominantReason([]string{"甲原因", "乙原因"}); got != "乙原因" {
t.Fatalf("同频应按字节序取最小,实际=%q", got)
}
if got := dominantReason([]string{"少数", "多数", "多数"}); got != "多数" {
t.Fatalf("应取出现次数最多,实际=%q", got)
}
}
// TestStoredFailureSummarySkipsNilAndEmpty 覆盖重启复机阶段:落库明细里的空原因与空指针不能参与统计。
func TestStoredFailureSummarySkipsNilAndEmpty(t *testing.T) {
details := []*entity.BatchDetail{
{DetailStatus: 2},
nil,
{DetailStatus: 3, ErrorReason: "当前卡状态不支持该操作"},
{DetailStatus: 3, ErrorReason: ""},
}
got := storedFailureSummary(details, 1, 2)
if !strings.HasPrefix(got, "共4张,成功1张,失败2张") || !strings.Contains(got, "当前卡状态不支持该操作") {
t.Fatalf("落库汇总=%q", got)
}
}
// TestLifecycleFailureSummaryUsesDetailReason 覆盖停复机首阶段:内存明细的 Reason 作为主要失败原因。
func TestLifecycleFailureSummaryUsesDetailReason(t *testing.T) {
details := []*lifecycle.Detail{
{Reason: ""},
{Reason: "停机或预销户卡,不能办理该业务"},
}
got := lifecycleFailureSummary(details, 1, 1)
if got != "共2张,成功1张,失败1张;主要失败原因:停机或预销户卡,不能办理该业务" {
t.Fatalf("首阶段汇总=%q", got)
}
}
+3 -3
View File
@@ -211,7 +211,7 @@ func (r *Repository) SaveResult(ctx context.Context, task *lifecycle.Task, detai
if resolved.Success {
if detail.Card == nil {
resolved.Success = false
resolved.Message = "SIM card record is missing"
resolved.Message = "卡记录不存在,未办理"
} else {
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
Where(dao.TSimCardInfo.Columns().Id, detail.Card.ID).
@@ -231,7 +231,7 @@ func (r *Repository) SaveResult(ctx context.Context, task *lifecycle.Task, detai
}
if affected != 1 {
resolved.Success = false
resolved.Message = "SIM card local status changed"
resolved.Message = "卡本地状态已变更"
}
}
}
@@ -276,7 +276,7 @@ func (r *Repository) Finalize(ctx context.Context, task *lifecycle.Task) error {
failCount := len(task.Details) - successCount
batchAsyncStatus, runResult := batchResult(successCount, failCount)
if task.FailureReason == "" && failCount > 0 {
task.FailureReason = strconv.Itoa(failCount) + " SIM cards failed"
task.FailureReason = lifecycleFailureSummary(task.Details, successCount, failCount)
}
validCount := 0
if task.ValidationOK {
+4 -4
View File
@@ -135,7 +135,7 @@ func (r *Repository) SaveRestartValidationFailure(ctx context.Context, task *lif
continue
}
if strings.TrimSpace(detail.Reason) == "" {
detail.Reason = "restart validation failed; upstream request was skipped"
detail.Reason = "重启校验未通过,未发起上游请求"
}
outcome := lifecycle.Outcome{Message: detail.Reason}
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
@@ -175,7 +175,7 @@ func (r *Repository) SaveRestartResult(ctx context.Context, task *lifecycle.Task
if resolved.Success {
if detail.Card == nil {
resolved.Success = false
resolved.Message = "SIM card record is missing"
resolved.Message = "卡记录不存在,未办理"
} else {
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
Where(dao.TSimCardInfo.Columns().Id, detail.Card.ID).
@@ -195,7 +195,7 @@ func (r *Repository) SaveRestartResult(ctx context.Context, task *lifecycle.Task
}
if affected != 1 {
resolved.Success = false
resolved.Message = "SIM card local status changed"
resolved.Message = "卡本地状态已变更"
}
}
}
@@ -327,7 +327,7 @@ func (r *Repository) FinalizeRestart(ctx context.Context, batchNo string) error
batchAsyncStatus, runResult := batchResult(successCount, failCount)
failReason := ""
if failCount > 0 {
failReason = strconv.Itoa(failCount) + " SIM cards failed"
failReason = storedFailureSummary(details, successCount, failCount)
}
validCount := 0
if restartValidationOK(main.BatchInfo) {
@@ -77,7 +77,7 @@ type changeCardUpdateDO struct {
DataServiceStatus any `orm:"data_service_status"`
PackageTotalFlowMb any `orm:"package_total_flow_mb"`
UsedFlowMb any `orm:"used_flow_mb"`
TrafficCutoffStatus any `orm:"traffic_cutoff_status"`
TrafficCutoffStatus any `orm:"traffic_cutoff_status"`
TrafficCutoffThreshold any `orm:"traffic_cutoff_threshold"`
SmsServiceStatus any `orm:"sms_service_status"`
PackageTotalSms any `orm:"package_total_sms"`
@@ -344,7 +344,7 @@ func (r *Repository) UpdateChangeCard(ctx context.Context, tx gdb.TX, iccid, pac
DataServiceStatus: 1,
PackageTotalFlowMb: packageFlowMb,
UsedFlowMb: 0,
TrafficCutoffStatus: 0,
TrafficCutoffStatus: 0,
TrafficCutoffThreshold: 0,
SmsServiceStatus: 0,
PackageTotalSms: 0,
@@ -23,7 +23,7 @@ const (
resultFileStatusSuccess = "success"
resultFileStatusFailed = "failed"
validationFailureReason = "batch validation failed; traffic cutoff update skipped"
validationFailureReason = "批次校验未通过,已跳过流量断网更新"
)
type Repository struct {
@@ -338,7 +338,7 @@ func (r *Repository) lockCards(ctx context.Context, tx gdb.TX, tenantID string,
func validateLockedTask(task *trafficcutoff.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
}
@@ -360,15 +360,15 @@ func validateLockedTask(task *trafficcutoff.Task) bool {
}
detail.Reason = ""
if detail.Card == nil {
detail.Reason = "ICCID does not exist in SIM card records"
detail.Reason = "ICCID不存在或不属于当前租户"
} else if !detail.Authorized {
detail.Reason = "ICCID is outside the current operator authorization scope"
detail.Reason = "ICCID不在当前操作员授权范围内"
} else if detail.InputMSISDN != "" && detail.InputMSISDN != detail.Card.MSISDN {
detail.Reason = "MSISDN does not match the SIM card record"
detail.Reason = "MSISDN与卡档案不一致"
} else if detail.InputIMSI != "" && detail.InputIMSI != detail.Card.IMSI {
detail.Reason = "IMSI does not match the SIM card record"
detail.Reason = "IMSI与卡档案不一致"
} else if detail.Card.BossCardStatus == trafficcutoff.BossCardStatusCanceled {
detail.Reason = "ICCID is already canceled"
detail.Reason = "ICCID已销户"
}
if detail.Reason != "" {
hasFailure = true
@@ -379,7 +379,7 @@ func validateLockedTask(task *trafficcutoff.Task) bool {
if len(statusSet) > 1 {
for _, detail := range task.Details {
if detail != nil && detail.Reason == "" {
detail.Reason = "same batch contains different card statuses"
detail.Reason = "同一批次内卡状态不一致"
}
}
hasFailure = true
@@ -390,7 +390,7 @@ func validateLockedTask(task *trafficcutoff.Task) bool {
}
for _, detail := range task.Details {
if detail != nil && detail.Reason == "" {
detail.Reason = "same batch has invalid traffic cutoff rows; update skipped"
detail.Reason = "同一批次存在无效流量断网明细,已跳过更新"
}
}
task.FailureReason = validationFailureReason
@@ -0,0 +1,84 @@
package trafficusage
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"strings"
"testing"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcfg"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/tdengine"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
type routingDriver struct{ statements []string }
type routingConnection struct{ owner *routingDriver }
func (recorder *routingDriver) Open(string) (driver.Conn, error) {
return &routingConnection{owner: recorder}, nil
}
func (connection *routingConnection) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("unexpected prepare")
}
func (connection *routingConnection) Close() error { return nil }
func (connection *routingConnection) Begin() (driver.Tx, error) {
return nil, errors.New("unexpected transaction")
}
func (connection *routingConnection) Ping(context.Context) error { return nil }
func (connection *routingConnection) ExecContext(_ context.Context, statement string, _ []driver.NamedValue) (driver.Result, error) {
connection.owner.statements = append(connection.owner.statements, statement)
return driver.RowsAffected(1), nil
}
var routingRecorder = &routingDriver{}
func init() {
sql.Register("traffic_usage_split_routing", routingRecorder)
}
func TestRepositoryWritesToConfiguredSplitDatabase(t *testing.T) {
recorder := routingRecorder
recorder.statements = nil
previous := g.Cfg().GetAdapter()
t.Cleanup(func() { _ = tdengine.Close(); g.Cfg().SetAdapter(previous) })
adapter, err := gcfg.NewAdapterContent("tsdb:\n database: TdEngine\n tdengine:\n type: traffic_usage_split_routing\n dsn: test\n dbName: legacy\n dailyDbName: iotlcm_daily\n monthlyDbName: iotlcm_monthly\n")
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
_ = tdengine.Close()
repository := New()
period := time.Date(2026, 10, 21, 0, 0, 0, 0, time.FixedZone("CST", 8*60*60))
base := model.TrafficUsageBase{Iccid: "8986112520609988658", UsageMb: 12.5, PackageCode: "test"}
if err := repository.UpdateDaily(context.Background(), []model.TrafficUsageDailyItem{{TrafficUsageBase: base, Date: period}}); err != nil {
t.Fatal(err)
}
if err := repository.UpdateMonthly(context.Background(), []model.TrafficUsageMonthlyItem{{TrafficUsageBase: base, Month: period}}); err != nil {
t.Fatal(err)
}
if len(recorder.statements) != 2 {
t.Fatalf("statements=%v", recorder.statements)
}
for index, expected := range []string{
"INSERT INTO iotlcm_daily.sim_card_usage_d_8986112520609988658 USING iotlcm_daily.t_sim_card_usage_daily",
"INSERT INTO iotlcm_monthly.sim_card_usage_m_8986112520609988658 USING iotlcm_monthly.t_sim_card_usage_monthly",
} {
if !strings.HasPrefix(recorder.statements[index], expected) {
t.Errorf("statement=%s want prefix=%s", recorder.statements[index], expected)
}
}
if err := repository.UpdateDaily(context.Background(), nil); err != nil {
t.Fatal(err)
}
if err := repository.UpdateMonthly(context.Background(), nil); err != nil {
t.Fatal(err)
}
if len(recorder.statements) != 2 {
t.Fatal("empty updates issued SQL")
}
}
+1 -1
View File
@@ -332,7 +332,7 @@ func (r *Repository) SaveValidationFailure(ctx context.Context, task *unsub.Task
}
reason := task.FailureReason
if reason == "" {
reason = "batch validation failed; unsub request was skipped"
reason = "批次校验未通过,已跳过退订请求"
}
now := gtime.Now()
successCount := 0