chore: 并入 iotlcm-worker-svr 历史到 worker-svr/(原提交 96f0f4e)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:03 +08:00
422 changed files with 80993 additions and 0 deletions
@@ -0,0 +1,35 @@
package batch
import (
"context"
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/model/do"
"testing"
)
func TestExecutionPreservesOriginMetadata(t *testing.T) {
ctx := context.Background()
r := newTestRepository(t, ctx)
insertBatch(t, ctx, r.db, "META", batchdomain.Type103)
if _, err := r.db.Model(dao.BatchMain.Table()).Ctx(ctx).Where("batch_no", "META").Data(do.BatchMain{SourceChannel: "mini_program", ExecutionMode: 1}).Update(); err != nil {
t.Fatal(err)
}
if claimed, err := r.MarkProcessing(ctx, "META", batchdomain.Type103); err != nil || !claimed {
t.Fatal(claimed, err)
}
if marked, err := r.MarkFailed(ctx, "META", batchdomain.Type103, "test failure"); err != nil || !marked {
t.Fatal(marked, err)
}
main, err := r.FindMain(ctx, "META")
if err != nil || main.SourceChannel != "mini_program" || main.ExecutionMode != 1 {
t.Fatalf("metadata changed: %+v err=%v", main, err)
}
if marked, err := r.MarkFailed(ctx, "META", batchdomain.Type103, "duplicate"); err != nil || marked {
t.Fatal(marked, err)
}
main, err = r.FindMain(ctx, "META")
if err != nil || main.SourceChannel != "mini_program" || main.ExecutionMode != 1 {
t.Fatal("duplicate mutated origin", err)
}
}
@@ -0,0 +1,206 @@
package batch
import (
"context"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
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"
)
const (
AsyncStatusSuccess = 3
RunResultSuccess = 2
batchDetailErrorReasonLimit = 512
)
type Repository struct {
db gdb.DB
}
func New() *Repository {
return &Repository{db: dao.BatchMain.DB()}
}
func (r *Repository) FindMain(ctx context.Context, batchNo string) (*entity.BatchMain, error) {
var main *entity.BatchMain
if err := r.db.Model(dao.BatchMain.Table()).Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Scan(&main); err != nil {
return nil, gerror.Wrap(err, "query batch main failed")
}
return main, nil
}
func (r *Repository) MarkProcessing(ctx context.Context, batchNo string, batchType int) (claimed bool, err error) {
err = r.db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
mainColumns := dao.BatchMain.Columns()
result, updateErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(mainColumns.BatchNo, batchNo).
Where(mainColumns.BatchType, batchType).
Where(mainColumns.BatchAsyncStatus, batchdomain.AsyncStatusPending).
Data(do.BatchMain{
BatchAsyncStatus: batchdomain.AsyncStatusProcessing,
RunResult: batchdomain.RunResultRunning,
StartTime: gtime.Now(),
}).
Update()
if updateErr != nil {
return gerror.Wrap(updateErr, "CAS batch main failed")
}
affected, affectedErr := result.RowsAffected()
if affectedErr != nil {
return gerror.Wrap(affectedErr, "read batch main CAS result failed")
}
if affected == 0 {
return nil
}
detailColumns := dao.BatchDetail.Columns()
if _, updateErr = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(detailColumns.BatchNo, batchNo).
Where(detailColumns.BatchType, batchType).
Where(detailColumns.DetailStatus, batchdomain.DetailStatusPending).
Data(do.BatchDetail{DetailStatus: batchdomain.DetailStatusRunning}).
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
})
return claimed, gerror.Wrap(err, "claim batch transaction failed")
}
func (r *Repository) ReclaimProcessing(ctx context.Context, batchNo string, batchType int, startedAt *gtime.Time) (claimed bool, err error) {
columns := dao.BatchMain.Columns()
model := r.db.Model(dao.BatchMain.Table()).Ctx(ctx).
Where(columns.BatchNo, batchNo).
Where(columns.BatchType, batchType).
Where(columns.BatchAsyncStatus, batchdomain.AsyncStatusProcessing)
if startedAt == nil || startedAt.IsZero() {
model = model.Where(columns.StartTime, nil)
} else {
model = model.Where(columns.StartTime, startedAt)
}
result, err := model.Data(do.BatchMain{
StartTime: gtime.Now(),
RunResult: batchdomain.RunResultRunning,
}).Update()
if err != nil {
return false, gerror.Wrap(err, "reclaim stale batch failed")
}
affected, err := result.RowsAffected()
if err != nil {
return false, gerror.Wrap(err, "read stale batch reclaim result failed")
}
return affected == 1, nil
}
func (r *Repository) MarkFailed(ctx context.Context, batchNo string, batchType int, reason string) (marked bool, err error) {
detailReason := truncateBatchDetailReason(reason)
err = r.db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
mainColumns := dao.BatchMain.Columns()
result, updateErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(mainColumns.BatchNo, batchNo).
Where(mainColumns.BatchType, batchType).
Where(mainColumns.BatchAsyncStatus, batchdomain.AsyncStatusProcessing).
Data(do.BatchMain{
BatchAsyncStatus: batchdomain.AsyncStatusFailed,
RunResult: batchdomain.RunResultFailed,
FailReason: batchfields.FailureReason(reason),
EndTime: gtime.Now(),
}).
Update()
if updateErr != nil {
return gerror.Wrap(updateErr, "mark batch main failed failed")
}
affected, affectedErr := result.RowsAffected()
if affectedErr != nil {
return gerror.Wrap(affectedErr, "read batch failure status update result failed")
}
if affected == 0 {
return nil
}
detailColumns := dao.BatchDetail.Columns()
if _, updateErr = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(detailColumns.BatchNo, batchNo).
Where(detailColumns.BatchType, batchType).
Where(detailColumns.DetailStatus, batchdomain.DetailStatusRunning).
Data(do.BatchDetail{
DetailStatus: batchdomain.DetailStatusFailed,
ErrorReason: detailReason,
}).
Update(); updateErr != nil {
return gerror.Wrap(updateErr, "mark running batch details failed failed")
}
marked = true
return nil
})
return marked, gerror.Wrap(err, "mark batch failure transaction failed")
}
func truncateBatchDetailReason(reason string) string {
runes := []rune(strings.TrimSpace(reason))
if len(runes) <= batchDetailErrorReasonLimit {
return string(runes)
}
return string(runes[:batchDetailErrorReasonLimit])
}
// MarkSuccess 将指定批次从 Processing 标记为 Success,并结算明细状态。
func (r *Repository) MarkSuccess(ctx context.Context, batchNo string, batchType int, success, fail int, reason string) (marked bool, err error) {
err = r.db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
mainColumns := dao.BatchMain.Columns()
result, updateErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(mainColumns.BatchNo, batchNo).
Where(mainColumns.BatchType, batchType).
Where(mainColumns.BatchAsyncStatus, batchdomain.AsyncStatusProcessing).
Data(do.BatchMain{
BatchAsyncStatus: AsyncStatusSuccess,
RunResult: RunResultSuccess,
SuccessCount: success,
FailCount: fail,
EndTime: gtime.Now(),
}).
Update()
if updateErr != nil {
return gerror.Wrap(updateErr, "mark batch main success failed")
}
affected, affectedErr := result.RowsAffected()
if affectedErr != nil {
return gerror.Wrap(affectedErr, "read batch success status update result failed")
}
if affected == 0 {
return nil
}
detailColumns := dao.BatchDetail.Columns()
if _, updateErr = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(detailColumns.BatchNo, batchNo).
Where(detailColumns.BatchType, batchType).
Where(detailColumns.DetailStatus, batchdomain.DetailStatusRunning).
Data(do.BatchDetail{
DetailStatus: batchdomain.DetailStatusSuccess,
ErrorReason: reason,
}).
Update(); updateErr != nil {
return gerror.Wrap(updateErr, "mark running batch details success failed")
}
marked = true
return nil
})
return marked, gerror.Wrap(err, "mark batch success transaction failed")
}
@@ -0,0 +1,261 @@
package batch
import (
"context"
"strings"
"testing"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
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"
)
func TestMarkProcessingClaimsMainAndPendingDetails(t *testing.T) {
ctx := context.Background()
repository := newTestRepository(t, ctx)
insertBatch(t, ctx, repository.db, "B-103", batchdomain.Type103)
main, err := repository.FindMain(ctx, "B-103")
if err != nil || main == nil || main.BatchType != batchdomain.Type103 {
t.Fatalf("FindMain() = (%+v, %v), want batch type %d", main, err, batchdomain.Type103)
}
missing, err := repository.FindMain(ctx, "missing")
if err != nil || missing != nil {
t.Fatalf("FindMain(missing) = (%+v, %v), want nil, nil", missing, err)
}
claimed, err := repository.MarkProcessing(ctx, "B-103", batchdomain.Type103)
if err != nil {
t.Fatalf("MarkProcessing() error = %v", err)
}
if !claimed {
t.Fatal("MarkProcessing() claimed = false, want true")
}
assertStatus(t, ctx, repository.db, dao.BatchMain.Table(), dao.BatchMain.Columns().BatchAsyncStatus, batchdomain.AsyncStatusProcessing)
assertStatus(t, ctx, repository.db, dao.BatchMain.Table(), dao.BatchMain.Columns().RunResult, batchdomain.RunResultRunning)
main, err = repository.FindMain(ctx, "B-103")
if err != nil || main.StartTime == nil || main.StartTime.IsZero() {
t.Fatalf("processing batch start time = %v, error = %v; want current time", main.StartTime, err)
}
var details []entity.BatchDetail
if err = repository.db.Model(dao.BatchDetail.Table()).Ctx(ctx).
Order(dao.BatchDetail.Columns().Id).
Scan(&details); err != nil {
t.Fatalf("query detail statuses: %v", err)
}
if len(details) != 2 || details[0].DetailStatus != batchdomain.DetailStatusRunning || details[1].DetailStatus != 2 {
t.Fatalf("details = %+v, want statuses [1 2]", details)
}
claimed, err = repository.MarkProcessing(ctx, "B-103", batchdomain.Type103)
if err != nil {
t.Fatalf("second MarkProcessing() error = %v", err)
}
if claimed {
t.Fatal("second MarkProcessing() claimed = true, want false")
}
}
func TestMarkProcessingRollsBackMainWhenDetailUpdateFails(t *testing.T) {
ctx := context.Background()
repository := newTestRepository(t, ctx)
insertBatch(t, ctx, repository.db, "B-104", batchdomain.Type104)
if _, err := repository.db.Exec(ctx, "DROP TABLE batch_detail"); err != nil {
t.Fatalf("drop detail table: %v", err)
}
if claimed, err := repository.MarkProcessing(ctx, "B-104", batchdomain.Type104); err == nil || claimed {
t.Fatalf("MarkProcessing() = (%t, %v), want false and error", claimed, err)
}
assertStatus(t, ctx, repository.db, dao.BatchMain.Table(), dao.BatchMain.Columns().BatchAsyncStatus, batchdomain.AsyncStatusPending)
assertStatus(t, ctx, repository.db, dao.BatchMain.Table(), dao.BatchMain.Columns().RunResult, 0)
main, err := repository.FindMain(ctx, "B-104")
if err != nil || main.StartTime != nil {
t.Fatalf("rolled back batch start time = %v, error = %v; want nil", main.StartTime, err)
}
}
func TestMarkFailedFinalizesProcessingBatchAndDetails(t *testing.T) {
ctx := context.Background()
repository := newTestRepository(t, ctx)
insertBatch(t, ctx, repository.db, "B-105", batchdomain.Type103)
if claimed, err := repository.MarkProcessing(ctx, "B-105", batchdomain.Type103); err != nil || !claimed {
t.Fatalf("MarkProcessing() = (%t, %v), want true, nil", claimed, err)
}
marked, err := repository.MarkFailed(ctx, "B-105", batchdomain.Type103, "source file is unavailable")
if err != nil || !marked {
t.Fatalf("MarkFailed() = (%t, %v), want true, nil", marked, err)
}
assertStatus(t, ctx, repository.db, dao.BatchMain.Table(), dao.BatchMain.Columns().BatchAsyncStatus, batchdomain.AsyncStatusFailed)
assertStatus(t, ctx, repository.db, dao.BatchMain.Table(), dao.BatchMain.Columns().RunResult, batchdomain.RunResultFailed)
var main entity.BatchMain
if err = repository.db.Model(dao.BatchMain.Table()).Ctx(ctx).Where(dao.BatchMain.Columns().BatchNo, "B-105").Scan(&main); err != nil {
t.Fatalf("query failed batch main: %v", err)
}
if main.FailReason != "source file is unavailable" || main.EndTime == nil || main.EndTime.IsZero() {
t.Fatalf("failed batch main = %+v, want failure reason and end time", main)
}
var details []entity.BatchDetail
if err = repository.db.Model(dao.BatchDetail.Table()).Ctx(ctx).Order(dao.BatchDetail.Columns().Id).Scan(&details); err != nil {
t.Fatalf("query failed batch details: %v", err)
}
if len(details) != 2 || details[0].DetailStatus != batchdomain.DetailStatusFailed || details[0].ErrorReason != "source file is unavailable" || details[1].DetailStatus != 2 {
t.Fatalf("failed details = %+v, want statuses [3 2]", details)
}
marked, err = repository.MarkFailed(ctx, "B-105", batchdomain.Type103, "ignored")
if err != nil || marked {
t.Fatalf("second MarkFailed() = (%t, %v), want false, nil", marked, err)
}
}
func TestMarkFailedTruncatesDetailReason(t *testing.T) {
ctx := context.Background()
repository := newTestRepository(t, ctx)
insertBatch(t, ctx, repository.db, "B-106", batchdomain.Type103)
if claimed, err := repository.MarkProcessing(ctx, "B-106", batchdomain.Type103); err != nil || !claimed {
t.Fatalf("MarkProcessing() = (%t, %v), want true, nil", claimed, err)
}
reason := strings.Repeat("x", batchDetailErrorReasonLimit+20)
marked, err := repository.MarkFailed(ctx, "B-106", batchdomain.Type103, reason)
if err != nil || !marked {
t.Fatalf("MarkFailed() = (%t, %v), want true, nil", marked, err)
}
var main entity.BatchMain
if err = repository.db.Model(dao.BatchMain.Table()).Ctx(ctx).Where(dao.BatchMain.Columns().BatchNo, "B-106").Scan(&main); err != nil {
t.Fatalf("query failed batch main: %v", err)
}
if main.FailReason != reason {
t.Fatalf("main failure reason length = %d, want full length %d", len([]rune(main.FailReason)), len([]rune(reason)))
}
var details []entity.BatchDetail
if err = repository.db.Model(dao.BatchDetail.Table()).Ctx(ctx).
Where(dao.BatchDetail.Columns().BatchNo, "B-106").
Order(dao.BatchDetail.Columns().Id).
Scan(&details); err != nil {
t.Fatalf("query failed batch details: %v", err)
}
if len(details) != 2 || len([]rune(details[0].ErrorReason)) != batchDetailErrorReasonLimit || details[1].ErrorReason != "" {
t.Fatalf("detail failure reasons = %+v, want first truncated to %d and second unchanged", details, batchDetailErrorReasonLimit)
}
}
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{
Type: "sqlite",
Link: "sqlite::@file(:memory:)",
})
if err != nil {
t.Fatalf("create sqlite database: %v", err)
}
statements := []string{
"CREATE TABLE batch_main (batch_no TEXT PRIMARY KEY, batch_type INTEGER NOT NULL, batch_async_status INTEGER NOT NULL, run_result INTEGER NOT NULL DEFAULT 0, fail_reason TEXT, start_time DATETIME, end_time DATETIME, source_channel TEXT NOT NULL DEFAULT 'legacy', execution_mode INTEGER NOT NULL DEFAULT 0)",
"CREATE TABLE batch_detail (id INTEGER PRIMARY KEY AUTOINCREMENT, batch_no TEXT NOT NULL, batch_type INTEGER NOT NULL, detail_status INTEGER NOT NULL, error_reason TEXT)",
}
for _, statement := range statements {
if _, err = db.Exec(ctx, statement); err != nil {
t.Fatalf("create test schema: %v", err)
}
}
t.Cleanup(func() { _ = db.Close(ctx) })
return &Repository{db: db}
}
func insertBatch(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, batchType int) {
t.Helper()
if _, err := db.Model(dao.BatchMain.Table()).Ctx(ctx).Data(do.BatchMain{
BatchNo: batchNo,
BatchType: batchType,
BatchAsyncStatus: batchdomain.AsyncStatusPending,
}).Insert(); err != nil {
t.Fatalf("insert batch main: %v", err)
}
for _, status := range []int{batchdomain.DetailStatusPending, 2} {
if _, err := db.Model(dao.BatchDetail.Table()).Ctx(ctx).Data(do.BatchDetail{
BatchNo: batchNo,
BatchType: batchType,
DetailStatus: status,
}).Insert(); err != nil {
t.Fatalf("insert batch detail: %v", err)
}
}
}
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)
if err != nil {
t.Fatalf("query %s status: %v", table, err)
}
if actual.Int() != expected {
t.Fatalf("%s status = %d, want %d", table, actual.Int(), expected)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,626 @@
package data
import (
"context"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/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"
data "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data"
)
type Repository struct {
db gdb.DB
resultFiles batchresult.Generator
}
func New() *Repository {
return &Repository{resultFiles: batchresult.New()}
}
func (r *Repository) database() gdb.DB {
if r.db != nil {
return r.db
}
return g.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}).
WhereIn(dao.BatchMain.Columns().BatchAsyncStatus, statuses).
Scan(&batch); err != nil {
return nil, gerror.Wrap(err, "query data service batch")
}
if batch.Id == 0 {
return nil, gerror.New("processing data service batch does not exist")
}
operation, err := data.OperationByBatchType(batch.BatchType)
if err != nil {
return nil, err
}
var batchDetails []*entity.BatchDetail
detailQuery := r.database().Model(dao.BatchDetail.Table()).Safe().Ctx(ctx).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().BatchType, batch.BatchType)
if len(ids) > 0 {
detailQuery = detailQuery.WhereIn("id", ids)
}
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{
Batch: data.Batch{
ID: batch.Id,
BatchNo: batch.BatchNo,
BatchType: batch.BatchType,
TenantID: batch.TenantId,
CreatedBy: batch.CreatedBy,
CreatedDept: batch.CreatedDept,
StartTime: batch.StartTime,
BatchInfo: batch.BatchInfo,
},
Operation: operation,
ApnName: apnName(batch.BatchInfo),
Details: make([]*data.Detail, 0, len(batchDetails)),
}
for _, batchDetail := range batchDetails {
if batchDetail == nil {
continue
}
detail := &data.Detail{
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 != "" {
detail.ICCID = value
}
detail.InputMSISDN = strings.TrimSpace(batchDetail.Snapshot.Get("MSISDN").String())
detail.InputIMSI = strings.TrimSpace(batchDetail.Snapshot.Get("IMSI").String())
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 err = r.loadCards(ctx, task); err != nil {
return nil, err
}
return task, nil
}
func apnName(info *gjson.Json) string {
if info != nil {
if value := strings.TrimSpace(info.Get("apnName").String()); value != "" {
return value
}
}
return consts.SimApnNameDefault
}
func (r *Repository) loadCards(ctx context.Context, task *data.Task) error {
iccids := make([]string, 0, len(task.Details))
for _, detail := range task.Details {
if detail != nil && detail.ICCID != "" {
iccids = append(iccids, detail.ICCID)
}
}
if len(iccids) == 0 {
return nil
}
var cards []*entity.TSimCardInfo
if err := r.database().Model(dao.TSimCardInfo.Table()).Safe().Ctx(ctx).
Where(dao.TSimCardInfo.Columns().TenantId, task.Batch.TenantID).
WhereIn(dao.TSimCardInfo.Columns().Iccid, iccids).
Scan(&cards); err != nil {
return gerror.Wrap(err, "query data service SIM cards")
}
byICCID := make(map[string]*entity.TSimCardInfo, len(cards))
for _, card := range cards {
if card != nil {
byICCID[strings.TrimSpace(card.Iccid)] = card
}
}
for _, detail := range task.Details {
card := byICCID[detail.ICCID]
if card == nil {
continue
}
detail.Card = &data.Card{
ID: card.Id,
ICCID: strings.TrimSpace(card.Iccid),
MSISDN: strings.TrimSpace(card.Msisdn),
IMSI: strings.TrimSpace(card.Imsi),
ChanKey: preferredDataServiceChanKey(dataAppBatchUsesSnapshotChannel(task.Batch.BatchInfo), detail.ChanKey, card.ChanKey),
DataServiceStatus: card.DataServiceStatus,
}
}
return nil
}
func preferredDataServiceChanKey(useSnapshot bool, snapshotChanKey string, cardChanKey string) string {
if useSnapshot {
if value := strings.TrimSpace(snapshotChanKey); value != "" {
return value
}
}
return strings.TrimSpace(cardChanKey)
}
func dataAppBatchUsesSnapshotChannel(info *gjson.Json) bool {
if info == nil {
return false
}
return strings.EqualFold(strings.TrimSpace(info.Get("source").String()), "app")
}
func (r *Repository) RefreshDataServiceState(ctx context.Context, task *data.Task) error {
if task == nil || len(task.Details) == 0 {
return nil
}
iccids := make([]string, 0, len(task.Details))
for _, detail := range task.Details {
if detail != nil && detail.ICCID != "" {
iccids = append(iccids, detail.ICCID)
}
}
if len(iccids) == 0 {
return nil
}
type row struct {
ICCID string `orm:"iccid"`
DataServiceStatus int `orm:"data_service_status"`
}
var cards []row
if err := r.database().Model(dao.TSimCardInfo.Table()).Safe().Ctx(ctx).
Fields(dao.TSimCardInfo.Columns().Iccid, dao.TSimCardInfo.Columns().DataServiceStatus).
Where(dao.TSimCardInfo.Columns().TenantId, task.Batch.TenantID).
WhereIn(dao.TSimCardInfo.Columns().Iccid, iccids).
Scan(&cards); err != nil {
return gerror.Wrap(err, "refresh data service card state")
}
byICCID := make(map[string]int, len(cards))
for _, card := range cards {
byICCID[strings.TrimSpace(card.ICCID)] = card.DataServiceStatus
}
for _, detail := range task.Details {
if detail == nil || detail.Card == nil {
continue
}
if status, ok := byICCID[detail.ICCID]; ok {
detail.Card.DataServiceStatus = status
detail.StatusBefore = status
detail.StatusAfter = status
}
}
return nil
}
func (r *Repository) SaveValidationFailure(ctx context.Context, task *data.Task) error {
return r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, detail := range task.Details {
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).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
Data(do.BatchDetail{
DetailStatus: consts.BatchDetailStatusFailed,
ErrorReason: detail.Reason,
Snapshot: detail.Snapshot(task.Operation, task.ApnName),
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.Newf("data service detail %d is no longer running", detail.ID)
}
}
return nil
})
}
func (r *Repository) SaveResult(ctx context.Context, task *data.Task, detail *data.Detail, outcome data.Outcome) (data.Outcome, error) {
if detail == nil {
return data.Outcome{}, gerror.New("data service detail is nil")
}
resolved := outcome
err := r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if resolved.Success {
if detail.Card == nil {
resolved.Success = false
resolved.Message = "卡记录不存在,未办理"
} else {
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
Where(dao.TSimCardInfo.Columns().Id, detail.Card.ID).
Where(dao.TSimCardInfo.Columns().TenantId, task.Batch.TenantID).
Data(dataServiceUpdateData(task, detail)).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.Newf("data service SIM card %d is missing or changed", detail.Card.ID)
}
detail.StatusAfter = task.Operation.TargetStatus
}
}
detail.ApplyOutcome(resolved)
detailStatus := consts.BatchDetailStatusFailed
if resolved.Success {
detailStatus = consts.BatchDetailStatusSuccess
}
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().Id, detail.ID).
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
Data(do.BatchDetail{
DetailStatus: detailStatus,
ErrorCode: strconv.Itoa(resolved.Code),
ErrorReason: detail.Reason,
Snapshot: detail.Snapshot(task.Operation, task.ApnName),
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.Newf("data service detail %d is no longer running", detail.ID)
}
return nil
})
return resolved, err
}
func dataServiceUpdateData(task *data.Task, detail *data.Detail) do.TSimCardTrafficCutoffConfig {
now := gtime.Now()
result := do.TSimCardTrafficCutoffConfig{
DataServiceStatus: task.Operation.TargetStatus,
StatusSyncTime: now,
UpdatedBy: task.Batch.CreatedBy,
}
switch task.Batch.BatchType {
case consts.BatchTypeDataCut:
if detail != nil && detail.TrafficCutoffCrossMonthHold == 1 {
result.TrafficCutoffTriggeredAt = now
}
case consts.BatchTypeDataStart:
result.TrafficCutoffTriggeredAt = gdb.Raw("NULL")
}
return result
}
func (r *Repository) Finalize(ctx context.Context, task *data.Task) error {
successCount, unknownCount := 0, 0
for _, detail := range task.Details {
if detail != nil && detail.DetailStatus == consts.BatchDetailStatusUnknown {
unknownCount++
} else if detail != nil && detail.DetailStatus == consts.BatchDetailStatusSuccess {
successCount++
}
}
failCount := len(task.Details) - successCount - unknownCount
batchAsyncStatus, runResult := batchResult(successCount, failCount)
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 {
validCount = validDetailCount(task)
}
artifact, err := r.artifactGenerator().Create(ctx, artifactRequest(task))
if err != nil {
return gerror.Wrap(err, "generate data service result file")
}
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,
OriginalName: artifact.OriginalName,
FileSuffix: artifact.FileSuffix,
Path: artifact.Path,
Url: artifact.URL,
CreatedDept: task.Batch.CreatedDept,
CreatedBy: task.Batch.CreatedBy,
UpdatedBy: task.Batch.CreatedBy,
Service: "local",
Md5: artifact.MD5,
FileSize: artifact.Size,
}).InsertAndGetId()
if err != nil {
return gerror.Wrap(err, "insert data service result OSS")
}
downloadID, err := tx.Ctx(ctx).Model(dao.TResourceBatchDownload.Table()).Data(do.TResourceBatchDownload{
BatchNo: task.Batch.BatchNo,
TaskName: batchresult.TaskName(task.Batch.BatchType),
ResourceType: consts.ResourceTypeExcel,
ExportScene: task.Batch.BatchType,
TotalCount: len(task.Details),
SuccessCount: successCount,
FailCount: failCount,
DownloadStatus: consts.DownloadStatusGenerated,
OssId: ossID,
FileName: artifact.FileName,
FileExt: artifact.FileSuffix,
FileUrl: artifact.URL,
FilePath: artifact.Path,
FileSize: artifact.Size,
FileMd5: artifact.MD5,
DownloadCount: 0,
MaxDownloadCount: 0,
DeptId: task.Batch.CreatedDept,
StartTime: task.Batch.StartTime,
FinishTime: now,
TenantId: task.Batch.TenantID,
CreatedDept: task.Batch.CreatedDept,
CreatedBy: task.Batch.CreatedBy,
UpdatedBy: task.Batch.CreatedBy,
}).InsertAndGetId()
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).
WhereIn(dao.BatchMain.Columns().BatchAsyncStatus, allowedStatuses).
Data(do.BatchMain{
BatchAsyncStatus: batchAsyncStatus,
ValidCount: validCount,
SuccessCount: successCount,
FailCount: failCount,
RunResult: runResult,
FailReason: task.FailureReason,
BatchInfo: buildBatchInfo(task, ossID, downloadID),
EndTime: now,
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
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 {
batchresult.RemoveLocalFile(artifact)
return err
}
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
}
count := 0
for _, detail := range task.Details {
if detail != nil && detail.ValidationPassed {
count++
}
}
return count
}
func (r *Repository) artifactGenerator() batchresult.Generator {
if r.resultFiles != nil {
return r.resultFiles
}
return batchresult.New()
}
func artifactRequest(task *data.Task) batchresult.Request {
rows := make([]batchresult.Row, 0, len(task.Details))
for _, detail := range task.Details {
if detail == nil {
continue
}
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.DetailStatus == consts.BatchDetailStatusSuccess,
Reason: detail.Reason,
UpstreamCode: detail.UpstreamCode,
OrderNum: detail.OrderNum,
})
}
return batchresult.Request{BatchNo: task.Batch.BatchNo, BatchType: task.Batch.BatchType, TenantID: task.Batch.TenantID, Rows: rows}
}
func operationLabel(operation data.Operation) string {
switch operation.BatchType {
case consts.BatchTypeDataCut:
return "达量断网"
case consts.BatchTypeDataStart:
return "开数据"
case consts.BatchTypeDataStop:
return "停数据"
default:
return operation.Name
}
}
func buildBatchInfo(task *data.Task, ossID, downloadID int64) *gjson.Json {
info := make(map[string]any)
if task.Batch.BatchInfo != nil {
for key, value := range task.Batch.BatchInfo.Map() {
info[key] = value
}
}
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)
}
func batchResult(successCount, failCount int) (int, int) {
switch {
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
}
@@ -0,0 +1,134 @@
package data
import (
"context"
"testing"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
dataService "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data"
)
func TestPreferredDataServiceChanKey(t *testing.T) {
tests := []struct {
name string
appBatch bool
snapshot string
card string
want string
}{
{name: "app resolved snapshot wins", appBatch: true, snapshot: " legacy-71 ", card: "stale-1", want: "legacy-71"},
{name: "console keeps card channel", snapshot: "legacy-71", card: " card-112 ", want: "card-112"},
{name: "card fallback", card: " card-112 ", want: "card-112"},
{name: "empty", want: ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := preferredDataServiceChanKey(test.appBatch, test.snapshot, test.card); got != test.want {
t.Fatalf("preferredDataServiceChanKey() = %q, want %q", got, test.want)
}
})
}
}
func TestDataAppBatchUsesSnapshotChannel(t *testing.T) {
if !dataAppBatchUsesSnapshotChannel(gjson.New(map[string]any{"source": "APP"})) {
t.Fatal("app batch should use the resolved snapshot channel")
}
if dataAppBatchUsesSnapshotChannel(gjson.New(map[string]any{"source": "console"})) {
t.Fatal("console batch must continue using the current card channel")
}
}
func TestSaveResultDoesNotClearTrafficCutoffFields(t *testing.T) {
ctx := context.Background()
db, err := gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(:memory:)"})
if err != nil {
t.Fatalf("create sqlite database: %v", err)
}
statements := []string{
"CREATE TABLE t_sim_card_info (id INTEGER PRIMARY KEY, tenant_id TEXT NOT NULL, data_service_status INTEGER NOT NULL DEFAULT 1, status_sync_time DATETIME, traffic_cutoff_status INTEGER NOT NULL DEFAULT 0, traffic_cutoff_threshold REAL NOT NULL DEFAULT 0, traffic_cutoff_cross_month_hold INTEGER NOT NULL DEFAULT 0, traffic_cutoff_triggered_at DATETIME, updated_by INTEGER NOT NULL DEFAULT 0, updated_at DATETIME)",
"CREATE TABLE batch_detail (id INTEGER PRIMARY KEY, batch_no TEXT NOT NULL, detail_status INTEGER NOT NULL, error_code TEXT, error_reason TEXT, snapshot TEXT, updated_by INTEGER NOT NULL DEFAULT 0, updated_at DATETIME)",
"INSERT INTO t_sim_card_info (id, tenant_id, data_service_status, traffic_cutoff_status, traffic_cutoff_threshold, traffic_cutoff_cross_month_hold, updated_by) VALUES (1, '000000', 1, 1, 512, 1, 7)",
"INSERT INTO batch_detail (id, batch_no, detail_status) VALUES (9, 'DST-TEST', 1)",
}
for _, statement := range statements {
if _, err = db.Exec(ctx, statement); err != nil {
t.Fatalf("execute setup SQL: %v", err)
}
}
repo := &Repository{db: db}
task := &dataService.Task{
Batch: dataService.Batch{
BatchNo: "DST-TEST",
BatchType: consts.BatchTypeDataStop,
TenantID: "000000",
CreatedBy: 13,
},
Operation: dataService.Operation{
BatchType: consts.BatchTypeDataStop,
TargetStatus: consts.SimDataServiceStatusDisabled,
},
}
detail := &dataService.Detail{
ID: 9,
Card: &dataService.Card{ID: 1, DataServiceStatus: consts.SimDataServiceStatusEnabled},
}
if _, err = repo.SaveResult(ctx, task, detail, dataService.Outcome{Success: true}); err != nil {
t.Fatalf("SaveResult() error = %v", err)
}
row, err := db.GetOne(ctx, "SELECT data_service_status, traffic_cutoff_status, traffic_cutoff_threshold, traffic_cutoff_cross_month_hold FROM t_sim_card_info WHERE id = 1")
if err != nil {
t.Fatalf("query card result: %v", err)
}
if got := row["data_service_status"].Int(); got != consts.SimDataServiceStatusDisabled {
t.Fatalf("data_service_status = %d", got)
}
if got := row["traffic_cutoff_status"].Int(); got != 1 {
t.Fatalf("traffic_cutoff_status = %d, want 1", got)
}
if got := row["traffic_cutoff_threshold"].Float64(); got != 512 {
t.Fatalf("traffic_cutoff_threshold = %v, want 512", got)
}
if got := row["traffic_cutoff_cross_month_hold"].Int(); got != 1 {
t.Fatalf("traffic_cutoff_cross_month_hold = %d, want 1", got)
}
}
func TestDataServiceUpdateDataTracksOnlyCrossMonthCutoff(t *testing.T) {
tests := []struct {
name string
batchType int
hold int
wantNil bool
}{
{name: "cutoff with hold", batchType: consts.BatchTypeDataCut, hold: 1, wantNil: false},
{name: "cutoff without hold", batchType: consts.BatchTypeDataCut, hold: 0, wantNil: true},
{name: "manual stop", batchType: consts.BatchTypeDataStop, hold: 1, wantNil: true},
{name: "data start clears trigger", batchType: consts.BatchTypeDataStart, hold: 1, wantNil: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
task := &dataService.Task{
Batch: dataService.Batch{BatchType: tt.batchType, CreatedBy: 7},
Operation: dataService.Operation{TargetStatus: consts.SimDataServiceStatusDisabled},
}
detail := &dataService.Detail{TrafficCutoffCrossMonthHold: tt.hold}
update := dataServiceUpdateData(task, detail)
if gotNil := update.TrafficCutoffTriggeredAt == nil; gotNil != tt.wantNil {
t.Fatalf("TrafficCutoffTriggeredAt nil = %v, want %v", gotNil, tt.wantNil)
}
if update.DataServiceStatus != task.Operation.TargetStatus {
t.Fatalf("DataServiceStatus = %v, want %d", update.DataServiceStatus, task.Operation.TargetStatus)
}
if update.StatusSyncTime == nil {
t.Fatal("StatusSyncTime must be set")
}
})
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -0,0 +1,133 @@
package imei
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
)
// imeiValuePattern bounds what may be written into t_sim_card_info.imei.
//
// The column is varchar(32) and the carrier value is digits only (15-17 for a
// real IMEI). This pattern is the primary defence for the CASE WHEN statement
// below, which is assembled as SQL text and therefore cannot rely on bound
// parameters for the value itself.
//
// The separator is intentionally absent: this service never concatenates
// multiple IMEIs (see the handler's multi-value note), so allowing ';' would
// widen the column contract for a case that cannot occur.
var imeiValuePattern = regexp.MustCompile(`^[0-9]{1,32}$`)
// maxImeiSyncBatch bounds one ApplyBatch statement. The caller (the ZSet
// consumer) claims in batches of claimBatch (default 100), so this is a guard
// rather than a working limit: it keeps a single UPDATE statement from growing
// without bound if a future caller raises the claim size.
const maxImeiSyncBatch = 1000
// Result is the database projection of one IMEI sync outcome.
//
// Two update kinds are represented by one struct:
//
// - Synced: the carrier answered successfully (Code == 0). imei_sync_time is
// written because the card was covered by this polling pass.
// - ValueChanged: the carrier returned a non-empty IMEI that differs from the
// stored value. Only then are imei and imei_update_time written.
//
// A Synced-but-empty result (carrier answered, no IMEI available) is therefore
// a heartbeat-only update: it records coverage without touching the value. That
// asymmetry is what makes the whole chain "additive": an upstream outage that
// returns empty values can never clear a stored IMEI.
type Result struct {
CardID int64
Synced bool
ValueChanged bool
Imei string
}
type Repository struct{}
func NewRepository() *Repository { return &Repository{} }
// ApplyBatch writes a batch of IMEI sync results in as few statements as
// possible.
//
// Two mutually exclusive groups are emitted, because they update different
// column sets and mixing them in one CASE expression would force every card to
// write every column:
//
// 1. heartbeat only (synced, value unchanged or empty) → imei_sync_time
// 2. value changed → imei + imei_update_time + imei_sync_time
//
// A statement is only issued when its group is non-empty, so an all-empty
// upstream pass still records coverage (group 1) without writing any value.
func (r *Repository) ApplyBatch(ctx context.Context, results []Result) error {
if len(results) == 0 {
return nil
}
if len(results) > maxImeiSyncBatch {
return fmt.Errorf("imei sync batch too large: %d > %d", len(results), maxImeiSyncBatch)
}
columns := dao.TSimCardInfo.Columns()
heartbeatIDs := make([]int64, 0, len(results))
changedIDs := make([]int64, 0, len(results))
var changedCase strings.Builder
changedCase.WriteString("CASE " + columns.Id + " ")
for _, result := range results {
if result.CardID <= 0 {
return fmt.Errorf("imei result has invalid cardId %d", result.CardID)
}
if !result.Synced {
// Only successfully synchronized cards get a heartbeat; a failed
// query says nothing about coverage.
continue
}
if !result.ValueChanged {
heartbeatIDs = append(heartbeatIDs, result.CardID)
continue
}
if !imeiValuePattern.MatchString(result.Imei) {
// Reject the whole batch rather than write a value that violates
// the column contract. The caller logs the message; the card is
// retried in a later round.
return fmt.Errorf("imei result for card %d has invalid value %q", result.CardID, result.Imei)
}
fmt.Fprintf(&changedCase, "WHEN %d THEN '%s' ", result.CardID, result.Imei)
changedIDs = append(changedIDs, result.CardID)
}
now := gtime.Now()
// Value changes first: if the second statement fails, the value and its
// change timestamp are already consistent, and the heartbeat is only a
// coverage marker that the next pass will re-write anyway.
if len(changedIDs) > 0 {
changedCase.WriteString("END")
if _, err := dao.TSimCardInfo.Ctx(ctx).
WhereIn(columns.Id, changedIDs).
Data(do.TSimCardInfo{
Imei: gdb.Raw(changedCase.String()),
ImeiUpdateTime: now,
ImeiSyncTime: now,
}).Update(); err != nil {
return err
}
}
if len(heartbeatIDs) > 0 {
if _, err := dao.TSimCardInfo.Ctx(ctx).
WhereIn(columns.Id, heartbeatIDs).
Data(do.TSimCardInfo{
ImeiSyncTime: now,
}).Update(); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,281 @@
package imei
import (
"context"
"strings"
"testing"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// setupDB builds a minimal card table mirroring the columns ApplyBatch writes.
func setupDB(t *testing.T, ctx context.Context) {
t.Helper()
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = g.DB().Close(ctx) })
if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil {
t.Fatal(err)
}
if _, err := g.DB().Exec(ctx, `CREATE TABLE t_sim_card_info (
id INTEGER PRIMARY KEY,
imei TEXT,
imei_sync_time DATETIME,
imei_update_time DATETIME
)`); err != nil {
t.Fatal(err)
}
if _, err := g.DB().Exec(ctx, `INSERT INTO t_sim_card_info (id, imei, imei_sync_time, imei_update_time) VALUES
(1,'OLD-IMEI-1',NULL,NULL),
(2,'OLD-IMEI-2',NULL,NULL),
(3,'OLD-IMEI-3',NULL,NULL),
(4,'',NULL,NULL)`); err != nil {
t.Fatal(err)
}
}
// readCard returns the stored imei and both timestamps for one card.
func readCard(t *testing.T, ctx context.Context, id int64) (imei string, syncTime, updateTime *string) {
t.Helper()
var row struct {
Imei string `orm:"imei"`
ImeiSyncTime *string `orm:"imei_sync_time"`
ImeiUpdateTime *string `orm:"imei_update_time"`
}
if err := g.DB().Model("t_sim_card_info").Where("id", id).Scan(&row); err != nil {
t.Fatal(err)
}
return row.Imei, row.ImeiSyncTime, row.ImeiUpdateTime
}
// TestApplyBatchValueChangeWritesAllThreeColumns 值变更时三列同时写。
func TestApplyBatchValueChangeWritesAllThreeColumns(t *testing.T) {
ctx := context.Background()
setupDB(t, ctx)
repo := NewRepository()
if err := repo.ApplyBatch(ctx, []Result{
{CardID: 1, Synced: true, ValueChanged: true, Imei: "865466070817587"},
}); err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
imei, syncTime, updateTime := readCard(t, ctx, 1)
if imei != "865466070817587" {
t.Fatalf("imei = %q", imei)
}
if syncTime == nil || updateTime == nil {
t.Fatalf("值变更应同时写两个时间戳: sync=%v update=%v", syncTime, updateTime)
}
}
// TestApplyBatchHeartbeatDoesNotTouchValue 心跳只写 imei_sync_time,
// 绝不改动 imei 与 imei_update_time。这是 §5.2 的核心保证。
func TestApplyBatchHeartbeatDoesNotTouchValue(t *testing.T) {
ctx := context.Background()
setupDB(t, ctx)
repo := NewRepository()
if err := repo.ApplyBatch(ctx, []Result{
{CardID: 1, Synced: true, ValueChanged: false},
}); err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
imei, syncTime, updateTime := readCard(t, ctx, 1)
if imei != "OLD-IMEI-1" {
t.Fatalf("心跳不得改动 imei: %q", imei)
}
if syncTime == nil {
t.Fatal("心跳应写 imei_sync_time")
}
if updateTime != nil {
t.Fatalf("心跳不得写 imei_update_time: %v", *updateTime)
}
}
// TestApplyBatchUnsyncedIsSkipped 未同步(查询失败)的卡完全不入库。
func TestApplyBatchUnsyncedIsSkipped(t *testing.T) {
ctx := context.Background()
setupDB(t, ctx)
repo := NewRepository()
if err := repo.ApplyBatch(ctx, []Result{
{CardID: 1, Synced: false},
{CardID: 2, Synced: false, ValueChanged: true, Imei: "999999999999999"},
}); err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
for _, id := range []int64{1, 2} {
imei, syncTime, updateTime := readCard(t, ctx, id)
if syncTime != nil || updateTime != nil {
t.Fatalf("card %d: 未同步不应写时间戳: sync=%v update=%v", id, syncTime, updateTime)
}
_ = imei
}
}
// TestApplyBatchMixedGroups 混合批次:值变更与心跳分组写入,互不干扰。
func TestApplyBatchMixedGroups(t *testing.T) {
ctx := context.Background()
setupDB(t, ctx)
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: 4, Synced: true, ValueChanged: true, Imei: "111222333444555"}, // 从空到有值
}); err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if imei, syncTime, updateTime := readCard(t, ctx, 1); imei != "865466070817587" || syncTime == nil || updateTime == nil {
t.Fatalf("card 1 应值变更: imei=%q sync=%v update=%v", imei, syncTime, updateTime)
}
if imei, syncTime, updateTime := readCard(t, ctx, 2); imei != "OLD-IMEI-2" || syncTime == nil || updateTime != nil {
t.Fatalf("card 2 应仅心跳: imei=%q sync=%v update=%v", imei, syncTime, updateTime)
}
if imei, syncTime, updateTime := readCard(t, ctx, 3); imei != "OLD-IMEI-3" || syncTime != nil || updateTime != nil {
t.Fatalf("card 3 应完全不动: imei=%q sync=%v update=%v", imei, syncTime, updateTime)
}
if imei, syncTime, updateTime := readCard(t, ctx, 4); imei != "111222333444555" || syncTime == nil || updateTime == nil {
t.Fatalf("card 4 应从空到有值: imei=%q sync=%v update=%v", imei, syncTime, updateTime)
}
}
// TestApplyBatchRejectsInvalidImeiValue 非数字值必须被拒绝,
// 因为 CASE WHEN 是拼接 SQL,值本身没有绑定参数保护。
func TestApplyBatchRejectsInvalidImeiValue(t *testing.T) {
ctx := context.Background()
setupDB(t, ctx)
repo := NewRepository()
malicious := []string{
"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{
{CardID: 1, Synced: true, ValueChanged: true, Imei: value},
})
if err == nil {
t.Fatalf("非法值 %q 应被拒绝", value)
}
}
// 拒绝后数据必须保持原样
if imei, _, _ := readCard(t, ctx, 1); imei != "OLD-IMEI-1" {
t.Fatalf("被拒绝的批次不得改动数据: %q", imei)
}
}
// TestApplyBatchAcceptsBoundaryLengths 15/17 位正常 IMEI 与列宽边界值应通过。
func TestApplyBatchAcceptsBoundaryLengths(t *testing.T) {
ctx := context.Background()
setupDB(t, ctx)
repo := NewRepository()
values := []string{
"865466070817587", // 15 位
"86546607081758712", // 17 位
strings.Repeat("9", 32), // varchar(32) 上限
}
for i, value := range values {
cardID := int64(i + 1)
if err := repo.ApplyBatch(ctx, []Result{
{CardID: cardID, Synced: true, ValueChanged: true, Imei: value},
}); err != nil {
t.Fatalf("合法值 %q 应被接受: %v", value, err)
}
if imei, _, _ := readCard(t, ctx, cardID); imei != value {
t.Fatalf("card %d: imei = %q, want %q", cardID, imei, value)
}
}
}
// TestApplyBatchRejectsInvalidCardID 非法卡 ID 拒绝整批。
func TestApplyBatchRejectsInvalidCardID(t *testing.T) {
ctx := context.Background()
setupDB(t, ctx)
repo := NewRepository()
if err := repo.ApplyBatch(ctx, []Result{
{CardID: 1, Synced: true, ValueChanged: true, Imei: "865466070817587"},
{CardID: 0, Synced: true, ValueChanged: true, Imei: "111222333444555"},
}); err == nil {
t.Fatal("非法 cardId 应被拒绝")
}
}
// TestApplyBatchRejectsOversizedBatch 超大批次拒绝,防止单条 SQL 无限增长。
func TestApplyBatchRejectsOversizedBatch(t *testing.T) {
ctx := context.Background()
setupDB(t, ctx)
repo := NewRepository()
results := make([]Result, 0, maxImeiSyncBatch+1)
for i := 0; i <= maxImeiSyncBatch; i++ {
results = append(results, Result{CardID: int64(i + 1), Synced: true})
}
if err := repo.ApplyBatch(ctx, results); err == nil {
t.Fatal("超大批次应被拒绝")
}
}
// TestApplyBatchEmptyIsNoop 空批次不发 SQL。
func TestApplyBatchEmptyIsNoop(t *testing.T) {
ctx := context.Background()
setupDB(t, ctx)
repo := NewRepository()
if err := repo.ApplyBatch(ctx, nil); err != nil {
t.Fatalf("空批次应无操作: %v", err)
}
if err := repo.ApplyBatch(ctx, []Result{}); err != nil {
t.Fatalf("空切片应无操作: %v", err)
}
}
// TestApplyBatchAllUnsyncedIssuesNoSQL 全部未同步时不应发任何 UPDATE。
func TestApplyBatchAllUnsyncedIssuesNoSQL(t *testing.T) {
ctx := context.Background()
setupDB(t, ctx)
repo := NewRepository()
if err := repo.ApplyBatch(ctx, []Result{
{CardID: 1, Synced: false},
{CardID: 2, Synced: false},
}); err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
for _, id := range []int64{1, 2} {
if _, syncTime, updateTime := readCard(t, ctx, id); syncTime != nil || updateTime != nil {
t.Fatalf("card %d 不应被改动", id)
}
}
}
// TestImeiValuePattern documents the accepted value grammar.
func TestImeiValuePattern(t *testing.T) {
valid := []string{"1", "865466070817587", strings.Repeat("9", 32)}
invalid := []string{"", "abc", "865466070817587'", "865-466", " 865466070817587", strings.Repeat("9", 33), "865466070817587;1"}
for _, value := range valid {
if !imeiValuePattern.MatchString(value) {
t.Fatalf("%q 应被接受", value)
}
}
for _, value := range invalid {
if imeiValuePattern.MatchString(value) {
t.Fatalf("%q 应被拒绝", value)
}
}
}
@@ -0,0 +1,914 @@
package inbound
import (
"context"
"os"
"path/filepath"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/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"
inbound "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/inbound"
)
const stockTable = "t_sim_card_stock"
const (
inboundStockQueryBatchSizeConfigKey = "batch.inbound.stockQueryBatchSize"
inboundWriteBatchSizeConfigKey = "batch.inbound.writeBatchSize"
defaultInboundStockQueryBatchSize = 1000
defaultInboundWriteBatchSize = 1000
)
type Repository struct {
db gdb.DB
resultFiles batchresult.Generator
}
type stockDO struct {
g.Meta `orm:"table:t_sim_card_stock, do:true"`
BatchNo any `orm:"batch_no"`
ICCID any `orm:"iccid"`
MSISDN any `orm:"msisdn"`
IMSI any `orm:"imsi"`
IMEI any `orm:"imei"`
ChanKey any `orm:"chan_key"`
TenantID any `orm:"tenant_id"`
CreatedDept any `orm:"created_dept"`
CreatedBy any `orm:"created_by"`
UpdatedBy any `orm:"updated_by"`
}
func New() *Repository {
return &Repository{db: dao.BatchMain.DB(), resultFiles: batchresult.New()}
}
func (r *Repository) database() gdb.DB {
if r.db != nil {
return r.db
}
return g.DB()
}
func (r *Repository) Load(ctx context.Context, batchNo string) (*inbound.Task, error) {
if batchNo == "" {
return nil, gerror.New("inbound batch number is empty")
}
var batch entity.BatchMain
if err := r.database().Model(dao.BatchMain.Table()).Safe().Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeInbound).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Scan(&batch); err != nil {
return nil, gerror.Wrap(err, "query inbound batch")
}
if batch.Id == 0 {
return nil, gerror.New("processing inbound batch does not exist")
}
task := &inbound.Task{
Batch: inbound.Batch{
ID: batch.Id,
BatchNo: batch.BatchNo,
BatchType: batch.BatchType,
SourceType: batch.SourceType,
TenantID: batch.TenantId,
CreatedDept: batch.CreatedDept,
CreatedBy: batch.CreatedBy,
StartTime: batch.StartTime,
BatchInfo: batch.BatchInfo,
},
}
task.Source = parseSource(batch.BatchInfo, batch.SourceOssId, batch.SourceType)
task.Options = parseOptions(batch.BatchInfo)
return task, nil
}
func (r *Repository) ReadOssFile(ctx context.Context, ossID int64) ([]byte, string, error) {
if ossID <= 0 {
return nil, "", gerror.New("oss id is empty")
}
var oss entity.SysOss
if err := r.database().Model(dao.SysOss.Table()).Safe().Ctx(ctx).
Where(dao.SysOss.Columns().OssId, ossID).
Scan(&oss); err != nil {
return nil, "", gerror.Wrap(err, "query OSS file")
}
if oss.OssId == 0 {
return nil, "", gerror.Newf("OSS file %d does not exist", ossID)
}
if oss.Service != "local" {
return nil, "", gerror.Newf("OSS file %d is not a local file: service=%q", ossID, oss.Service)
}
uploadRoot := g.Cfg().MustGet(ctx, "system.upload.local.path").String()
localPath, attempts, err := resolveReadableLocalOssPath(uploadRoot, oss.TenantId, oss.Path)
if err != nil {
return nil, fileDisplayName(oss, ""), gerror.Wrapf(err, "resolve OSS file %d local path from %q, attempted paths: %s", ossID, oss.Path, strings.Join(attempts, "; "))
}
content, err := os.ReadFile(localPath)
if err != nil {
return nil, fileDisplayName(oss, localPath), gerror.Wrapf(err, "OSS file %d local path is unreadable: original path=%q, resolved path=%q", ossID, oss.Path, localPath)
}
return content, fileDisplayName(oss, localPath), nil
}
func resolveLocalOssPath(uploadRoot, tenantID, storedPath string) (string, error) {
uploadRoot = strings.TrimSpace(uploadRoot)
if uploadRoot == "" {
return "", gerror.New("system.upload.local.path is empty")
}
tenantID = strings.TrimSpace(tenantID)
if tenantID == "" {
tenantID = "default"
}
storedPath = strings.TrimSpace(storedPath)
if storedPath == "" {
return "", gerror.New("OSS path is empty")
}
objectSegments, err := ossObjectSegments(tenantID, storedPath)
if err != nil {
return "", err
}
objectPath := filepath.Join(objectSegments...)
localPath := filepath.Join(uploadRoot, objectPath)
relativePath, err := filepath.Rel(uploadRoot, localPath)
if err != nil {
return "", gerror.Wrap(err, "resolve local OSS path")
}
if relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) {
return "", gerror.New("resolved OSS path escapes upload root")
}
return localPath, nil
}
func resolveReadableLocalOssPath(uploadRoot, tenantID, storedPath string) (string, []string, error) {
var attempts []string
var firstErr error
for _, root := range uploadRootCandidates(uploadRoot) {
localPath, err := resolveLocalOssPath(root, tenantID, storedPath)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
attempts = appendUniqueString(attempts, localPath)
if isReadableFile(localPath) {
return localPath, attempts, nil
}
}
if _, err := ossObjectSegments(strings.TrimSpace(defaultTenant(tenantID)), storedPath); err != nil {
if firstErr == nil {
firstErr = err
}
} else {
for _, candidate := range storedPathCandidates(storedPath) {
attempts = appendUniqueString(attempts, candidate)
if isReadableFile(candidate) {
return candidate, attempts, nil
}
}
}
if firstErr != nil && len(attempts) == 0 {
return "", attempts, firstErr
}
return "", attempts, gerror.New("OSS local file does not exist")
}
func uploadRootCandidates(uploadRoot string) []string {
var candidates []string
for _, root := range []string{
os.Getenv("IOTLCM_WORKER_UPLOAD_ROOT"),
os.Getenv("IOTLCM_UPLOAD_ROOT"),
uploadRoot,
} {
root = strings.TrimSpace(root)
if root == "" {
continue
}
candidates = appendUploadRootCandidate(candidates, root)
}
return candidates
}
func appendUploadRootCandidate(candidates []string, root string) []string {
for _, candidate := range uploadRootEquivalentCandidates(root) {
if filepath.IsAbs(candidate) {
candidates = appendUniqueString(candidates, candidate)
} else {
for _, base := range localBaseDirs() {
candidates = appendUniqueString(candidates, filepath.Join(base, candidate))
}
candidates = appendUniqueString(candidates, candidate)
}
}
return candidates
}
func uploadRootEquivalentCandidates(root string) []string {
root = strings.TrimSpace(root)
if root == "" {
return nil
}
candidates := []string{root}
normalized := filepath.ToSlash(filepath.Clean(root))
if normalized == "resource/upload" || strings.HasSuffix(normalized, "/resource/upload") {
prefix := strings.TrimSuffix(normalized, "resource/upload")
candidates = appendUniqueString(candidates, filepath.FromSlash(prefix+"upload"))
}
return candidates
}
func storedPathCandidates(storedPath string) []string {
storedPath = strings.TrimSpace(storedPath)
if storedPath == "" {
return nil
}
var candidates []string
candidates = appendUniqueString(candidates, storedPath)
if !filepath.IsAbs(storedPath) {
for _, base := range localBaseDirs() {
candidates = appendUniqueString(candidates, filepath.Join(base, storedPath))
}
}
return candidates
}
func localBaseDirs() []string {
var dirs []string
if wd, err := os.Getwd(); err == nil && strings.TrimSpace(wd) != "" {
dirs = appendUniqueString(dirs, wd)
}
if exe, err := os.Executable(); err == nil && strings.TrimSpace(exe) != "" {
dirs = appendUniqueString(dirs, filepath.Dir(exe))
}
return dirs
}
func ossObjectSegments(tenantID, storedPath string) ([]string, error) {
tenantID = defaultTenant(tenantID)
segments := strings.FieldsFunc(strings.ReplaceAll(storedPath, "\\", "/"), func(r rune) bool {
return r == '/'
})
for index, segment := range segments {
if segment != tenantID {
continue
}
objectSegments := segments[index:]
for _, objectSegment := range objectSegments {
if objectSegment == "." || objectSegment == ".." {
return nil, gerror.Newf("invalid OSS object path segment %q", objectSegment)
}
}
return objectSegments, nil
}
return nil, gerror.Newf("OSS path does not contain tenant %q", tenantID)
}
func defaultTenant(tenantID string) string {
tenantID = strings.TrimSpace(tenantID)
if tenantID == "" {
return "default"
}
return tenantID
}
func isReadableFile(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func appendUniqueString(values []string, value string) []string {
value = strings.TrimSpace(value)
if value == "" {
return values
}
for _, existing := range values {
if existing == value {
return values
}
}
return append(values, value)
}
func uniqueStrings(values []string) []string {
if len(values) == 0 {
return nil
}
uniq := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
uniq = append(uniq, value)
}
return uniq
}
func (r *Repository) FindExistingStocks(ctx context.Context, _ string, iccids, msisdns, imsis []string) ([]inbound.Stock, error) {
if len(iccids) == 0 && len(msisdns) == 0 && len(imsis) == 0 {
return nil, nil
}
type queryResult struct {
stocks []inbound.Stock
err error
}
results := make(chan queryResult, 3)
queries := 0
if len(iccids) > 0 {
queries++
go func() {
stocks, err := r.findExistingStocksByField(ctx, "iccid", "ICCID", iccids)
results <- queryResult{stocks: stocks, err: err}
}()
}
if len(msisdns) > 0 {
queries++
go func() {
stocks, err := r.findExistingStocksByField(ctx, "msisdn", "MSISDN", msisdns)
results <- queryResult{stocks: stocks, err: err}
}()
}
if len(imsis) > 0 {
queries++
go func() {
stocks, err := r.findExistingStocksByField(ctx, "imsi", "IMSI", imsis)
results <- queryResult{stocks: stocks, err: err}
}()
}
stocks := make([]inbound.Stock, 0)
for i := 0; i < queries; i++ {
result := <-results
if result.err != nil {
return nil, result.err
}
stocks = append(stocks, result.stocks...)
}
return stocks, nil
}
func (r *Repository) stockQuery(ctx context.Context) *gdb.Model {
return r.database().Model(stockTable).Safe().Ctx(ctx)
}
func (r *Repository) findExistingStocksByField(ctx context.Context, field, label string, values []string) ([]inbound.Stock, error) {
uniq := uniqueStrings(values)
if len(uniq) == 0 {
return nil, nil
}
batchSize := inboundStockQueryBatchSize(ctx)
stocks := make([]inbound.Stock, 0)
for _, chunk := range chunkStrings(uniq, batchSize) {
var chunkStocks []inbound.Stock
model := r.stockQuery(ctx).WhereIn(field, chunk)
if err := model.Fields("iccid", "msisdn", "imsi").Scan(&chunkStocks); err != nil {
return nil, gerror.Wrapf(err, "query inbound stock %s duplicates", label)
}
stocks = append(stocks, chunkStocks...)
}
return stocks, nil
}
func (r *Repository) SaveValidationFailure(ctx context.Context, task *inbound.Task) 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
}
return updateBatchMain(ctx, tx, task, do.BatchMain{
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
TotalCount: len(task.Rows),
ValidCount: task.ValidCount,
SuccessCount: 0,
FailCount: failCount,
RunResult: consts.BatchRunResultFailed,
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 {
return r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
batchInfo := buildInboundBatchInfo(task)
task.Batch.BatchInfo = batchInfo
stocks := make([]stockDO, 0, len(task.Rows))
details := make([]do.BatchDetail, 0, len(task.Rows))
for _, row := range task.Rows {
if row == nil {
continue
}
stocks = append(stocks, stockDO{
BatchNo: task.Batch.BatchNo,
ICCID: row.ICCID,
MSISDN: nullableString(row.MSISDN),
IMSI: nullableString(row.IMSI),
IMEI: nullableString(row.IMEI),
ChanKey: row.ChanKey,
TenantID: task.Batch.TenantID,
CreatedDept: task.Batch.CreatedDept,
CreatedBy: task.Batch.CreatedBy,
UpdatedBy: task.Batch.CreatedBy,
})
details = append(details, batchDetailRow(task, row, consts.BatchDetailStatusSuccess))
}
if err := insertChunkedRows(ctx, tx, stockTable, "inbound stock", stocks, inboundWriteBatchSize(ctx)); err != nil {
return err
}
if err := insertChunkedRows(ctx, tx, dao.BatchDetail.Table(), "inbound detail", details, inboundWriteBatchSize(ctx)); err != nil {
return err
}
return updateBatchMain(ctx, tx, task, do.BatchMain{
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
TotalCount: len(task.Rows),
ValidCount: len(task.Rows),
SuccessCount: len(task.Rows),
FailCount: 0,
RunResult: consts.BatchRunResultSuccess,
FailReason: "",
BatchInfo: batchInfo,
EndTime: gtime.Now(),
UpdatedBy: task.Batch.CreatedBy,
})
})
}
func (r *Repository) SaveBusinessFailure(ctx context.Context, task *inbound.Task, reason string) error {
task.ValidCount = countRowsWithoutReason(task.Rows)
for _, row := range task.Rows {
if row != nil {
row.Reason = reason
}
}
task.FailureReason = reason
return r.SaveValidationFailure(ctx, task)
}
func buildBatchDetails(task *inbound.Task, status int) []do.BatchDetail {
details := make([]do.BatchDetail, 0, len(task.Rows))
for _, row := range task.Rows {
if row == nil {
continue
}
details = append(details, batchDetailRow(task, row, status))
}
return details
}
func batchDetailRow(task *inbound.Task, row *inbound.Row, status int) do.BatchDetail {
return do.BatchDetail{
BatchNo: task.Batch.BatchNo,
BatchType: task.Batch.BatchType,
DetailStatus: status,
BlankOutStatus: 1,
RowNo: row.RowNo,
ItemId: batchfields.ItemID(row.ICCID),
ErrorReason: batchfields.DetailReason(row.Reason),
Snapshot: rowSnapshot(row),
TenantId: task.Batch.TenantID,
CreatedDept: task.Batch.CreatedDept,
CreatedBy: task.Batch.CreatedBy,
UpdatedBy: task.Batch.CreatedBy,
}
}
func (r *Repository) SaveResultFile(ctx context.Context, task *inbound.Task, request batchresult.Request) error {
artifact, err := r.artifactGenerator().Create(ctx, request)
if err != nil {
return r.updateResultFileStatus(ctx, task, "failed", 0, 0, err.Error())
}
var (
ossID int64
downloadID int64
)
err = r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
var insertErr error
ossID, insertErr = tx.Ctx(ctx).Model(dao.SysOss.Table()).Data(do.SysOss{
TenantId: task.Batch.TenantID,
FileName: artifact.FileName,
OriginalName: artifact.OriginalName,
FileSuffix: artifact.FileSuffix,
Path: artifact.Path,
Url: artifact.URL,
CreatedDept: task.Batch.CreatedDept,
CreatedBy: task.Batch.CreatedBy,
UpdatedBy: task.Batch.CreatedBy,
Service: "local",
Md5: artifact.MD5,
FileSize: artifact.Size,
}).InsertAndGetId()
if insertErr != nil {
return gerror.Wrap(insertErr, "insert inbound result OSS")
}
downloadID, insertErr = tx.Ctx(ctx).Model(dao.TResourceBatchDownload.Table()).Data(do.TResourceBatchDownload{
BatchNo: task.Batch.BatchNo,
TaskName: batchresult.TaskName(task.Batch.BatchType),
ResourceType: consts.ResourceTypeExcel,
ExportScene: task.Batch.BatchType,
TotalCount: len(task.Rows),
SuccessCount: successRows(task.Rows),
FailCount: failedRows(task.Rows),
DownloadStatus: consts.DownloadStatusGenerated,
OssId: ossID,
FileName: artifact.FileName,
FileExt: artifact.FileSuffix,
FileUrl: artifact.URL,
FilePath: artifact.Path,
FileSize: artifact.Size,
FileMd5: artifact.MD5,
DownloadCount: 0,
MaxDownloadCount: 0,
DeptId: task.Batch.CreatedDept,
StartTime: task.Batch.StartTime,
FinishTime: gtime.Now(),
TenantId: task.Batch.TenantID,
CreatedDept: task.Batch.CreatedDept,
CreatedBy: task.Batch.CreatedBy,
UpdatedBy: task.Batch.CreatedBy,
}).InsertAndGetId()
if insertErr != nil {
return gerror.Wrap(insertErr, "insert inbound result download")
}
return updateBatchInfoOnly(ctx, tx, task, buildResultBatchInfo(task, "success", ossID, downloadID, ""), ossID)
})
if err != nil {
batchresult.RemoveLocalFile(artifact)
return r.updateResultFileStatus(ctx, task, "failed", 0, 0, err.Error())
}
return nil
}
func (r *Repository) artifactGenerator() batchresult.Generator {
if r.resultFiles != nil {
return r.resultFiles
}
return batchresult.New()
}
func (r *Repository) updateResultFileStatus(ctx context.Context, task *inbound.Task, status string, ossID, downloadID int64, reason string) error {
return r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
return updateBatchInfoOnly(ctx, tx, task, buildResultBatchInfo(task, status, ossID, downloadID, reason), ossID)
})
}
func insertChunkedRows[T any](ctx context.Context, tx gdb.TX, table, label string, rows []T, batchSize int) error {
if len(rows) == 0 {
return nil
}
if batchSize <= 0 {
batchSize = defaultInboundWriteBatchSize
}
for start := 0; start < len(rows); start += batchSize {
end := start + batchSize
if end > len(rows) {
end = len(rows)
}
if _, err := tx.Ctx(ctx).Model(table).Data(rows[start:end]).Insert(); err != nil {
return gerror.Wrapf(err, "insert %s chunk", label)
}
}
return nil
}
func chunkStrings(values []string, batchSize int) [][]string {
if batchSize <= 0 {
batchSize = defaultInboundStockQueryBatchSize
}
if len(values) == 0 {
return nil
}
chunks := make([][]string, 0, (len(values)+batchSize-1)/batchSize)
for start := 0; start < len(values); start += batchSize {
end := start + batchSize
if end > len(values) {
end = len(values)
}
chunks = append(chunks, values[start:end])
}
return chunks
}
func inboundStockQueryBatchSize(ctx context.Context) int {
value := g.Cfg().MustGet(ctx, inboundStockQueryBatchSizeConfigKey, defaultInboundStockQueryBatchSize).Int()
if value <= 0 {
return defaultInboundStockQueryBatchSize
}
return value
}
func inboundWriteBatchSize(ctx context.Context) int {
value := g.Cfg().MustGet(ctx, inboundWriteBatchSizeConfigKey, defaultInboundWriteBatchSize).Int()
if value <= 0 {
return defaultInboundWriteBatchSize
}
return value
}
func updateBatchMain(ctx context.Context, tx gdb.TX, task *inbound.Task, data do.BatchMain) error {
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchMain.Columns().BatchType, task.Batch.BatchType).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Data(data).
Update()
if err != nil {
return gerror.Wrap(err, "update inbound batch main")
}
affected, err := result.RowsAffected()
if err != nil {
return gerror.Wrap(err, "read inbound batch main update result")
}
if affected != 1 {
return gerror.New("inbound batch is no longer processing")
}
return nil
}
func updateBatchInfoOnly(ctx context.Context, tx gdb.TX, task *inbound.Task, info *gjson.Json, resultOssID int64) error {
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchMain.Columns().BatchType, task.Batch.BatchType).
Data(do.BatchMain{
ResultOssId: workerOssIDValue(resultOssID),
BatchInfo: info,
UpdatedBy: task.Batch.CreatedBy,
}).
Update()
if err != nil {
return gerror.Wrap(err, "update inbound result file status")
}
affected, err := result.RowsAffected()
if err != nil {
return gerror.Wrap(err, "read inbound result file update result")
}
if affected != 1 {
return gerror.New("inbound batch disappeared while saving result file")
}
task.Batch.BatchInfo = info
return nil
}
func workerOssIDValue(ossID int64) any {
if ossID <= 0 {
return nil
}
return ossID
}
func parseSource(info *gjson.Json, sourceOssID int64, sourceType int) inbound.Source {
source := inbound.Source{
ImportFileOssID: jsonInt64(info, "source.importFileOssId", "importFileOssId"),
InputText: jsonString(info, "source.inputText", "inputText"),
InputFileOssID: jsonInt64(info, "source.inputFileOssId", "inputFileOssId"),
Range: inbound.Range{
RangeType: jsonString(info, "source.range.rangeType", "range.rangeType", "rangeType"),
StartICCID: jsonString(info, "source.range.startIccid", "range.startIccid", "startIccid"),
EndICCID: jsonString(info, "source.range.endIccid", "range.endIccid", "endIccid"),
StartMSISDN: jsonString(info, "source.range.startMsisdn", "range.startMsisdn", "startMsisdn"),
EndMSISDN: jsonString(info, "source.range.endMsisdn", "range.endMsisdn", "endMsisdn"),
StartIMSI: jsonString(info, "source.range.startImsi", "range.startImsi", "startImsi"),
EndIMSI: jsonString(info, "source.range.endImsi", "range.endImsi", "endImsi"),
},
}
if sourceOssID > 0 {
switch sourceType {
case consts.BatchSourceTypeBatchInput:
source.InputFileOssID = sourceOssID
default:
source.ImportFileOssID = sourceOssID
}
}
return source
}
func parseOptions(info *gjson.Json) inbound.Options {
return inbound.Options{
ChanKey: jsonString(info, "inboundOptions.chanKey", "chanKey"),
ValidateUpstreamBasicInfo: jsonString(info, "inboundOptions.validateUpstreamBasicInfo", "validateUpstreamBasicInfo"),
CardType: jsonString(info, "inboundOptions.cardType", "cardType"),
UpPkgCode: jsonString(info, "inboundOptions.upPkgCode", "upPkgCode"),
SuppliersCode: jsonString(info, "inboundOptions.suppliersCode", "suppliersCode"),
UpstreamActivationTime: jsonString(info, "inboundOptions.upstreamActivationTime", "upstreamActivationTime"),
InsertToPlugEnabled: jsonString(info, "inboundOptions.insertToPlugEnabled", "insertToPlugEnabled"),
CarrierTrafficCutoffEnabled: jsonString(info, "inboundOptions.carrierTrafficCutoffEnabled", "carrierTrafficCutoffEnabled"),
AgreementExpiryDate: jsonString(info, "inboundOptions.agreementExpiryDate", "agreementExpiryDate"),
HasTestPeriod: jsonString(info, "inboundOptions.hasTestPeriod", "hasTestPeriod"),
TestPeriodDays: jsonString(info, "inboundOptions.testPeriodDays", "testPeriodDays"),
TestPeriodLatestExpiryDate: jsonString(info, "inboundOptions.testPeriodLatestExpiryDate", "testPeriodLatestExpiryDate"),
HasSilentPeriod: jsonString(info, "inboundOptions.hasSilentPeriod", "hasSilentPeriod"),
SilentPeriodDays: jsonString(info, "inboundOptions.silentPeriodDays", "silentPeriodDays"),
SilentPeriodLatestExpiryDate: jsonString(info, "inboundOptions.silentPeriodLatestExpiryDate", "silentPeriodLatestExpiryDate"),
}
}
func jsonString(info *gjson.Json, paths ...string) string {
if info == nil {
return ""
}
for _, path := range paths {
value := strings.TrimSpace(info.Get(path).String())
if value != "" {
return value
}
}
return ""
}
func jsonInt64(info *gjson.Json, paths ...string) int64 {
if info == nil {
return 0
}
for _, path := range paths {
value := info.Get(path).Int64()
if value > 0 {
return value
}
}
return 0
}
func rowSnapshot(row *inbound.Row) *gjson.Json {
raw := map[string]any{}
for key, value := range row.Raw {
raw[key] = value
}
return gjson.New(map[string]any{
"rowNo": row.RowNo,
"inputCode": row.InputCode,
"ICCID": row.ICCID,
"MSISDN": row.MSISDN,
"IMSI": row.IMSI,
"IMEI": row.IMEI,
"chanKey": row.ChanKey,
"cardType": row.CardType,
"upPkgCode": row.UpPkgCode,
"suppliersCode": row.SuppliersCode,
"upstreamActivationTime": row.UpstreamActivationTime,
"insertToPlugEnabled": row.InsertToPlugEnabled,
"carrierTrafficCutoffEnabled": row.CarrierTrafficCutoffEnabled,
"agreementExpiryDate": row.AgreementExpiryDate,
"hasTestPeriod": row.HasTestPeriod,
"testPeriodDays": row.TestPeriodDays,
"testPeriodLatestExpiryDate": row.TestPeriodLatestExpiryDate,
"hasSilentPeriod": row.HasSilentPeriod,
"silentPeriodDays": row.SilentPeriodDays,
"silentPeriodLatestExpiryDate": row.SilentPeriodLatestExpiryDate,
"rawFields": raw,
})
}
func buildInboundBatchInfo(task *inbound.Task) *gjson.Json {
if task == nil {
return gjson.New(map[string]any{})
}
info := make(map[string]any)
if task != nil && task.Batch.BatchInfo != nil {
for key, value := range task.Batch.BatchInfo.Map() {
info[key] = value
}
}
info["inboundOptions"] = map[string]any{
"chanKey": task.Options.ChanKey,
"validateUpstreamBasicInfo": task.Options.ValidateUpstreamBasicInfo,
"cardType": task.Options.CardType,
"upPkgCode": task.Options.UpPkgCode,
"suppliersCode": task.Options.SuppliersCode,
"upstreamActivationTime": task.Options.UpstreamActivationTime,
"insertToPlugEnabled": task.Options.InsertToPlugEnabled,
"carrierTrafficCutoffEnabled": task.Options.CarrierTrafficCutoffEnabled,
"agreementExpiryDate": task.Options.AgreementExpiryDate,
"hasTestPeriod": task.Options.HasTestPeriod,
"testPeriodDays": task.Options.TestPeriodDays,
"testPeriodLatestExpiryDate": task.Options.TestPeriodLatestExpiryDate,
"hasSilentPeriod": task.Options.HasSilentPeriod,
"silentPeriodDays": task.Options.SilentPeriodDays,
"silentPeriodLatestExpiryDate": task.Options.SilentPeriodLatestExpiryDate,
}
return gjson.New(info)
}
func buildResultBatchInfo(task *inbound.Task, status string, ossID, downloadID int64, reason string) *gjson.Json {
info := make(map[string]any)
if task.Batch.BatchInfo != nil {
for key, value := range task.Batch.BatchInfo.Map() {
info[key] = value
}
}
resultFile := map[string]any{"status": status}
if ossID > 0 {
resultFile["ossId"] = ossID
}
if downloadID > 0 {
resultFile["downloadId"] = downloadID
}
if reason != "" {
resultFile["error"] = reason
}
info["resultFile"] = resultFile
return gjson.New(info)
}
func fileDisplayName(oss entity.SysOss, fallback string) string {
for _, value := range []string{oss.OriginalName, oss.FileName, fallback} {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return "unknown"
}
func nullableString(value string) any {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return value
}
func countRowsWithoutReason(rows []*inbound.Row) int {
count := 0
for _, row := range rows {
if row != nil && row.Reason == "" {
count++
}
}
return count
}
func successRows(rows []*inbound.Row) int {
count := 0
for _, row := range rows {
if row != nil && row.Reason == "" {
count++
}
}
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
}
@@ -0,0 +1,307 @@
package inbound
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
_ "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")
tests := []struct {
name string
tenantID string
storedPath string
wantPath string
wantErr bool
}{
{
name: "relative Admin path",
tenantID: "000000",
storedPath: "resource/upload/000000/user/file/20260721/source.xlsx",
wantPath: expectedPath,
},
{
name: "absolute Admin path",
tenantID: "000000",
storedPath: "/app/upload/000000/user/file/20260721/source.xlsx",
wantPath: expectedPath,
},
{
name: "default tenant",
tenantID: "",
storedPath: "resource/upload/default/user/file/20260721/source.xlsx",
wantPath: filepath.Join(uploadRoot, "default", "user", "file", "20260721", "source.xlsx"),
},
{
name: "tenant is missing",
tenantID: "000000",
storedPath: "resource/upload/000001/user/file/20260721/source.xlsx",
wantErr: true,
},
{
name: "path traversal is rejected",
tenantID: "000000",
storedPath: "resource/upload/000000/../secret.xlsx",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotPath, err := resolveLocalOssPath(uploadRoot, tt.tenantID, tt.storedPath)
if tt.wantErr {
if err == nil {
t.Fatal("expected an error")
}
return
}
if err != nil {
t.Fatalf("resolveLocalOssPath() error = %v", err)
}
if gotPath != tt.wantPath {
t.Fatalf("resolveLocalOssPath() = %q, want %q", gotPath, tt.wantPath)
}
})
}
}
func TestResolveLocalOssPathReturnsReadableFile(t *testing.T) {
uploadRoot := t.TempDir()
storedPath := "resource/upload/000000/user/file/20260721/source.xlsx"
localPath, err := resolveLocalOssPath(uploadRoot, "000000", storedPath)
if err != nil {
t.Fatalf("resolveLocalOssPath() error = %v", err)
}
if err = os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
t.Fatalf("create directory: %v", err)
}
if err = os.WriteFile(localPath, []byte("inbound source"), 0o600); err != nil {
t.Fatalf("write source file: %v", err)
}
content, err := os.ReadFile(localPath)
if err != nil {
t.Fatalf("read source file: %v", err)
}
if string(content) != "inbound source" {
t.Fatalf("unexpected source content: %q", content)
}
}
func TestUploadRootCandidatesIncludeMountedUploadDirectory(t *testing.T) {
wd := t.TempDir()
oldWd, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
if err = os.Chdir(wd); err != nil {
t.Fatalf("change working directory: %v", err)
}
defer func() {
_ = os.Chdir(oldWd)
}()
candidates := uploadRootCandidates("resource/upload")
want := filepath.Join(wd, "upload")
for _, candidate := range candidates {
if filepath.Clean(candidate) == filepath.Clean(want) {
return
}
}
t.Fatalf("uploadRootCandidates() = %#v, want %q", candidates, want)
}
func TestResolveReadableLocalOssPathUsesMountedUploadDirectory(t *testing.T) {
wd := t.TempDir()
oldWd, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
if err = os.Chdir(wd); err != nil {
t.Fatalf("change working directory: %v", err)
}
defer func() {
_ = os.Chdir(oldWd)
}()
storedPath := "resource/upload/000000/user/file/20260813/sim-card-inbound/import/source.xlsx"
mountedPath := filepath.Join(wd, "upload", "000000", "user", "file", "20260813", "sim-card-inbound", "import", "source.xlsx")
if err = os.MkdirAll(filepath.Dir(mountedPath), 0o755); err != nil {
t.Fatalf("create mounted upload directory: %v", err)
}
if err = os.WriteFile(mountedPath, []byte("xlsx"), 0o600); err != nil {
t.Fatalf("write mounted upload file: %v", err)
}
gotPath, attempts, err := resolveReadableLocalOssPath("resource/upload", "000000", storedPath)
if err != nil {
t.Fatalf("resolveReadableLocalOssPath() error = %v, attempts = %#v", err, attempts)
}
if gotPath != mountedPath {
t.Fatalf("resolveReadableLocalOssPath() = %q, want %q, attempts = %#v", gotPath, mountedPath, attempts)
}
}
func TestBuildResultBatchInfoUsesGroupedResultFile(t *testing.T) {
task := &serviceinbound.Task{
Batch: serviceinbound.Batch{
BatchInfo: gjson.New(map[string]any{
"source": map[string]any{"inputText": "8986"},
}),
},
}
info := buildResultBatchInfo(task, "failed", 0, 0, "disk full")
if info.Get("resultFile.status").String() != "failed" {
t.Fatalf("unexpected result file status: %s", info.Get("resultFile.status").String())
}
if info.Get("resultFile.error").String() != "disk full" {
t.Fatalf("unexpected result file error: %s", info.Get("resultFile.error").String())
}
if info.Get("resultFileOssId").Int64() != 0 || info.Get("resultDownloadId").Int64() != 0 {
t.Fatalf("result file metadata should stay grouped: %s", info.MustToJsonString())
}
}
func TestParseAndBuildInboundOptionsPreserveUpstreamValidationFlag(t *testing.T) {
info := gjson.New(map[string]any{
"inboundOptions": map[string]any{
"chanKey": "chan-a",
"validateUpstreamBasicInfo": 1,
},
})
options := parseOptions(info)
if options.ValidateUpstreamBasicInfo != "1" {
t.Fatalf("ValidateUpstreamBasicInfo = %q, want 1", options.ValidateUpstreamBasicInfo)
}
task := &serviceinbound.Task{
Batch: serviceinbound.Batch{BatchInfo: info},
Options: options,
}
built := buildInboundBatchInfo(task)
if built.Get("inboundOptions.validateUpstreamBasicInfo").String() != "1" {
t.Fatalf("validate option not preserved: %s", built.MustToJsonString())
}
if built.Get("inboundOptions.chanKey").String() != "chan-a" {
t.Fatalf("chanKey not preserved: %s", built.MustToJsonString())
}
}
func TestParseSourcePrefersSourceOssId(t *testing.T) {
tests := []struct {
name string
sourceType int
wantImportID int64
wantInputID int64
}{
{name: "file source", sourceType: consts.BatchSourceTypeFile, wantImportID: 88, wantInputID: 22},
{name: "input source", sourceType: consts.BatchSourceTypeBatchInput, wantImportID: 11, wantInputID: 88},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := gjson.New(map[string]any{
"source": map[string]any{
"importFileOssId": int64(11),
"inputFileOssId": int64(22),
},
})
source := parseSource(info, 88, tt.sourceType)
if source.ImportFileOssID != tt.wantImportID {
t.Fatalf("ImportFileOssID = %d, want %d", source.ImportFileOssID, tt.wantImportID)
}
if source.InputFileOssID != tt.wantInputID {
t.Fatalf("InputFileOssID = %d, want %d", source.InputFileOssID, tt.wantInputID)
}
})
}
}
@@ -0,0 +1,38 @@
package lifecycle
import (
"testing"
"github.com/gogf/gf/v2/encoding/gjson"
)
func TestPreferredLifecycleChanKey(t *testing.T) {
tests := []struct {
name string
appBatch bool
snapshot string
card string
want string
}{
{name: "app resolved snapshot wins", appBatch: true, snapshot: " legacy-129 ", card: "stale-1", want: "legacy-129"},
{name: "console keeps card channel", snapshot: "legacy-129", card: " card-112 ", want: "card-112"},
{name: "card fallback", card: " card-112 ", want: "card-112"},
{name: "empty", want: ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := preferredLifecycleChanKey(test.appBatch, test.snapshot, test.card); got != test.want {
t.Fatalf("preferredLifecycleChanKey() = %q, want %q", got, test.want)
}
})
}
}
func TestAppBatchUsesSnapshotChannel(t *testing.T) {
if !appBatchUsesSnapshotChannel(gjson.New(map[string]any{"source": "APP"})) {
t.Fatal("app batch should use the resolved snapshot channel")
}
if appBatchUsesSnapshotChannel(gjson.New(map[string]any{"source": "console"})) {
t.Fatal("console batch must continue using the current card channel")
}
}
@@ -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)
}
}
@@ -0,0 +1,432 @@
package lifecycle
import (
"context"
"strconv"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"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"
lifecycle "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/lifecycle"
)
type Repository struct {
db gdb.DB
resultFiles batchresult.Generator
}
func New() *Repository {
return &Repository{resultFiles: batchresult.New()}
}
func (r *Repository) database() gdb.DB {
if r.db != nil {
return r.db
}
return g.DB()
}
func (r *Repository) Load(ctx context.Context, batchNo string) (*lifecycle.Task, error) {
if batchNo == "" {
return nil, gerror.New("lifecycle batch number is empty")
}
batch := entity.BatchMain{}
if err := r.database().Model(dao.BatchMain.Table()).Safe().Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
WhereIn(dao.BatchMain.Columns().BatchType, []int{consts.BatchTypeSimCardStart, consts.BatchTypeSimCardStop}).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Scan(&batch); err != nil {
return nil, gerror.Wrap(err, "query lifecycle batch")
}
if batch.Id == 0 {
return nil, gerror.New("processing lifecycle batch does not exist")
}
operation, err := lifecycle.OperationByBatchType(batch.BatchType)
if err != nil {
return nil, err
}
var batchDetails []*entity.BatchDetail
if err = 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 lifecycle details")
}
if len(batchDetails) == 0 {
return nil, gerror.New("lifecycle batch has no running details")
}
task := &lifecycle.Task{
Batch: lifecycle.Batch{
ID: batch.Id,
BatchNo: batch.BatchNo,
BatchType: batch.BatchType,
TenantID: batch.TenantId,
CreatedBy: batch.CreatedBy,
CreatedDept: batch.CreatedDept,
StartTime: batch.StartTime,
BatchInfo: batch.BatchInfo,
},
Operation: operation,
Details: make([]*lifecycle.Detail, 0, len(batchDetails)),
}
for _, batchDetail := range batchDetails {
if batchDetail == nil {
continue
}
detail := &lifecycle.Detail{
ID: batchDetail.Id,
RowNo: batchDetail.RowNo,
ICCID: strings.TrimSpace(batchDetail.ItemId),
Reason: strings.TrimSpace(batchDetail.ErrorReason),
OriginalSnapshot: batchDetail.Snapshot,
}
if batchDetail.Snapshot != nil {
if value := strings.TrimSpace(batchDetail.Snapshot.Get("ICCID").String()); value != "" {
detail.ICCID = value
}
detail.InputMSISDN = strings.TrimSpace(batchDetail.Snapshot.Get("MSISDN").String())
detail.InputIMSI = strings.TrimSpace(batchDetail.Snapshot.Get("IMSI").String())
detail.MSISDN = detail.InputMSISDN
detail.IMSI = detail.InputIMSI
detail.Authorized = batchDetail.Snapshot.Get("authorized").Bool()
detail.ChanKey = strings.TrimSpace(batchDetail.Snapshot.Get("chanKey").String())
}
task.Details = append(task.Details, detail)
}
if len(task.Details) == 0 {
return nil, gerror.New("lifecycle batch has no readable running details")
}
if err = r.loadCards(ctx, task); err != nil {
return nil, err
}
return task, nil
}
func (r *Repository) loadCards(ctx context.Context, task *lifecycle.Task) error {
iccids := make([]string, 0, len(task.Details))
for _, detail := range task.Details {
if detail != nil && detail.ICCID != "" {
iccids = append(iccids, detail.ICCID)
}
}
if len(iccids) == 0 {
return nil
}
var cards []*entity.TSimCardInfo
if err := r.database().Model(dao.TSimCardInfo.Table()).Safe().Ctx(ctx).
Where(dao.TSimCardInfo.Columns().TenantId, task.Batch.TenantID).
WhereIn(dao.TSimCardInfo.Columns().Iccid, iccids).
Scan(&cards); err != nil {
return gerror.Wrap(err, "query lifecycle SIM cards")
}
byICCID := make(map[string]*entity.TSimCardInfo, len(cards))
for _, card := range cards {
if card != nil {
byICCID[strings.TrimSpace(card.Iccid)] = card
}
}
for _, detail := range task.Details {
card := byICCID[detail.ICCID]
if card == nil {
continue
}
detail.Card = &lifecycle.Card{
ID: card.Id,
ICCID: strings.TrimSpace(card.Iccid),
MSISDN: strings.TrimSpace(card.Msisdn),
IMSI: strings.TrimSpace(card.Imsi),
ChanKey: preferredLifecycleChanKey(appBatchUsesSnapshotChannel(task.Batch.BatchInfo), detail.ChanKey, card.ChanKey),
BossCardStatus: card.BossCardStatus,
}
}
return nil
}
func preferredLifecycleChanKey(useSnapshot bool, snapshotChanKey string, cardChanKey string) string {
if useSnapshot {
if value := strings.TrimSpace(snapshotChanKey); value != "" {
return value
}
}
return strings.TrimSpace(cardChanKey)
}
func appBatchUsesSnapshotChannel(info *gjson.Json) bool {
if info == nil {
return false
}
return strings.EqualFold(strings.TrimSpace(info.Get("source").String()), "app")
}
func (r *Repository) SaveValidationFailure(ctx context.Context, task *lifecycle.Task) error {
return r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, detail := range task.Details {
if detail == nil {
continue
}
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().Id, detail.ID).
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
Data(do.BatchDetail{
DetailStatus: consts.BatchDetailStatusFailed,
ErrorReason: detail.Reason,
Snapshot: detail.Snapshot(),
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.Newf("lifecycle detail %d is no longer running", detail.ID)
}
}
return nil
})
}
func (r *Repository) SaveResult(ctx context.Context, task *lifecycle.Task, detail *lifecycle.Detail, outcome lifecycle.Outcome) (lifecycle.Outcome, error) {
if detail == nil {
return lifecycle.Outcome{}, gerror.New("lifecycle detail is nil")
}
resolved := outcome
err := r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if resolved.Success {
if detail.Card == nil {
resolved.Success = false
resolved.Message = "卡记录不存在,未办理"
} else {
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
Where(dao.TSimCardInfo.Columns().Id, detail.Card.ID).
Where(dao.TSimCardInfo.Columns().TenantId, task.Batch.TenantID).
Where(dao.TSimCardInfo.Columns().BossCardStatus, detail.StatusBefore).
Data(do.TSimCardInfo{
BossCardStatus: task.Operation.TargetStatus,
StatusSyncTime: gtime.Now(),
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
resolved.Success = false
resolved.Message = "卡本地状态已变更"
}
}
}
detail.ApplyOutcome(resolved)
detailStatus := consts.BatchDetailStatusFailed
if resolved.Success {
detailStatus = consts.BatchDetailStatusSuccess
}
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().Id, detail.ID).
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
Data(do.BatchDetail{
DetailStatus: detailStatus,
ErrorCode: strconv.Itoa(resolved.Code),
ErrorReason: detail.Reason,
Snapshot: detail.Snapshot(),
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.Newf("lifecycle detail %d is no longer running", detail.ID)
}
return nil
})
return resolved, err
}
func (r *Repository) Finalize(ctx context.Context, task *lifecycle.Task) error {
successCount := 0
for _, detail := range task.Details {
if detail != nil && detail.Reason == "" {
successCount++
}
}
failCount := len(task.Details) - successCount
batchAsyncStatus, runResult := batchResult(successCount, failCount)
if task.FailureReason == "" && failCount > 0 {
task.FailureReason = lifecycleFailureSummary(task.Details, successCount, failCount)
}
validCount := 0
if task.ValidationOK {
validCount = len(task.Details)
}
artifact, err := r.artifactGenerator().Create(ctx, artifactRequest(task))
if err != nil {
return gerror.Wrap(err, "generate lifecycle result file")
}
now := gtime.Now()
err = r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
ossID, err := tx.Ctx(ctx).Model(dao.SysOss.Table()).Data(do.SysOss{
TenantId: task.Batch.TenantID,
FileName: artifact.FileName,
OriginalName: artifact.OriginalName,
FileSuffix: artifact.FileSuffix,
Path: artifact.Path,
Url: artifact.URL,
CreatedDept: task.Batch.CreatedDept,
CreatedBy: task.Batch.CreatedBy,
UpdatedBy: task.Batch.CreatedBy,
Service: "local",
Md5: artifact.MD5,
FileSize: artifact.Size,
}).InsertAndGetId()
if err != nil {
return gerror.Wrap(err, "insert lifecycle result OSS")
}
downloadID, err := tx.Ctx(ctx).Model(dao.TResourceBatchDownload.Table()).Data(do.TResourceBatchDownload{
BatchNo: task.Batch.BatchNo,
TaskName: batchresult.TaskName(task.Batch.BatchType),
ResourceType: consts.ResourceTypeExcel,
ExportScene: task.Batch.BatchType,
TotalCount: len(task.Details),
SuccessCount: successCount,
FailCount: failCount,
DownloadStatus: consts.DownloadStatusGenerated,
OssId: ossID,
FileName: artifact.FileName,
FileExt: artifact.FileSuffix,
FileUrl: artifact.URL,
FilePath: artifact.Path,
FileSize: artifact.Size,
FileMd5: artifact.MD5,
DownloadCount: 0,
MaxDownloadCount: 0,
DeptId: task.Batch.CreatedDept,
StartTime: task.Batch.StartTime,
FinishTime: now,
TenantId: task.Batch.TenantID,
CreatedDept: task.Batch.CreatedDept,
CreatedBy: task.Batch.CreatedBy,
UpdatedBy: task.Batch.CreatedBy,
}).InsertAndGetId()
if err != nil {
return gerror.Wrap(err, "insert lifecycle result download")
}
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Data(do.BatchMain{
BatchAsyncStatus: batchAsyncStatus,
ValidCount: validCount,
SuccessCount: successCount,
FailCount: failCount,
RunResult: runResult,
FailReason: task.FailureReason,
ResultOssId: lifecycleOssIDValue(ossID),
BatchInfo: buildBatchInfo(task, ossID, downloadID),
EndTime: now,
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.New("lifecycle batch is no longer processing")
}
return nil
})
if err != nil {
batchresult.RemoveLocalFile(artifact)
return err
}
return nil
}
func (r *Repository) artifactGenerator() batchresult.Generator {
if r.resultFiles != nil {
return r.resultFiles
}
return batchresult.New()
}
func artifactRequest(task *lifecycle.Task) batchresult.Request {
rows := make([]batchresult.Row, 0, len(task.Details))
for _, detail := range task.Details {
if detail == nil {
continue
}
rows = append(rows, batchresult.Row{
RowNo: detail.RowNo,
ICCID: detail.ICCID,
MSISDN: detail.MSISDN,
IMSI: detail.IMSI,
Success: detail.Reason == "",
Reason: detail.Reason,
UpstreamCode: detail.UpstreamCode,
OrderNum: detail.OrderNum,
})
}
return batchresult.Request{BatchNo: task.Batch.BatchNo, BatchType: task.Batch.BatchType, TenantID: task.Batch.TenantID, Rows: rows}
}
func buildBatchInfo(task *lifecycle.Task, ossID, downloadID int64) *gjson.Json {
info := make(map[string]any)
if task.Batch.BatchInfo != nil {
for key, value := range task.Batch.BatchInfo.Map() {
info[key] = value
}
}
operation := "start"
if task.Batch.BatchType == consts.BatchTypeSimCardStop {
operation = "stop"
}
info["operation"] = operation
info["operType"] = task.Operation.OperType
info["resultFileOssId"] = ossID
info["resultDownloadId"] = downloadID
return gjson.New(info)
}
func lifecycleOssIDValue(ossID int64) any {
if ossID <= 0 {
return nil
}
return ossID
}
func batchResult(successCount, failCount int) (int, int) {
switch {
case failCount == 0:
return consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess
case successCount == 0:
return consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed
default:
return consts.BatchAsyncStatusPartialSuccess, consts.BatchRunResultPartialSuccess
}
}
@@ -0,0 +1,610 @@
package lifecycle
import (
"context"
"strconv"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
lifecycle "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/lifecycle"
)
func (r *Repository) FindRestartMain(ctx context.Context, batchNo string) (*entity.BatchMain, error) {
var main *entity.BatchMain
if err := r.database().Model(dao.BatchMain.Table()).Safe().Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeSimCardRestart).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Scan(&main); err != nil {
return nil, gerror.Wrap(err, "query restart batch main")
}
return main, nil
}
func (r *Repository) LoadRestartStop(ctx context.Context, batchNo string) (*lifecycle.Task, error) {
return r.loadRestartTask(ctx, batchNo, consts.BatchDetailStatusRunning, lifecycle.Operation{
BatchType: consts.BatchTypeSimCardRestart,
OperType: consts.SimCardStatusOperTypeStop,
ExpectedStatus: consts.SimBossCardStatusActivated,
TargetStatus: consts.SimBossCardStatusStopped,
})
}
func (r *Repository) LoadRestartResume(ctx context.Context, batchNo string) (*lifecycle.Task, error) {
return r.loadRestartTask(ctx, batchNo, consts.BatchDetailStatusRunning, lifecycle.Operation{
BatchType: consts.BatchTypeSimCardRestart,
OperType: consts.SimCardStatusOperTypeStart,
ExpectedStatus: consts.SimBossCardStatusStopped,
TargetStatus: consts.SimBossCardStatusActivated,
})
}
func (r *Repository) loadRestartTask(ctx context.Context, batchNo string, detailStatus int, operation lifecycle.Operation) (*lifecycle.Task, error) {
if batchNo == "" {
return nil, gerror.New("restart batch number is empty")
}
batch := entity.BatchMain{}
if err := r.database().Model(dao.BatchMain.Table()).Safe().Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeSimCardRestart).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Scan(&batch); err != nil {
return nil, gerror.Wrap(err, "query restart batch")
}
if batch.Id == 0 {
return nil, gerror.New("processing restart batch does not exist")
}
var batchDetails []*entity.BatchDetail
if err := r.database().Model(dao.BatchDetail.Table()).Safe().Ctx(ctx).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeSimCardRestart).
Where(dao.BatchDetail.Columns().DetailStatus, detailStatus).
OrderAsc(dao.BatchDetail.Columns().RowNo).
Scan(&batchDetails); err != nil {
return nil, gerror.Wrap(err, "query restart details")
}
if len(batchDetails) == 0 {
return nil, gerror.New("restart batch has no runnable details")
}
task := &lifecycle.Task{
Batch: lifecycle.Batch{
ID: batch.Id,
BatchNo: batch.BatchNo,
BatchType: batch.BatchType,
TenantID: batch.TenantId,
CreatedBy: batch.CreatedBy,
CreatedDept: batch.CreatedDept,
StartTime: batch.StartTime,
BatchInfo: batch.BatchInfo,
},
Operation: operation,
Details: make([]*lifecycle.Detail, 0, len(batchDetails)),
}
for _, batchDetail := range batchDetails {
if batchDetail == nil {
continue
}
task.Details = append(task.Details, restartDetail(batchDetail))
}
if len(task.Details) == 0 {
return nil, gerror.New("restart batch has no readable details")
}
if err := r.loadCards(ctx, task); err != nil {
return nil, err
}
return task, nil
}
func restartDetail(batchDetail *entity.BatchDetail) *lifecycle.Detail {
detail := &lifecycle.Detail{
ID: batchDetail.Id,
RowNo: batchDetail.RowNo,
ICCID: strings.TrimSpace(batchDetail.ItemId),
Reason: strings.TrimSpace(batchDetail.ErrorReason),
OriginalSnapshot: batchDetail.Snapshot,
}
if batchDetail.Snapshot != nil {
if value := strings.TrimSpace(batchDetail.Snapshot.Get("ICCID").String()); value != "" {
detail.ICCID = value
}
detail.InputMSISDN = strings.TrimSpace(batchDetail.Snapshot.Get("MSISDN").String())
detail.InputIMSI = strings.TrimSpace(batchDetail.Snapshot.Get("IMSI").String())
detail.MSISDN = detail.InputMSISDN
detail.IMSI = detail.InputIMSI
detail.Authorized = batchDetail.Snapshot.Get("authorized").Bool()
detail.ChanKey = strings.TrimSpace(batchDetail.Snapshot.Get("chanKey").String())
}
return detail
}
func (r *Repository) SaveRestartValidationFailure(ctx context.Context, task *lifecycle.Task, stage string) error {
return r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, detail := range task.Details {
if detail == nil {
continue
}
if strings.TrimSpace(detail.Reason) == "" {
detail.Reason = "重启校验未通过,未发起上游请求"
}
outcome := lifecycle.Outcome{Message: detail.Reason}
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().Id, detail.ID).
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeSimCardRestart).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
Data(do.BatchDetail{
DetailStatus: consts.BatchDetailStatusFailed,
ErrorCode: strconv.Itoa(outcome.Code),
ErrorReason: detail.Reason,
Snapshot: restartSnapshot(task, detail, outcome, stage, gtime.Now()),
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.Newf("restart detail %d is no longer running", detail.ID)
}
}
return nil
})
}
func (r *Repository) SaveRestartResult(ctx context.Context, task *lifecycle.Task, detail *lifecycle.Detail, outcome lifecycle.Outcome, stage string) (lifecycle.Outcome, error) {
if detail == nil {
return lifecycle.Outcome{}, gerror.New("restart detail is nil")
}
resolved := outcome
now := gtime.Now()
err := r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if resolved.Success {
if detail.Card == nil {
resolved.Success = false
resolved.Message = "卡记录不存在,未办理"
} else {
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
Where(dao.TSimCardInfo.Columns().Id, detail.Card.ID).
Where(dao.TSimCardInfo.Columns().TenantId, task.Batch.TenantID).
Where(dao.TSimCardInfo.Columns().BossCardStatus, detail.StatusBefore).
Data(do.TSimCardInfo{
BossCardStatus: task.Operation.TargetStatus,
StatusSyncTime: now,
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
resolved.Success = false
resolved.Message = "卡本地状态已变更"
}
}
}
detail.ApplyOutcome(resolved)
detailStatus := consts.BatchDetailStatusFailed
if resolved.Success {
detailStatus = consts.BatchDetailStatusSuccess
}
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().Id, detail.ID).
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeSimCardRestart).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
Data(do.BatchDetail{
DetailStatus: detailStatus,
ErrorCode: strconv.Itoa(resolved.Code),
ErrorReason: detail.Reason,
Snapshot: restartSnapshot(task, detail, resolved, stage, now),
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.Newf("restart detail %d is no longer running", detail.ID)
}
return nil
})
return resolved, err
}
func (r *Repository) WaitRestartResume(ctx context.Context, task *lifecycle.Task, resumeAt *gtime.Time) error {
info := buildRestartBatchInfo(task.Batch.BatchInfo)
restart := restartInfo(info)
restart["phase"] = consts.RestartPhaseWaitingResume
restart["stopCompletedAt"] = gtime.Now().String()
restart["resumeAt"] = resumeAt.String()
restart["validationOK"] = true
info = setRestartInfo(info, restart)
return r.updateRestartInfo(ctx, task.Batch.BatchNo, info, task.Batch.CreatedBy)
}
func (r *Repository) ClaimRestartResume(ctx context.Context, batchNo string, now *gtime.Time) (claimed bool, err error) {
main, err := r.FindRestartMain(ctx, batchNo)
if err != nil {
return false, err
}
if main == nil || restartPhase(main.BatchInfo) != consts.RestartPhaseWaitingResume {
return false, nil
}
resumeAt := restartResumeAt(main.BatchInfo)
if resumeAt == nil || resumeAt.After(now) {
return false, nil
}
info := buildRestartBatchInfo(main.BatchInfo)
restart := restartInfo(info)
restart["phase"] = consts.RestartPhaseResuming
restart["resumeStartedAt"] = now.String()
info = setRestartInfo(info, restart)
err = r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
mainColumns := dao.BatchMain.Columns()
result, updateErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(mainColumns.BatchNo, batchNo).
Where(mainColumns.BatchType, consts.BatchTypeSimCardRestart).
Where(mainColumns.BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Where("JSON_UNQUOTE(JSON_EXTRACT("+mainColumns.BatchInfo+", '$.restart.phase')) = ?", consts.RestartPhaseWaitingResume).
Data(do.BatchMain{
BatchInfo: info,
UpdatedBy: main.CreatedBy,
}).Update()
if updateErr != nil {
return updateErr
}
affected, affectedErr := result.RowsAffected()
if affectedErr != nil {
return affectedErr
}
if affected == 0 {
return nil
}
if _, updateErr = tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeSimCardRestart).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusSuccess).
Data(do.BatchDetail{
DetailStatus: consts.BatchDetailStatusRunning,
UpdatedBy: main.CreatedBy,
}).Update(); updateErr != nil {
return updateErr
}
claimed = true
return nil
})
return claimed, err
}
func (r *Repository) FinalizeRestart(ctx context.Context, batchNo string) error {
main, err := r.FindRestartMain(ctx, batchNo)
if err != nil {
return err
}
if main == nil {
return gerror.New("processing restart batch does not exist")
}
var details []*entity.BatchDetail
if err = r.database().Model(dao.BatchDetail.Table()).Safe().Ctx(ctx).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeSimCardRestart).
OrderAsc(dao.BatchDetail.Columns().RowNo).
Scan(&details); err != nil {
return gerror.Wrap(err, "query restart final details")
}
if len(details) == 0 {
return gerror.New("restart batch has no final details")
}
successCount := 0
for _, detail := range details {
if detail != nil && detail.DetailStatus == consts.BatchDetailStatusSuccess {
successCount++
}
}
failCount := len(details) - successCount
batchAsyncStatus, runResult := batchResult(successCount, failCount)
failReason := ""
if failCount > 0 {
failReason = storedFailureSummary(details, successCount, failCount)
}
validCount := 0
if restartValidationOK(main.BatchInfo) {
validCount = len(details)
}
artifact, err := r.artifactGenerator().Create(ctx, restartArtifactRequest(main, details))
if err != nil {
return gerror.Wrap(err, "generate restart result file")
}
now := gtime.Now()
err = r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
ossID, err := tx.Ctx(ctx).Model(dao.SysOss.Table()).Data(do.SysOss{
TenantId: main.TenantId,
FileName: artifact.FileName,
OriginalName: artifact.OriginalName,
FileSuffix: artifact.FileSuffix,
Path: artifact.Path,
Url: artifact.URL,
CreatedDept: main.CreatedDept,
CreatedBy: main.CreatedBy,
UpdatedBy: main.CreatedBy,
Service: "local",
Md5: artifact.MD5,
FileSize: artifact.Size,
}).InsertAndGetId()
if err != nil {
return gerror.Wrap(err, "insert restart result OSS")
}
downloadID, err := tx.Ctx(ctx).Model(dao.TResourceBatchDownload.Table()).Data(do.TResourceBatchDownload{
BatchNo: main.BatchNo,
TaskName: batchresult.TaskName(consts.BatchTypeSimCardRestart),
ResourceType: consts.ResourceTypeExcel,
ExportScene: consts.BatchTypeSimCardRestart,
TotalCount: len(details),
SuccessCount: successCount,
FailCount: failCount,
DownloadStatus: consts.DownloadStatusGenerated,
OssId: ossID,
FileName: artifact.FileName,
FileExt: artifact.FileSuffix,
FileUrl: artifact.URL,
FilePath: artifact.Path,
FileSize: artifact.Size,
FileMd5: artifact.MD5,
DownloadCount: 0,
MaxDownloadCount: 0,
DeptId: main.CreatedDept,
StartTime: main.StartTime,
FinishTime: now,
TenantId: main.TenantId,
CreatedDept: main.CreatedDept,
CreatedBy: main.CreatedBy,
UpdatedBy: main.CreatedBy,
}).InsertAndGetId()
if err != nil {
return gerror.Wrap(err, "insert restart result download")
}
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, main.BatchNo).
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeSimCardRestart).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Data(do.BatchMain{
BatchAsyncStatus: batchAsyncStatus,
ValidCount: validCount,
SuccessCount: successCount,
FailCount: failCount,
RunResult: runResult,
FailReason: failReason,
ResultOssId: lifecycleOssIDValue(ossID),
BatchInfo: buildRestartFinishedInfo(main.BatchInfo, ossID, downloadID, now),
EndTime: now,
UpdatedBy: main.CreatedBy,
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.New("restart batch is no longer processing")
}
return nil
})
if err != nil {
batchresult.RemoveLocalFile(artifact)
return err
}
return nil
}
func (r *Repository) updateRestartInfo(ctx context.Context, batchNo string, info *gjson.Json, updatedBy int64) error {
result, err := r.database().Model(dao.BatchMain.Table()).Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeSimCardRestart).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Data(do.BatchMain{
BatchInfo: info,
UpdatedBy: updatedBy,
}).Update()
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return gerror.New("restart batch info was not updated")
}
return nil
}
func restartArtifactRequest(main *entity.BatchMain, details []*entity.BatchDetail) batchresult.Request {
rows := make([]batchresult.Row, 0, len(details))
for _, detail := range details {
if detail == nil {
continue
}
rows = append(rows, restartArtifactRow(detail))
}
return batchresult.Request{BatchNo: main.BatchNo, BatchType: consts.BatchTypeSimCardRestart, TenantID: main.TenantId, Rows: rows}
}
func restartArtifactRow(detail *entity.BatchDetail) batchresult.Row {
row := batchresult.Row{
RowNo: detail.RowNo,
ICCID: strings.TrimSpace(detail.ItemId),
Success: detail.DetailStatus == consts.BatchDetailStatusSuccess,
Reason: strings.TrimSpace(detail.ErrorReason),
}
if detail.Snapshot != nil {
if value := strings.TrimSpace(detail.Snapshot.Get("ICCID").String()); value != "" {
row.ICCID = value
}
row.MSISDN = strings.TrimSpace(detail.Snapshot.Get("MSISDN").String())
row.IMSI = strings.TrimSpace(detail.Snapshot.Get("IMSI").String())
row.OrderNum = strings.TrimSpace(detail.Snapshot.Get("restart.resume.orderNum").String())
if row.OrderNum == "" {
row.OrderNum = strings.TrimSpace(detail.Snapshot.Get("restart.stop.orderNum").String())
}
}
if code, err := strconv.Atoi(strings.TrimSpace(detail.ErrorCode)); err == nil {
row.UpstreamCode = code
}
return row
}
func restartSnapshot(task *lifecycle.Task, detail *lifecycle.Detail, outcome lifecycle.Outcome, stage string, now *gtime.Time) *gjson.Json {
base := snapshotMap(detail)
base["ICCID"] = detail.ICCID
base["inputMsisdn"] = detail.InputMSISDN
base["inputImsi"] = detail.InputIMSI
base["MSISDN"] = detail.MSISDN
base["IMSI"] = detail.IMSI
base["authorized"] = detail.Authorized
base["chanKey"] = detail.ChanKey
base["statusBefore"] = detail.StatusBefore
base["orderNum"] = detail.OrderNum
base["upstreamCode"] = detail.UpstreamCode
base["upstreamMessage"] = detail.UpstreamReason
restart := snapshotNestedMap(base["restart"])
statusAfter := detail.StatusBefore
if outcome.Success {
statusAfter = task.Operation.TargetStatus
}
restart[stage] = map[string]any{
"success": outcome.Success,
"reason": detail.Reason,
"upstreamCode": outcome.Code,
"upstreamMessage": strings.TrimSpace(outcome.Message),
"orderNum": strings.TrimSpace(outcome.OrderNum),
"operType": task.Operation.OperType,
"statusBefore": detail.StatusBefore,
"statusAfter": statusAfter,
"processedAt": now.String(),
}
base["restart"] = restart
return gjson.New(base)
}
func snapshotMap(detail *lifecycle.Detail) map[string]any {
if detail != nil && detail.OriginalSnapshot != nil {
return copyMap(detail.OriginalSnapshot.Map())
}
if detail != nil {
return copyMap(detail.Snapshot().Map())
}
return map[string]any{}
}
func snapshotNestedMap(value any) map[string]any {
if nested, ok := value.(map[string]any); ok {
return copyMap(nested)
}
return map[string]any{}
}
func buildRestartBatchInfo(info *gjson.Json) *gjson.Json {
data := copyMap(nil)
if info != nil {
data = copyMap(info.Map())
}
data["operation"] = "restart"
data["stopOperType"] = consts.SimCardStatusOperTypeStop
data["resumeOperType"] = consts.SimCardStatusOperTypeStart
if _, ok := data["resumeDelaySeconds"]; !ok {
data["resumeDelaySeconds"] = 300
}
data["restart"] = restartInfo(info)
return gjson.New(data)
}
func buildRestartFinishedInfo(info *gjson.Json, ossID, downloadID int64, finishedAt *gtime.Time) *gjson.Json {
updated := buildRestartBatchInfo(info)
restart := restartInfo(updated)
restart["phase"] = consts.RestartPhaseFinished
restart["finishedAt"] = finishedAt.String()
data := updated.Map()
data["resultFileOssId"] = ossID
data["resultDownloadId"] = downloadID
data["restart"] = restart
return gjson.New(data)
}
func setRestartInfo(info *gjson.Json, restart map[string]any) *gjson.Json {
data := map[string]any{}
if info != nil {
data = copyMap(info.Map())
}
data["restart"] = restart
return gjson.New(data)
}
func restartInfo(info *gjson.Json) map[string]any {
if info == nil {
return map[string]any{}
}
return copyMap(info.Get("restart").Map())
}
func restartPhase(info *gjson.Json) string {
if info == nil {
return ""
}
return strings.TrimSpace(info.Get("restart.phase").String())
}
func restartResumeAt(info *gjson.Json) *gtime.Time {
if info == nil {
return nil
}
value := strings.TrimSpace(info.Get("restart.resumeAt").String())
if value == "" {
return nil
}
parsed := gtime.NewFromStr(value)
if parsed == nil || parsed.IsZero() {
return nil
}
return parsed
}
func restartValidationOK(info *gjson.Json) bool {
if info == nil {
return false
}
return info.Get("restart.validationOK").Bool()
}
func copyMap(source map[string]any) map[string]any {
copied := make(map[string]any, len(source))
for key, value := range source {
copied[key] = value
}
return copied
}
@@ -0,0 +1,51 @@
package monthlyusage
import (
"context"
"fmt"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
)
// Repository applies the current usage result to the card information table.
// It deliberately does not write TDengine history for this full-sync flow.
type Repository struct{}
type UsageResult struct {
CardID int64
UsageMB float64
}
func NewRepository() *Repository {
return &Repository{}
}
func (r *Repository) ApplyBatch(ctx context.Context, results []UsageResult) error {
if len(results) == 0 {
return nil
}
cardIDs := make([]int64, 0, len(results))
var statement strings.Builder
statement.WriteString("CASE " + dao.TSimCardInfo.Columns().Id + " ")
for _, result := range results {
if result.CardID <= 0 {
return fmt.Errorf("monthly usage result has invalid cardId %d", result.CardID)
}
fmt.Fprintf(&statement, "WHEN %d THEN %f ", result.CardID, result.UsageMB)
cardIDs = append(cardIDs, result.CardID)
}
statement.WriteString("END")
_, err := dao.TSimCardInfo.Ctx(ctx).
WhereIn(dao.TSimCardInfo.Columns().Id, cardIDs).
Data(do.TSimCardInfo{
UsedFlowMb: gdb.Raw(statement.String()),
FlowSyncTime: gtime.Now(),
}).
Update()
return err
}
@@ -0,0 +1,45 @@
package monthlyusage
import (
"context"
"testing"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
func TestApplyBatchUpdatesDifferentCardsWithOneCaseStatement(t *testing.T) {
if err := gdb.SetConfig(gdb.Config{
"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)"}},
}); err != nil {
t.Fatal(err)
}
ctx := context.Background()
if _, err := g.DB().Exec(ctx, `CREATE TABLE t_sim_card_info (
id INTEGER PRIMARY KEY,
used_flow_mb REAL,
flow_sync_time DATETIME
)`); err != nil {
t.Fatal(err)
}
if _, err := g.DB().Exec(ctx, `INSERT INTO t_sim_card_info(id, used_flow_mb) VALUES (1, 0), (2, 0), (3, 9)`); err != nil {
t.Fatal(err)
}
if err := NewRepository().ApplyBatch(ctx, []UsageResult{
{CardID: 1, UsageMB: 1.5},
{CardID: 2, UsageMB: 2.75},
}); err != nil {
t.Fatal(err)
}
var rows []struct {
ID int64 `orm:"id"`
UsedFlowMB float64 `orm:"used_flow_mb"`
}
if err := g.DB().Model("t_sim_card_info").Fields("id,used_flow_mb").OrderAsc("id").Scan(&rows); err != nil {
t.Fatal(err)
}
if len(rows) != 3 || rows[0].UsedFlowMB != 1.5 || rows[1].UsedFlowMB != 2.75 || rows[2].UsedFlowMB != 9 {
t.Fatalf("rows = %+v", rows)
}
}
@@ -0,0 +1,411 @@
package renewchange
import (
"context"
"encoding/json"
"sort"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
)
// ChangeBindHistoryDO is the repository-local shape of the history table.
// It deliberately lives outside the generated model because Worker does not
// generate the card-package domain tables.
type ChangeBindHistoryDO struct {
g.Meta `orm:"table:t_sim_bind_package_logs, do:true"`
SubscriptionNo any `orm:"subscription_no"`
BatchNo any `orm:"batch_no"`
Iccid any `orm:"iccid"`
Msisdn any `orm:"msisdn"`
DeptId any `orm:"dept_id"`
PackageType any `orm:"package_type"`
PackageCode any `orm:"package_code"`
PreviousPackageCode any `orm:"previous_package_code"`
BusinessType any `orm:"business_type"`
EffectiveStatus any `orm:"effective_status"`
HandleTime *gtime.Time `orm:"handle_time"`
EffectiveTime *gtime.Time `orm:"effective_time"`
ExpectedExpireTime *gtime.Time `orm:"expected_expire_time"`
ActualExpireTime *gtime.Time `orm:"actual_expire_time"`
DurationMonths any `orm:"duration_months"`
GiftDurationMonths any `orm:"gift_duration_months"`
OrderCode any `orm:"order_code"`
Remark any `orm:"remark"`
TenantId any `orm:"tenant_id"`
CreatedDept any `orm:"created_dept"`
CreatedBy any `orm:"created_by"`
CreatedAt *gtime.Time `orm:"created_at"`
UpdatedBy any `orm:"updated_by"`
UpdatedAt *gtime.Time `orm:"updated_at"`
}
type changeOrderUpdateDO struct {
g.Meta `orm:"table:t_order, do:true"`
ReturnStatus any `orm:"return_status"`
UpdatedBy any `orm:"updated_by"`
UpdatedAt *gtime.Time `orm:"updated_at"`
}
type changeOrderItemRow struct {
ItemId int64 `orm:"item_id"`
ExtraSkuData string `orm:"extra_sku_data"`
}
type changeOrderItemUpdateDO struct {
g.Meta `orm:"table:t_order_item, do:true"`
ExtraSkuData any `orm:"extra_sku_data"`
UpdatedBy any `orm:"updated_by"`
UpdatedAt *gtime.Time `orm:"updated_at"`
}
type changeCardUpdateDO struct {
g.Meta `orm:"table:t_sim_card_info, do:true"`
CurrentPackageCode any `orm:"current_package_code"`
PackageStatus any `orm:"package_status"`
FlowPkgBeginAt any `orm:"flow_pkg_begin_at"`
FlowPkgPresentAt any `orm:"flow_pkg_present_at"`
FlowPkgRealPresentAt any `orm:"flow_pkg_real_present_at"`
FlowPkgEndAt any `orm:"flow_pkg_end_at"`
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"`
TrafficCutoffThreshold any `orm:"traffic_cutoff_threshold"`
SmsServiceStatus any `orm:"sms_service_status"`
PackageTotalSms any `orm:"package_total_sms"`
UsedSms any `orm:"used_sms"`
VoiceServiceStatus any `orm:"voice_service_status"`
PackageTotalVoice any `orm:"package_total_voice"`
UsedVoice any `orm:"used_voice"`
RoamServiceStatus any `orm:"roam_service_status"`
PackageTotalRoamFlowMb any `orm:"package_total_roam_flow_mb"`
UsedRoamFlowMb any `orm:"used_roam_flow_mb"`
UpdatedBy any `orm:"updated_by"`
UpdatedAt *gtime.Time `orm:"updated_at"`
}
type ChangeDeptRow struct {
DeptId int64 `orm:"dept_id"`
ParentId int64 `orm:"parent_id"`
TenantId string `orm:"tenant_id"`
}
// LockChangeBatch locks the formal change batch so a duplicate delivery
// cannot create a second order after the first transaction commits.
func (r *Repository) LockChangeBatch(ctx context.Context, tx gdb.TX, batchNo string) (*BatchMainRow, error) {
var main BatchMainRow
if err := tx.Ctx(ctx).Model(TableBatchMain).
Where("batch_no", strings.TrimSpace(batchNo)).
Where("batch_type", consts.BatchTypeChange).
LockUpdate().
Scan(&main); err != nil {
return nil, gerror.Wrap(err, "lock change batch failed")
}
if main.Id == 0 {
return nil, gerror.New("change batch does not exist")
}
return &main, nil
}
// LockChangeDetails locks all details, allowing the executor to verify that
// the claimed batch still has exactly the expected running rows.
func (r *Repository) LockChangeDetails(ctx context.Context, tx gdb.TX, batchNo string) ([]*BatchDetailRow, error) {
var rows []*BatchDetailRow
if err := tx.Ctx(ctx).Model(TableBatchDetail).
Where("batch_no", strings.TrimSpace(batchNo)).
Where("batch_type", consts.BatchTypeChange).
OrderAsc("row_no").
LockUpdate().
Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "lock change details failed")
}
return rows, nil
}
// LockChangeBinds returns every pending/effective bind for the requested
// cards. The service chooses the single current bind and treats equal-priority
// duplicates as an integrity error.
func (r *Repository) LockChangeBinds(ctx context.Context, tx gdb.TX, iccids []string) ([]*SimCardBindPkg, error) {
return r.LockActiveBindRowsByIccids(ctx, tx, iccids)
}
func (r *Repository) LoadChangePackage(ctx context.Context, tx gdb.TX, tenantID, packageCode string) (*FlowPackageInfo, error) {
var row FlowPackageInfo
if err := tx.Ctx(ctx).Model(TableSimFlowPackageInfo).
Where("tenant_id", strings.TrimSpace(tenantID)).
Where("package_code", strings.TrimSpace(packageCode)).
LockUpdate().
Scan(&row); err != nil {
return nil, gerror.Wrap(err, "load change package failed")
}
if row.Id == 0 {
return nil, gerror.Newf("package not found: %s", packageCode)
}
return &row, nil
}
func (r *Repository) LoadChangeDept(ctx context.Context, tx gdb.TX, tenantID string, deptID int64) (*ChangeDeptRow, error) {
var row ChangeDeptRow
if err := tx.Ctx(ctx).Model(TableSysDept).
Where("tenant_id", strings.TrimSpace(tenantID)).
Where("dept_id", deptID).
Scan(&row); err != nil {
return nil, gerror.Wrap(err, "load change customer failed")
}
if row.DeptId == 0 {
return nil, gerror.New("change customer does not exist")
}
return &row, nil
}
func (r *Repository) ArchiveChangeBinds(ctx context.Context, tx gdb.TX, binds []*SimCardBindPkg, status int, actualExpireAt *gtime.Time, changeBatchNo string, userID int64) error {
if len(binds) == 0 {
return nil
}
rows := make([]ChangeBindHistoryDO, 0, len(binds))
for _, bind := range binds {
if bind == nil || strings.TrimSpace(bind.SubscriptionNo) == "" {
return gerror.New("change subscription history is incomplete")
}
rows = append(rows, ChangeBindHistoryDO{
SubscriptionNo: bind.SubscriptionNo,
BatchNo: bind.BatchNo,
Iccid: bind.Iccid,
Msisdn: bind.Msisdn,
DeptId: bind.DeptId,
PackageType: bind.PackageType,
PackageCode: bind.PackageCode,
PreviousPackageCode: bind.PreviousPackageCode,
BusinessType: bind.BusinessType,
EffectiveStatus: status,
HandleTime: bind.HandleTime,
EffectiveTime: bind.EffectiveTime,
ExpectedExpireTime: bind.ExpectedExpireTime,
ActualExpireTime: actualExpireAt,
DurationMonths: bind.DurationMonths,
GiftDurationMonths: bind.GiftDurationMonths,
OrderCode: bind.OrderCode,
Remark: appendChangeRemark(bind.Remark, changeBatchNo),
TenantId: bind.TenantId,
CreatedDept: bind.CreatedDept,
CreatedBy: bind.CreatedBy,
CreatedAt: bind.CreatedAt,
UpdatedBy: userID,
UpdatedAt: actualExpireAt,
})
}
if _, err := tx.Ctx(ctx).Model(TableSimBindPackageLogs).
Data(rows).
OnConflict("subscription_no").
Save(); err != nil {
return gerror.Wrap(err, "archive change subscriptions failed")
}
return nil
}
func (r *Repository) DeleteChangeBinds(ctx context.Context, tx gdb.TX, subscriptionNos []string) (int64, error) {
unique := uniqueStrings(subscriptionNos)
if len(unique) == 0 {
return 0, nil
}
result, err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
WhereIn("subscription_no", unique).
Delete()
if err != nil {
return 0, gerror.Wrap(err, "delete change subscriptions failed")
}
affected, err := result.RowsAffected()
if err != nil {
return 0, gerror.Wrap(err, "read deleted change subscriptions count failed")
}
return affected, nil
}
func (r *Repository) UpdateChangeOrdersAfterRemoval(ctx context.Context, tx gdb.TX, orderCodes []string, batchNo, batchKey string, quantityByOrder map[string]int, eventTime *gtime.Time, userID int64) error {
orderCodes = uniqueStrings(orderCodes)
sort.Strings(orderCodes)
if len(orderCodes) == 0 {
return nil
}
if eventTime == nil {
eventTime = gtime.Now()
}
activeCounts, err := countChangeBindsByOrder(ctx, tx, orderCodes)
if err != nil {
return err
}
for _, orderCode := range orderCodes {
returnStatus := consts.OrderReturnStatusSuccess
if activeCounts[orderCode] > 0 {
returnStatus = consts.OrderReturnStatusPartial
}
result, updateErr := tx.Ctx(ctx).Model(TableOrder).
Where("order_sn", orderCode).
Data(buildChangeOrderUpdateDO(returnStatus, userID, eventTime)).
Update()
if updateErr != nil {
return gerror.Wrap(updateErr, "update changed order return status failed")
}
affected, affectedErr := result.RowsAffected()
if affectedErr != nil {
return gerror.Wrap(affectedErr, "read changed order update count failed")
}
if affected != 1 {
return gerror.Newf("changed source order does not exist: %s", orderCode)
}
if err = r.updateChangeOrderItemExtra(ctx, tx, orderCode, batchNo, batchKey, quantityByOrder[orderCode], eventTime, userID); err != nil {
return err
}
}
return nil
}
func (r *Repository) updateChangeOrderItemExtra(ctx context.Context, tx gdb.TX, orderCode, batchNo, batchKey string, quantity int, eventTime *gtime.Time, userID int64) error {
var item changeOrderItemRow
if err := tx.Ctx(ctx).Model(TableOrderItem).
Where("order_sn", orderCode).
Limit(1).
Scan(&item); err != nil {
return gerror.Wrap(err, "load changed order item failed")
}
if item.ItemId == 0 {
return gerror.Newf("changed order item does not exist: %s", orderCode)
}
extra := map[string]any{}
if strings.TrimSpace(item.ExtraSkuData) != "" {
if err := json.Unmarshal([]byte(item.ExtraSkuData), &extra); err != nil {
return gerror.Wrap(err, "parse changed order item extra failed")
}
}
extra[batchKey+"BatchNo"] = batchNo
extra[batchKey+"Time"] = eventTime.Format("Y-m-d H:i:s")
if batchKey == "lastUnsubscribe" {
extra["unsubscribedQuantity"] = quantity
} else {
extra["voidedByChangeQuantity"] = quantity
}
data, err := json.Marshal(extra)
if err != nil {
return gerror.Wrap(err, "marshal changed order item extra failed")
}
_, err = tx.Ctx(ctx).Model(TableOrderItem).
Where("item_id", item.ItemId).
Data(buildChangeOrderItemUpdateDO(string(data), userID, eventTime)).
Update()
return gerror.Wrap(err, "update changed order item extra failed")
}
func buildChangeOrderUpdateDO(returnStatus int, userID int64, updatedAt *gtime.Time) changeOrderUpdateDO {
return changeOrderUpdateDO{ReturnStatus: returnStatus, UpdatedBy: userID, UpdatedAt: updatedAt}
}
func buildChangeOrderItemUpdateDO(extraSKUData string, userID int64, updatedAt *gtime.Time) changeOrderItemUpdateDO {
return changeOrderItemUpdateDO{ExtraSkuData: extraSKUData, UpdatedBy: userID, UpdatedAt: updatedAt}
}
func countChangeBindsByOrder(ctx context.Context, tx gdb.TX, orderCodes []string) (map[string]int, error) {
type orderCountRow struct {
OrderCode string `orm:"order_code"`
Count int `orm:"count"`
}
var rows []orderCountRow
if err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
Fields("order_code", "COUNT(1) AS count").
WhereIn("order_code", orderCodes).
Group("order_code").
Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "count changed order subscriptions failed")
}
counts := make(map[string]int, len(rows))
for _, row := range rows {
counts[strings.TrimSpace(row.OrderCode)] = row.Count
}
return counts, nil
}
func (r *Repository) UpdateChangeCard(ctx context.Context, tx gdb.TX, iccid, packageCode string, packageStatus int, packageFlowMb float64, beginAt, endAt *gtime.Time, userID int64) error {
result, err := tx.Ctx(ctx).Model(TableSimCardInfo).
Where("iccid", strings.TrimSpace(iccid)).
Data(changeCardUpdateDO{
CurrentPackageCode: packageCode,
PackageStatus: packageStatus,
FlowPkgBeginAt: beginAt,
FlowPkgPresentAt: gdb.Raw("NULL"),
FlowPkgRealPresentAt: gdb.Raw("NULL"),
FlowPkgEndAt: endAt,
DataServiceStatus: 1,
PackageTotalFlowMb: packageFlowMb,
UsedFlowMb: 0,
TrafficCutoffStatus: 0,
TrafficCutoffThreshold: 0,
SmsServiceStatus: 0,
PackageTotalSms: 0,
UsedSms: 0,
VoiceServiceStatus: 0,
PackageTotalVoice: 0,
UsedVoice: 0,
RoamServiceStatus: 0,
PackageTotalRoamFlowMb: 0,
UsedRoamFlowMb: 0,
UpdatedBy: userID,
UpdatedAt: gtime.Now(),
}).
Update()
if err != nil {
return gerror.Wrap(err, "update changed card package failed")
}
affected, err := result.RowsAffected()
if err != nil {
return gerror.Wrap(err, "read changed card update count failed")
}
if affected != 1 {
return gerror.Newf("changed card was modified concurrently: %s", iccid)
}
return nil
}
func (r *Repository) MarkChangeDetailsSuccess(ctx context.Context, tx gdb.TX, batchNo string, details []*BatchDetailRow, snapshots map[int]*gjson.Json, userID int64) error {
for _, detail := range details {
if detail == nil {
continue
}
data := do.BatchDetail{
DetailStatus: consts.BatchDetailStatusSuccess,
ErrorCode: "",
ErrorReason: "",
Snapshot: snapshots[detail.RowNo],
UpdatedBy: userID,
}
result, err := tx.Ctx(ctx).Model(TableBatchDetail).
Where("batch_no", batchNo).
Where("batch_type", consts.BatchTypeChange).
Where("row_no", detail.RowNo).
Data(data).
Update()
if err != nil {
return gerror.Wrap(err, "mark change detail success failed")
}
affected, affectedErr := result.RowsAffected()
if affectedErr != nil || affected != 1 {
return gerror.Newf("change detail disappeared during success update: row %d", detail.RowNo)
}
}
return nil
}
func appendChangeRemark(old, batchNo string) string {
addition := "changeBatchNo=" + strings.TrimSpace(batchNo)
old = strings.TrimSpace(old)
if old == "" {
return addition
}
return old + "; " + addition
}
@@ -0,0 +1,32 @@
package renewchange
import (
"testing"
"time"
"github.com/gogf/gf/v2/os/gtime"
)
func TestChangeOrderRemovalUpdatesCarryAuditTimestamp(t *testing.T) {
updatedAt := gtime.NewFromTime(time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local))
orderUpdate := buildChangeOrderUpdateDO(2, 7, updatedAt)
itemUpdate := buildChangeOrderItemUpdateDO(`{"lastChangeBatchNo":"CHG-1"}`, 7, updatedAt)
if orderUpdate.UpdatedAt != updatedAt || itemUpdate.UpdatedAt != updatedAt {
t.Fatalf("source order audit timestamps = %v/%v, want shared %v", orderUpdate.UpdatedAt, itemUpdate.UpdatedAt, updatedAt)
}
if orderUpdate.ReturnStatus != 2 || orderUpdate.UpdatedBy != int64(7) {
t.Fatalf("source order update = %+v", orderUpdate)
}
if itemUpdate.ExtraSkuData == nil || itemUpdate.UpdatedBy != int64(7) {
t.Fatalf("source order item update = %+v", itemUpdate)
}
}
func TestChangeCardUpdateHasExplicitUpdatedAtField(t *testing.T) {
updatedAt := gtime.NewFromTime(time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local))
update := changeCardUpdateDO{UpdatedBy: int64(7), UpdatedAt: updatedAt}
if update.UpdatedAt != updatedAt {
t.Fatalf("card updated_at = %v, want %v", update.UpdatedAt, updatedAt)
}
}
@@ -0,0 +1,39 @@
package renewchange
import "github.com/gogf/gf/v2/errors/gerror"
var (
errRenewDurationEmpty = gerror.New("续费/变更套餐未配置可续费时长")
errPkgNotFound = gerror.New("套餐不存在")
errPkgDisabled = gerror.New("套餐未启用或不存在")
errDeptNotFound = gerror.New("客户不存在")
errNoChangeablePackages = gerror.New("当前套餐未配置可变更套餐范围")
errCardNotFound = gerror.New("ICCID不存在正式卡记录")
errLatestBindMissing = gerror.New("ICCID最新订购记录不存在")
errCardNotOwned = gerror.New("ICCID不属于当前机构")
errCardNoCurrentPackage = gerror.New("ICCID没有当前套餐")
errCardPackageMismatch = gerror.New("ICCID当前套餐与最新订购记录不一致")
errCardExpireMissing = gerror.New("ICCID最新订购没有预计到期时间")
errCardSourceBatchMismatch = gerror.New("ICCID不属于来源出库批次")
errDuplicateICCID = gerror.New("ICCID重复")
errPrepareIncomplete = gerror.New("预处理结果不完整")
errMultiplePackages = gerror.New("当前批次下存在多个套餐,不能统一处理")
errMultipleCustomers = gerror.New("当前批次下存在多个客户,不能统一处理")
errSameBatchInvalid = gerror.New("同批存在不满足条件的卡,本次未执行")
errSameBatchChangeInvalid = gerror.New("同批存在不满足变更条件的卡,本行未执行")
errNoPendingBinds = gerror.New("ICCID没有有效订购记录")
errActiveBindNotFound = gerror.New("ICCID当前有效订购不存在")
errActiveBindMismatch = gerror.New("ICCID当前有效订购与当前套餐不一致")
errActiveBindSourceBatch = gerror.New("ICCID当前有效订购不属于来源批次")
errMsisdnMismatch = gerror.New("MSISDN与SIM卡记录不一致")
errImsiMismatch = gerror.New("IMSI与SIM卡记录不一致")
errRenewableDurationMiss = gerror.New("续费时长不在当前套餐可续费范围内")
errPriceChanged = gerror.New("当前续费价格已变化,请重新校验")
errPackageTypeMismatch = gerror.New("目标套餐类型与当前套餐类型不一致")
errNewPackageNotInChange = gerror.New("新套餐不在当前套餐可变更范围内")
errNewPackageSameAsOld = gerror.New("新套餐不能与当前套餐相同")
errTargetPkgNotFound = gerror.New("目标套餐不存在")
errPrepareBatchNotFound = gerror.New("预处理批次不存在或已执行")
errPrepareBatchTypeWrong = gerror.New("批次不是续费/变更预处理批次")
errPrepareInfoMissing = gerror.New("预处理批次缺少关键快照")
)
@@ -0,0 +1,54 @@
package renewchange
import (
"crypto/rand"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
)
const businessNoAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
// NextOrderSN returns a production order number before any INSERT is issued.
func NextOrderSN(now time.Time) (string, error) {
suffix, err := randomBusinessNoSuffix(8)
if err != nil {
return "", err
}
return fmt.Sprintf("SO%s%s", now.Format("20060102150405"), suffix), nil
}
func randomBusinessNoSuffix(length int) (string, error) {
if length <= 0 {
return "", gerror.New("business number suffix length must be greater than zero")
}
randomBytes := make([]byte, length)
if _, err := rand.Read(randomBytes); err != nil {
return "", gerror.Wrap(err, "generate business number suffix failed")
}
buf := make([]byte, length)
for i, value := range randomBytes {
buf[i] = businessNoAlphabet[int(value)%len(businessNoAlphabet)]
}
return string(buf), nil
}
// GenerateOrderSn 形如 "yyyyMMddHHmmssfff"+6位随机后缀
func GenerateOrderSn(t *time.Time) string {
now := time.Now()
if t != nil {
now = *t
}
return fmt.Sprintf("%s%06d", now.UTC().Format("20060102150405.000")[0:14], now.Nanosecond()/1000%1000000)
}
// GenerateSubscriptionNo 形如 "SUB" + yyyyMMddHHmmss + 6位后缀
func GenerateSubscriptionNo(prefix string) string {
now := time.Now()
if strings.TrimSpace(prefix) == "" {
prefix = "SUB"
}
return fmt.Sprintf("%s%s%06d", prefix, now.UTC().Format("20060102150405"), now.Nanosecond()/1000%1000000)
}
@@ -0,0 +1,195 @@
package renewchange
import (
"context"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
)
// OrderDO mirrors the production t_order table. It is repository-local
// because Worker does not generate DAO/DO/entity code for the order domain.
type OrderDO struct {
g.Meta `orm:"table:t_order, do:true"`
Id any `orm:"id"`
OrderSn any `orm:"order_sn"`
ParentOrderId any `orm:"parent_order_id"`
ParentOrderSn any `orm:"parent_order_sn"`
CustomerType any `orm:"customer_type"`
CustomerAgentId any `orm:"customer_agent_id"`
EndUserId any `orm:"end_user_id"`
AgentId any `orm:"agent_id"`
OrderType any `orm:"order_type"`
OrderStatus any `orm:"order_status"`
ShippingStatus any `orm:"shipping_status"`
ReturnStatus any `orm:"return_status"`
Consignee any `orm:"consignee"`
Mobile any `orm:"mobile"`
Email any `orm:"email"`
Address any `orm:"address"`
AreaCode any `orm:"area_code"`
BuyerNote any `orm:"buyer_note"`
AdminNote any `orm:"admin_note"`
Remark any `orm:"remark"`
ShippingMethod any `orm:"shipping_method"`
LogisticsId any `orm:"logistics_id"`
LogisticsName any `orm:"logistics_name"`
ShippingTypeId any `orm:"shipping_type_id"`
ShippingTypeName any `orm:"shipping_type_name"`
TrackingNo any `orm:"tracking_no"`
ShippingTime *gtime.Time `orm:"shipping_time"`
ReceivedTime *gtime.Time `orm:"received_time"`
PayType any `orm:"pay_type"`
PayStatus any `orm:"pay_status"`
PayOrderSn any `orm:"pay_order_sn"`
PayTime *gtime.Time `orm:"pay_time"`
TotalAmount any `orm:"total_amount"`
ProductAmount any `orm:"product_amount"`
ShippingFee any `orm:"shipping_fee"`
ServiceFee any `orm:"service_fee"`
InvoiceFee any `orm:"invoice_fee"`
CouponAmount any `orm:"coupon_amount"`
DiscountAmount any `orm:"discount_amount"`
Balance any `orm:"balance"`
PaidAmount any `orm:"paid_amount"`
OnlinePaidAmount any `orm:"online_paid_amount"`
OfflinePaidAmount any `orm:"offline_paid_amount"`
UnpaidAmount any `orm:"unpaid_amount"`
UnrefundAmount any `orm:"unrefund_amount"`
IsNeedCommission any `orm:"is_need_commission"`
DistributionStatus any `orm:"distribution_status"`
TenantId any `orm:"tenant_id"`
CreatedDept any `orm:"created_dept"`
CreatedBy any `orm:"created_by"`
CreatedAt *gtime.Time `orm:"created_at"`
UpdatedBy any `orm:"updated_by"`
UpdatedAt *gtime.Time `orm:"updated_at"`
}
// OrderItemDO mirrors the production t_order_item table.
type OrderItemDO struct {
g.Meta `orm:"table:t_order_item, do:true"`
ItemId any `orm:"item_id"`
OrderId any `orm:"order_id"`
OrderSn any `orm:"order_sn"`
CustomerType any `orm:"customer_type"`
CustomerAgentId any `orm:"customer_agent_id"`
EndUserId any `orm:"end_user_id"`
Price any `orm:"price"`
Quantity any `orm:"quantity"`
ProductId any `orm:"product_id"`
ProductName any `orm:"product_name"`
ProductSn any `orm:"product_sn"`
PicThumb any `orm:"pic_thumb"`
SkuId any `orm:"sku_id"`
SkuData any `orm:"sku_data"`
DeliveryQuantity any `orm:"delivery_quantity"`
ProductType any `orm:"product_type"`
IsGift any `orm:"is_gift"`
AgentId any `orm:"agent_id"`
PrepayPrice any `orm:"prepay_price"`
Commission any `orm:"commission"`
OriginPrice any `orm:"origin_price"`
PromotionData any `orm:"promotion_data"`
ExtraSkuData any `orm:"extra_sku_data"`
PayMode any `orm:"pay_mode"`
ServiceBeginTime *gtime.Time `orm:"service_begin_time"`
ServiceEndTime *gtime.Time `orm:"service_end_time"`
TenantId any `orm:"tenant_id"`
CreatedDept any `orm:"created_dept"`
CreatedBy any `orm:"created_by"`
CreatedAt *gtime.Time `orm:"created_at"`
UpdatedBy any `orm:"updated_by"`
UpdatedAt *gtime.Time `orm:"updated_at"`
}
// InsertContractOrder inserts a fully built order and returns its primary key.
// The order number must exist before the INSERT is issued.
func (r *Repository) InsertContractOrder(ctx context.Context, tx gdb.TX, order *OrderDO) (int64, error) {
if err := ValidateOrderDO(order); err != nil {
return 0, err
}
id, err := tx.Ctx(ctx).Model(TableOrder).Data(order).InsertAndGetId()
if err != nil {
return 0, gerror.Wrap(err, "insert order failed")
}
return id, nil
}
// InsertContractOrderItem inserts the aggregate order item for an order.
func (r *Repository) InsertContractOrderItem(ctx context.Context, tx gdb.TX, item *OrderItemDO) error {
if err := ValidateOrderItemDO(item); err != nil {
return err
}
_, err := tx.Ctx(ctx).Model(TableOrderItem).Data(item).Insert()
return gerror.Wrap(err, "insert order item failed")
}
func ValidateOrderDO(order *OrderDO) error {
if order == nil {
return gerror.New("order cannot be nil")
}
if strings.TrimSpace(gconv.String(order.OrderSn)) == "" {
return gerror.New("order number cannot be empty")
}
if gconv.Int64(order.CustomerAgentId) <= 0 {
return gerror.New("order customer agent id must be greater than zero")
}
if gconv.Int64(order.AgentId) <= 0 {
return gerror.New("order seller agent id must be greater than zero")
}
if strings.TrimSpace(gconv.String(order.TenantId)) == "" {
return gerror.New("order tenant id cannot be empty")
}
if gconv.Int64(order.CreatedDept) <= 0 || gconv.Int64(order.CreatedBy) <= 0 || gconv.Int64(order.UpdatedBy) <= 0 {
return gerror.New("order creator organization is incomplete")
}
if order.CreatedAt == nil || order.UpdatedAt == nil {
return gerror.New("order audit time cannot be empty")
}
return nil
}
func ValidateOrderItemDO(item *OrderItemDO) error {
if item == nil {
return gerror.New("order item cannot be nil")
}
if gconv.Int64(item.OrderId) <= 0 {
return gerror.New("order item order id must be greater than zero")
}
if strings.TrimSpace(gconv.String(item.OrderSn)) == "" {
return gerror.New("order item order number cannot be empty")
}
if gconv.Int64(item.CustomerAgentId) <= 0 {
return gerror.New("order item customer agent id must be greater than zero")
}
if gconv.Int64(item.AgentId) <= 0 {
return gerror.New("order item seller agent id must be greater than zero")
}
if gconv.Int(item.Quantity) <= 0 {
return gerror.New("order item quantity must be greater than zero")
}
if gconv.Int64(item.ProductId) <= 0 {
return gerror.New("order item product id must be greater than zero")
}
if strings.TrimSpace(gconv.String(item.ProductName)) == "" || strings.TrimSpace(gconv.String(item.ProductSn)) == "" {
return gerror.New("order item product contract is incomplete")
}
if item.ServiceBeginTime == nil || item.ServiceEndTime == nil || !item.ServiceEndTime.After(item.ServiceBeginTime) {
return gerror.New("order item service period is invalid")
}
if strings.TrimSpace(gconv.String(item.TenantId)) == "" {
return gerror.New("order item tenant id cannot be empty")
}
if gconv.Int64(item.CreatedDept) <= 0 || gconv.Int64(item.CreatedBy) <= 0 || gconv.Int64(item.UpdatedBy) <= 0 {
return gerror.New("order item creator organization is incomplete")
}
if item.CreatedAt == nil || item.UpdatedAt == nil {
return gerror.New("order item audit time cannot be empty")
}
return nil
}
@@ -0,0 +1,218 @@
package renewchange
import (
"reflect"
"strings"
"testing"
"time"
"github.com/gogf/gf/v2/os/gtime"
)
func TestOrderDOLocalSchemaMatchesProductionContract(t *testing.T) {
want := []string{
"Id", "OrderSn", "ParentOrderId", "ParentOrderSn", "CustomerType",
"CustomerAgentId", "EndUserId", "AgentId", "OrderType", "OrderStatus",
"ShippingStatus", "ReturnStatus", "Consignee", "Mobile", "Email", "Address",
"AreaCode", "BuyerNote", "AdminNote", "Remark", "ShippingMethod", "LogisticsId",
"LogisticsName", "ShippingTypeId", "ShippingTypeName", "TrackingNo", "ShippingTime",
"ReceivedTime", "PayType", "PayStatus", "PayOrderSn", "PayTime", "TotalAmount",
"ProductAmount", "ShippingFee", "ServiceFee", "InvoiceFee", "CouponAmount",
"DiscountAmount", "Balance", "PaidAmount", "OnlinePaidAmount", "OfflinePaidAmount",
"UnpaidAmount", "UnrefundAmount", "IsNeedCommission", "DistributionStatus", "TenantId",
"CreatedDept", "CreatedBy", "CreatedAt", "UpdatedBy", "UpdatedAt",
}
assertDOFields(t, reflect.TypeOf(OrderDO{}), want)
}
func TestOrderItemDOLocalSchemaMatchesProductionContract(t *testing.T) {
want := []string{
"ItemId", "OrderId", "OrderSn", "CustomerType", "CustomerAgentId", "EndUserId",
"Price", "Quantity", "ProductId", "ProductName", "ProductSn", "PicThumb", "SkuId",
"SkuData", "DeliveryQuantity", "ProductType", "IsGift", "AgentId", "PrepayPrice",
"Commission", "OriginPrice", "PromotionData", "ExtraSkuData", "PayMode",
"ServiceBeginTime", "ServiceEndTime", "TenantId", "CreatedDept", "CreatedBy",
"CreatedAt", "UpdatedBy", "UpdatedAt",
}
assertDOFields(t, reflect.TypeOf(OrderItemDO{}), want)
}
func TestOrderDOsHaveExplicitORMColumnMappings(t *testing.T) {
for _, testCase := range []struct {
name string
typ reflect.Type
}{
{name: "order", typ: reflect.TypeOf(OrderDO{})},
{name: "order item", typ: reflect.TypeOf(OrderItemDO{})},
{name: "change history", typ: reflect.TypeOf(ChangeBindHistoryDO{})},
{name: "change order update", typ: reflect.TypeOf(changeOrderUpdateDO{})},
{name: "change order item update", typ: reflect.TypeOf(changeOrderItemUpdateDO{})},
{name: "change card update", typ: reflect.TypeOf(changeCardUpdateDO{})},
} {
t.Run(testCase.name, func(t *testing.T) {
for i := 0; i < testCase.typ.NumField(); i++ {
field := testCase.typ.Field(i)
if field.Name == "Meta" {
continue
}
if got := field.Tag.Get("orm"); got == "" {
t.Errorf("%s has no explicit orm column mapping", field.Name)
}
}
})
}
}
func TestOrderDOTagsMatchProductionColumns(t *testing.T) {
assertDOColumnTags(t, reflect.TypeOf(OrderDO{}), map[string]string{
"OrderSn": "order_sn", "CustomerAgentId": "customer_agent_id", "EndUserId": "end_user_id", "AgentId": "agent_id",
"OrderType": "order_type", "OrderStatus": "order_status", "ShippingStatus": "shipping_status", "ReturnStatus": "return_status",
"BuyerNote": "buyer_note", "AdminNote": "admin_note", "Remark": "remark", "PayType": "pay_type", "PayStatus": "pay_status",
"PayTime": "pay_time", "TotalAmount": "total_amount", "ProductAmount": "product_amount", "PaidAmount": "paid_amount",
"OnlinePaidAmount": "online_paid_amount", "OfflinePaidAmount": "offline_paid_amount", "TenantId": "tenant_id",
"CreatedDept": "created_dept", "CreatedBy": "created_by", "CreatedAt": "created_at", "UpdatedBy": "updated_by", "UpdatedAt": "updated_at",
})
assertDOColumnTags(t, reflect.TypeOf(OrderItemDO{}), map[string]string{
"ItemId": "item_id", "OrderId": "order_id", "OrderSn": "order_sn", "CustomerAgentId": "customer_agent_id", "EndUserId": "end_user_id",
"Price": "price", "Quantity": "quantity", "ProductId": "product_id", "ProductName": "product_name", "ProductSn": "product_sn",
"SkuData": "sku_data", "ProductType": "product_type", "IsGift": "is_gift", "AgentId": "agent_id", "OriginPrice": "origin_price",
"ExtraSkuData": "extra_sku_data", "PayMode": "pay_mode", "ServiceBeginTime": "service_begin_time", "ServiceEndTime": "service_end_time",
"TenantId": "tenant_id", "CreatedDept": "created_dept", "CreatedBy": "created_by", "CreatedAt": "created_at", "UpdatedBy": "updated_by", "UpdatedAt": "updated_at",
})
}
func TestNextOrderSNIsGeneratedBeforeInsert(t *testing.T) {
got, err := NextOrderSN(time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local))
if err != nil {
t.Fatalf("NextOrderSN returned error: %v", err)
}
if !strings.HasPrefix(got, "SO20260803100250") {
t.Fatalf("NextOrderSN = %q, want SO prefix and timestamp", got)
}
if len(got) != len("SO20260803100250")+8 {
t.Fatalf("NextOrderSN length = %d, want %d", len(got), len("SO20260803100250")+8)
}
}
func TestValidateContractOrderRequiredFields(t *testing.T) {
now := gtime.NewFromTime(time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local))
order := &OrderDO{
OrderSn: "SO20260803100250ABCDEFGH",
CustomerAgentId: int64(200),
AgentId: int64(100),
TenantId: "000000",
CreatedDept: int64(100),
CreatedBy: int64(1),
CreatedAt: now,
UpdatedBy: int64(1),
UpdatedAt: now,
}
if err := ValidateOrderDO(order); err != nil {
t.Fatalf("ValidateOrderDO returned error: %v", err)
}
for _, testCase := range []struct {
name string
clear func(*OrderDO)
}{
{name: "order number", clear: func(order *OrderDO) { order.OrderSn = "" }},
{name: "customer agent", clear: func(order *OrderDO) { order.CustomerAgentId = int64(0) }},
{name: "seller agent", clear: func(order *OrderDO) { order.AgentId = int64(0) }},
{name: "tenant", clear: func(order *OrderDO) { order.TenantId = "" }},
{name: "creator department", clear: func(order *OrderDO) { order.CreatedDept = int64(0) }},
{name: "creator", clear: func(order *OrderDO) { order.CreatedBy = int64(0) }},
{name: "updater", clear: func(order *OrderDO) { order.UpdatedBy = int64(0) }},
{name: "created time", clear: func(order *OrderDO) { order.CreatedAt = nil }},
{name: "updated time", clear: func(order *OrderDO) { order.UpdatedAt = nil }},
} {
t.Run(testCase.name, func(t *testing.T) {
invalid := *order
testCase.clear(&invalid)
if err := ValidateOrderDO(&invalid); err == nil {
t.Fatalf("ValidateOrderDO accepted missing %s", testCase.name)
}
})
}
}
func TestValidateContractOrderItemRequiredFields(t *testing.T) {
beginAt := gtime.NewFromTime(time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local))
endAt := gtime.NewFromTime(time.Date(2027, 8, 3, 0, 0, 0, 0, time.Local))
item := &OrderItemDO{
OrderId: int64(1),
OrderSn: "SO20260803100250ABCDEFGH",
CustomerAgentId: int64(200),
AgentId: int64(100),
Quantity: 2,
ProductId: int64(300),
ProductName: "package",
ProductSn: "PKG001",
ServiceBeginTime: beginAt,
ServiceEndTime: endAt,
TenantId: "000000",
CreatedDept: int64(100),
CreatedBy: int64(1),
CreatedAt: beginAt,
UpdatedBy: int64(1),
UpdatedAt: beginAt,
}
if err := ValidateOrderItemDO(item); err != nil {
t.Fatalf("ValidateOrderItemDO returned error: %v", err)
}
for _, testCase := range []struct {
name string
clear func(*OrderItemDO)
}{
{name: "order id", clear: func(item *OrderItemDO) { item.OrderId = int64(0) }},
{name: "order number", clear: func(item *OrderItemDO) { item.OrderSn = "" }},
{name: "customer agent", clear: func(item *OrderItemDO) { item.CustomerAgentId = int64(0) }},
{name: "seller agent", clear: func(item *OrderItemDO) { item.AgentId = int64(0) }},
{name: "quantity", clear: func(item *OrderItemDO) { item.Quantity = 0 }},
{name: "product id", clear: func(item *OrderItemDO) { item.ProductId = int64(0) }},
{name: "product name", clear: func(item *OrderItemDO) { item.ProductName = "" }},
{name: "product code", clear: func(item *OrderItemDO) { item.ProductSn = "" }},
{name: "service start", clear: func(item *OrderItemDO) { item.ServiceBeginTime = nil }},
{name: "service end", clear: func(item *OrderItemDO) { item.ServiceEndTime = nil }},
{name: "tenant", clear: func(item *OrderItemDO) { item.TenantId = "" }},
{name: "creator department", clear: func(item *OrderItemDO) { item.CreatedDept = int64(0) }},
{name: "creator", clear: func(item *OrderItemDO) { item.CreatedBy = int64(0) }},
{name: "updater", clear: func(item *OrderItemDO) { item.UpdatedBy = int64(0) }},
{name: "created time", clear: func(item *OrderItemDO) { item.CreatedAt = nil }},
{name: "updated time", clear: func(item *OrderItemDO) { item.UpdatedAt = nil }},
} {
t.Run(testCase.name, func(t *testing.T) {
invalid := *item
testCase.clear(&invalid)
if err := ValidateOrderItemDO(&invalid); err == nil {
t.Fatalf("ValidateOrderItemDO accepted missing %s", testCase.name)
}
})
}
}
func assertDOFields(t *testing.T, typ reflect.Type, want []string) {
t.Helper()
got := make([]string, 0, typ.NumField()-1)
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if field.Name == "Meta" {
continue
}
got = append(got, field.Name)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("%s fields mismatch\n got: %v\nwant: %v", typ.Name(), got, want)
}
}
func assertDOColumnTags(t *testing.T, typ reflect.Type, want map[string]string) {
t.Helper()
for fieldName, column := range want {
field, ok := typ.FieldByName(fieldName)
if !ok {
t.Fatalf("%s missing field %s", typ.Name(), fieldName)
}
if got := field.Tag.Get("orm"); got != column {
t.Fatalf("%s.%s orm tag = %q, want %q", typ.Name(), fieldName, got, column)
}
}
}
@@ -0,0 +1,39 @@
package renewchange
import (
"database/sql"
"errors"
"testing"
)
func TestNormalizePriceOverrideResultTreatsNoRowsAsMissing(t *testing.T) {
got, err := normalizePriceOverrideResult(&FlowPackagePrice{}, sql.ErrNoRows)
if err != nil {
t.Fatalf("normalizePriceOverrideResult returned error: %v", err)
}
if got != nil {
t.Fatalf("normalizePriceOverrideResult returned %+v, want nil", got)
}
}
func TestNormalizePriceOverrideResultReturnsOtherErrors(t *testing.T) {
wantErr := errors.New("database unavailable")
got, err := normalizePriceOverrideResult(nil, wantErr)
if got != nil {
t.Fatalf("normalizePriceOverrideResult returned %+v on error", got)
}
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want wrapped %v", err, wantErr)
}
}
func TestNormalizePriceOverrideResultKeepsPriceRow(t *testing.T) {
want := &FlowPackagePrice{Id: 7, AgentCostProfit: 1.25}
got, err := normalizePriceOverrideResult(want, nil)
if err != nil {
t.Fatalf("normalizePriceOverrideResult returned error: %v", err)
}
if got != want {
t.Fatalf("row pointer = %p, want %p", got, want)
}
}
@@ -0,0 +1,90 @@
package renewchange
import (
"context"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
)
// LockRenewBatchMain locks the business batch before the renew transaction
// starts. This closes the gap between runtime claiming and business writes
// when a duplicate message is delivered concurrently.
func (r *Repository) LockRenewBatchMain(ctx context.Context, tx gdb.TX, batchNo string, batchType int) (*BatchMainRow, error) {
var main BatchMainRow
err := tx.Ctx(ctx).Model(TableBatchMain).
Where("batch_no", strings.TrimSpace(batchNo)).
Where("batch_type", batchType).
LockUpdate().
Scan(&main)
if err != nil {
return nil, gerror.Wrap(err, "lock renew batch main failed")
}
if main.Id == 0 {
return nil, gerror.New("renew batch main not found")
}
return &main, nil
}
// LockRenewDetails reloads every detail row inside the business transaction.
// The initial consumer read is only a routing hint; the transaction must lock
// the exact rows and let the service reject any row that is no longer running.
func (r *Repository) LockRenewDetails(ctx context.Context, tx gdb.TX, batchNo string, batchType int) ([]*BatchDetailRow, error) {
var rows []*BatchDetailRow
if err := tx.Ctx(ctx).Model(TableBatchDetail).
Where("batch_no", strings.TrimSpace(batchNo)).
Where("batch_type", batchType).
OrderAsc("row_no").
LockUpdate().
Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "lock renew details failed")
}
return rows, nil
}
// LoadRenewPackage reads the package inside the business transaction so the
// order item is built from a real package row, not a hand-written snapshot.
func (r *Repository) LoadRenewPackage(ctx context.Context, tx gdb.TX, tenantID, packageCode string) (*FlowPackageInfo, error) {
var pkg FlowPackageInfo
query := tx.Ctx(ctx).Model(TableSimFlowPackageInfo).
Where("package_code", strings.TrimSpace(packageCode))
if strings.TrimSpace(tenantID) != "" {
query = query.Where("tenant_id", strings.TrimSpace(tenantID))
}
query = query.LockUpdate()
if err := query.Scan(&pkg); err != nil {
return nil, gerror.Wrap(err, "load renew package failed")
}
if pkg.Id == 0 {
return nil, gerror.New("renew package not found")
}
return &pkg, nil
}
// UpdateRenewCard writes only the fields allowed by the renew contract.
func (r *Repository) UpdateRenewCard(ctx context.Context, tx gdb.TX, iccid string, update *do.TSimCardInfo) error {
if strings.TrimSpace(iccid) == "" {
return gerror.New("renew card ICCID is empty")
}
if update == nil {
return gerror.New("renew card update is empty")
}
result, err := tx.Ctx(ctx).Model(TableSimCardInfo).
Where("iccid", strings.TrimSpace(iccid)).
Data(update).
Update()
if err != nil {
return gerror.Wrap(err, "update renew card failed")
}
affected, err := result.RowsAffected()
if err != nil {
return gerror.Wrap(err, "read renew card update count failed")
}
if affected != 1 {
return gerror.Newf("renew card changed during execution: %s", iccid)
}
return nil
}
@@ -0,0 +1,529 @@
package renewchange
import (
"context"
"database/sql"
"errors"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
)
// Repository 提供对续费/变更相关表的 typed 访问。
// 表名/列名都是常量(见 tables.go),与 Admin 同步链路保持一致。
type Repository struct{}
func New() *Repository {
return &Repository{}
}
const simCardRenewChangeFields = "id,iccid,msisdn,imsi,dept_id,current_package_code,package_status,outbound_batch_no,flow_pkg_begin_at,flow_pkg_end_at,tenant_id,created_dept,updated_by,updated_at"
func (r *Repository) database() gdb.DB {
return db()
}
// DB 返回底层 gdb.DB,供 service 层发起事务。
func (r *Repository) DB() gdb.DB {
return r.database()
}
// LoadBatchMain 按 batchNo+batchType 加载。
func (r *Repository) LoadBatchMain(ctx context.Context, batchNo string, batchType int) (*BatchMainRow, error) {
if batchNo == "" {
return nil, gerror.New("batch number is empty")
}
m := r.database().Model(TableBatchMain).Safe().Ctx(ctx).
Where("batch_no", batchNo).
Where("batch_type", batchType)
var out BatchMainRow
if err := m.Scan(&out); err != nil {
return nil, gerror.Wrap(err, "query batch main failed")
}
if out.Id == 0 {
return nil, nil
}
return &out, nil
}
// LoadBatchDetails 加载指定批次下指定状态集合的明细。
func (r *Repository) LoadBatchDetails(ctx context.Context, batchNo string, batchType int, statuses []int) ([]*BatchDetailRow, error) {
if batchNo == "" {
return nil, nil
}
m := r.database().Model(TableBatchDetail).Safe().Ctx(ctx).
Where("batch_no", batchNo).
Where("batch_type", batchType).
OrderAsc("row_no")
if len(statuses) > 0 {
m = m.WhereIn("detail_status", statuses)
}
var out []*BatchDetailRow
if err := m.Scan(&out); err != nil {
return nil, gerror.Wrap(err, "query batch details failed")
}
return out, nil
}
func (r *Repository) LoadCardsByIdentityValues(ctx context.Context, tenantID string, values []string) ([]*SimCardInfo, error) {
uniq := uniqueStrings(values)
if len(uniq) == 0 {
return nil, nil
}
tenantID = strings.TrimSpace(tenantID)
byID := map[int64]*SimCardInfo{}
for _, column := range []string{"iccid", "msisdn", "imsi"} {
var rows []*SimCardInfo
query := r.database().Model(TableSimCardInfo).Safe().Ctx(ctx).
Fields(simCardRenewChangeFields).
WhereIn(column, uniq)
if tenantID != "" {
query = query.Where("tenant_id", tenantID)
}
if err := query.Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "query sim cards by identity failed")
}
for _, row := range rows {
if row != nil && row.Id > 0 {
byID[row.Id] = row
}
}
}
out := make([]*SimCardInfo, 0, len(byID))
for _, row := range byID {
out = append(out, row)
}
return out, nil
}
// UpdateBatchMainStatus 在事务内更新主表。
func (r *Repository) UpdateBatchMainStatus(ctx context.Context, tx gdb.TX, batchNo string, batchType int, data *BatchMainRow) error {
updateData := map[string]any{
"batch_type": data.BatchType,
"batch_async_status": data.BatchAsyncStatus,
"run_result": data.RunResult,
"total_count": data.TotalCount,
"valid_count": data.ValidCount,
"success_count": data.SuccessCount,
"fail_count": data.FailCount,
"fail_reason": data.FailReason,
"batch_info": data.BatchInfo,
"end_time": data.EndTime,
"updated_by": data.UpdatedBy,
"updated_at": gtime.Now(),
"order_sn": data.OrderSn,
"target_dept_id": data.TargetDeptId,
}
if data.StartTime != nil && !data.StartTime.IsZero() {
updateData["start_time"] = data.StartTime
}
_, err := tx.Ctx(ctx).Model(TableBatchMain).
Where("batch_no", batchNo).
Where("batch_type", batchType).
Data(updateData).
Update()
return gerror.Wrap(err, "update batch main failed")
}
// ConvertBatchDetails 将指定状态的明细转换为目标类型与状态。
func (r *Repository) ConvertBatchDetails(ctx context.Context, tx gdb.TX, batchNo string, fromTypes []int, toType, toStatus int, data map[string]any) (int64, error) {
if data == nil {
data = map[string]any{}
}
data["batch_type"] = toType
data["detail_status"] = toStatus
data["updated_at"] = gtime.Now()
result, err := tx.Ctx(ctx).Model(TableBatchDetail).
Where("batch_no", batchNo).
WhereIn("batch_type", fromTypes).
Data(data).
Update()
if err != nil {
return 0, gerror.Wrap(err, "convert batch details failed")
}
affected, err := result.RowsAffected()
if err != nil {
return 0, err
}
return affected, nil
}
// UpdateBatchDetailSnapshot 覆盖指定明细的 snapshot 与状态。
func (r *Repository) UpdateBatchDetailSnapshot(ctx context.Context, tx gdb.TX, batchNo string, batchType, rowNo int, status int, snapshot *gjson.Json, reason string, userID int64) error {
data := map[string]any{
"detail_status": status,
"snapshot": snapshot,
"updated_at": gtime.Now(),
"updated_by": userID,
}
if reason != "" {
data["error_reason"] = reason
}
_, err := tx.Ctx(ctx).Model(TableBatchDetail).
Where("batch_no", batchNo).
Where("batch_type", batchType).
Where("row_no", rowNo).
Data(data).
Update()
return gerror.Wrap(err, "update batch detail snapshot failed")
}
// UpsertBatchDetail 按 (batch_no, batch_type, row_no) 写入明细(覆盖式)。
func (r *Repository) UpsertBatchDetail(ctx context.Context, tx gdb.TX, detail *BatchDetailRow) error {
_, err := tx.Ctx(ctx).Model(TableBatchDetail).
Where("batch_no", detail.BatchNo).
Where("batch_type", detail.BatchType).
Where("row_no", detail.RowNo).
Data(map[string]any{
"detail_status": detail.DetailStatus,
"blank_out_status": detail.BlankOutStatus,
"item_id": detail.ItemId,
"error_code": detail.ErrorCode,
"error_reason": detail.ErrorReason,
"snapshot": detail.Snapshot,
"updated_at": gtime.Now(),
"updated_by": detail.UpdatedBy,
}).
Update()
if err != nil {
return gerror.Wrap(err, "update batch detail failed")
}
return nil
}
// LoadCardsByIccids 加载正式卡。
func (r *Repository) LoadCardsByIccids(ctx context.Context, iccids []string) (map[string]*SimCardInfo, error) {
out := make(map[string]*SimCardInfo, len(iccids))
uniq := uniqueStrings(iccids)
if len(uniq) == 0 {
return out, nil
}
var rows []*SimCardInfo
if err := r.database().Model(TableSimCardInfo).Safe().Ctx(ctx).
Fields(simCardRenewChangeFields).
WhereIn("iccid", uniq).
Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "query sim cards failed")
}
for _, row := range rows {
if row == nil {
continue
}
row.Iccid = strings.TrimSpace(row.Iccid)
if row.Iccid == "" {
continue
}
out[row.Iccid] = row
}
return out, nil
}
// LockCardsByIccids 在事务内按 ICCID 锁行加载正式卡。
func (r *Repository) LockCardsByIccids(ctx context.Context, tx gdb.TX, iccids []string) (map[string]*SimCardInfo, error) {
out := make(map[string]*SimCardInfo, len(iccids))
uniq := uniqueStrings(iccids)
if len(uniq) == 0 {
return out, nil
}
var rows []*SimCardInfo
if err := tx.Ctx(ctx).Model(TableSimCardInfo).
Fields(simCardRenewChangeFields).
WhereIn("iccid", uniq).
LockUpdate().
Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "lock sim cards failed")
}
for _, row := range rows {
if row == nil {
continue
}
row.Iccid = strings.TrimSpace(row.Iccid)
if row.Iccid == "" {
continue
}
out[row.Iccid] = row
}
return out, nil
}
// LoadActiveBindRowsByIccids loads every pending/effective formal package
// subscription for prepare validation. Add-on package rows (103/104) are
// intentionally excluded from current-subscription selection.
func (r *Repository) LoadActiveBindRowsByIccids(ctx context.Context, iccids []string) ([]*SimCardBindPkg, error) {
uniq := uniqueStrings(iccids)
if len(uniq) == 0 {
return nil, nil
}
var rows []*SimCardBindPkg
if err := r.database().Ctx(ctx).Model(TableSimCardBindPkg).
WhereIn("iccid", uniq).
WhereIn("package_type", consts.FormalPackageTypes()).
WhereIn("effective_status", []int{consts.EffectiveStatusPending, consts.EffectiveStatusEffective}).
OrderDesc("effective_status").
OrderAsc("effective_time").
OrderAsc("id").
Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "load active binds failed")
}
return rows, nil
}
// LoadRenewBindRowsByIccids includes expired formal-package history so renewal
// can restart after a card has naturally expired. Add-on package rows (103/104)
// never participate in renewal selection.
func (r *Repository) LoadRenewBindRowsByIccids(ctx context.Context, iccids []string) ([]*SimCardBindPkg, error) {
uniq := uniqueStrings(iccids)
if len(uniq) == 0 {
return nil, nil
}
var rows []*SimCardBindPkg
if err := r.database().Ctx(ctx).Model(TableSimCardBindPkg).
WhereIn("iccid", uniq).
WhereIn("package_type", consts.FormalPackageTypes()).
WhereIn("effective_status", []int{
consts.EffectiveStatusPending,
consts.EffectiveStatusEffective,
consts.EffectiveStatusExpired,
}).
OrderDesc("effective_status").
OrderAsc("effective_time").
OrderAsc("id").
Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "load renew binds failed")
}
return rows, nil
}
// LockActiveBindRowsByIccids is the transactional counterpart used by change
// execution before any business write. It locks formal package rows only.
func (r *Repository) LockActiveBindRowsByIccids(ctx context.Context, tx gdb.TX, iccids []string) ([]*SimCardBindPkg, error) {
uniq := uniqueStrings(iccids)
if len(uniq) == 0 {
return nil, nil
}
var rows []*SimCardBindPkg
if err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
WhereIn("iccid", uniq).
WhereIn("package_type", consts.FormalPackageTypes()).
WhereIn("effective_status", []int{consts.EffectiveStatusPending, consts.EffectiveStatusEffective}).
OrderDesc("effective_status").
OrderAsc("effective_time").
OrderAsc("id").
LockUpdate().
Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "lock active binds failed")
}
return rows, nil
}
// LockRenewBindRowsByIccids includes expired formal-package history for renewal
// execution. Change execution keeps using LockActiveBindRowsByIccids.
func (r *Repository) LockRenewBindRowsByIccids(ctx context.Context, tx gdb.TX, iccids []string) ([]*SimCardBindPkg, error) {
uniq := uniqueStrings(iccids)
if len(uniq) == 0 {
return nil, nil
}
var rows []*SimCardBindPkg
if err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
WhereIn("iccid", uniq).
WhereIn("package_type", consts.FormalPackageTypes()).
WhereIn("effective_status", []int{
consts.EffectiveStatusPending,
consts.EffectiveStatusEffective,
consts.EffectiveStatusExpired,
}).
OrderDesc("effective_status").
OrderAsc("effective_time").
OrderAsc("id").
LockUpdate().
Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "lock renew binds failed")
}
return rows, nil
}
// LockPendingBindsByIccids 锁行加载 pending 状态的未来订购。
func (r *Repository) LockPendingBindsByIccids(ctx context.Context, tx gdb.TX, iccids []string) ([]*SimCardBindPkg, error) {
uniq := uniqueStrings(iccids)
if len(uniq) == 0 {
return nil, nil
}
var rows []*SimCardBindPkg
if err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
WhereIn("iccid", uniq).
WhereIn("package_type", consts.FormalPackageTypes()).
Where("effective_status", consts.EffectiveStatusPending).
OrderAsc("effective_time").
OrderAsc("id").
LockUpdate().
Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "lock pending binds failed")
}
return rows, nil
}
// DeletePendingBindsBySubscriptionNos 删除(作废)一批订阅号。
func (r *Repository) DeletePendingBindsBySubscriptionNos(ctx context.Context, tx gdb.TX, subscriptionNos []string) (int64, error) {
uniq := uniqueStrings(subscriptionNos)
if len(uniq) == 0 {
return 0, nil
}
result, err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
WhereIn("subscription_no", uniq).
Delete()
if err != nil {
return 0, gerror.Wrap(err, "delete pending binds failed")
}
affected, err := result.RowsAffected()
if err != nil {
return 0, err
}
return affected, nil
}
// LoadPackageByCode 按 packageCode 读取套餐。
func (r *Repository) LoadPackageByCode(ctx context.Context, tenantID, packageCode string) (*FlowPackageInfo, error) {
if packageCode == "" {
return nil, errPkgNotFound
}
var row FlowPackageInfo
err := r.database().Model(TableSimFlowPackageInfo).Safe().Ctx(ctx).
Where("tenant_id", tenantID).
Where("package_code", strings.TrimSpace(packageCode)).
Scan(&row)
if err != nil {
return nil, gerror.Wrap(err, "query package failed")
}
if row.Id == 0 {
return nil, errPkgNotFound
}
return &row, nil
}
// LoadDeptName 按 deptId 取客户名。
func (r *Repository) LoadDeptName(ctx context.Context, tenantID string, deptID int64) (string, error) {
row, err := r.LoadDept(ctx, tenantID, deptID)
if err != nil {
return "", err
}
return strings.TrimSpace(row.DeptName), nil
}
// LoadDept returns the current department and its parent. Renewals use the
// parent department as the price owner for the current card owner.
func (r *Repository) LoadDept(ctx context.Context, tenantID string, deptID int64) (*SysDeptRow, error) {
if deptID <= 0 {
return nil, errDeptNotFound
}
var row SysDeptRow
err := r.database().Model(TableSysDept).Safe().Ctx(ctx).
Where("tenant_id", tenantID).
Where("dept_id", deptID).
Scan(&row)
if err != nil {
return nil, gerror.Wrap(err, "query dept failed")
}
if row.DeptId == 0 {
return nil, errDeptNotFound
}
return &row, nil
}
// LoadPriceOverride 读取代理续费/变更价格覆盖。
func (r *Repository) LoadPriceOverride(ctx context.Context, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*FlowPackagePrice, error) {
var row FlowPackagePrice
err := r.database().Model(TableSimFlowPackagePrice).Safe().Ctx(ctx).
Where("tenant_id", tenantID).
Where("dept_id", deptID).
Where("agent_scope_type", scopeType).
Where("target_agent_id", targetAgentID).
Where("package_code", strings.TrimSpace(packageCode)).
Where("enabled", 1).
Scan(&row)
return normalizePriceOverrideResult(&row, err)
}
func normalizePriceOverrideResult(row *FlowPackagePrice, err error) (*FlowPackagePrice, error) {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, gerror.Wrap(err, "query price override failed")
}
if row == nil || row.Id == 0 {
return nil, nil
}
return row, nil
}
// UpdateCardPackageFields 续费/变更后更新卡的当前套餐与流量状态。
func (r *Repository) UpdateCardPackageFields(ctx context.Context, tx gdb.TX, iccid string, fields map[string]any, userID int64) error {
if iccid == "" {
return gerror.New("iccid is empty")
}
fields["updated_by"] = userID
fields["updated_at"] = gtime.Now()
_, err := tx.Ctx(ctx).Model(TableSimCardInfo).
Where("iccid", iccid).
Data(fields).
Update()
return gerror.Wrap(err, "update sim card failed")
}
// InsertOrder 插入订单(返回 orderSn)。
// InsertOrderItem 插入订单项。
// UpdateOrderReturnStatus 更新订单退订状态。
// UpdateOrderItemExtra 合并并写入订单项 extraSkuData(JSON 字符串)。
// ActiveBindCountByOrderCodes 统计订单当前未作废的订购数。
func (r *Repository) ActiveBindCountByOrderCodes(ctx context.Context, tx gdb.TX, orderSNs []string) (map[string]int, error) {
out := make(map[string]int, len(orderSNs))
uniq := uniqueStrings(orderSNs)
if len(uniq) == 0 {
return out, nil
}
type row struct {
OrderCode string `orm:"order_code"`
Count int `orm:"count"`
}
var rows []row
if err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
Fields("order_code", "COUNT(1) AS count").
WhereIn("order_code", uniq).
Group("order_code").
Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "count active binds failed")
}
for _, r := range rows {
out[strings.TrimSpace(r.OrderCode)] = r.Count
}
return out, nil
}
// InsertBind 插入新的订购记录。
func (r *Repository) InsertBind(ctx context.Context, tx gdb.TX, bind *SimCardBindPkg) error {
_, err := tx.Ctx(ctx).Model(TableSimCardBindPkg).Data(bind).Insert()
return gerror.Wrap(err, "insert bind failed")
}
// InsertSysOss 插入 OSS 记录,返回 ID。
func (r *Repository) InsertSysOss(ctx context.Context, tx gdb.TX, row *SysOssRow) (int64, error) {
id, err := tx.Ctx(ctx).Model(TableSysOss).Data(row).InsertAndGetId()
if err != nil {
return 0, gerror.Wrap(err, "insert sys oss failed")
}
return id, nil
}
// InsertBatchDownload 插入 t_resource_batch_download 记录,返回 ID。
func (r *Repository) InsertBatchDownload(ctx context.Context, tx gdb.TX, row *ResourceBatchDownloadRow) (int64, error) {
id, err := tx.Ctx(ctx).Model(TableTResourceBatchDl).Data(row).InsertAndGetId()
if err != nil {
return 0, gerror.Wrap(err, "insert batch download failed")
}
return id, nil
}
@@ -0,0 +1,392 @@
package renewchange
import (
"encoding/json"
"sort"
"strings"
"time"
"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/gtime"
)
const (
TableSimCardInfo = "t_sim_card_info"
TableSimCardBindPkg = "t_sim_card_bind_pkg"
TableSimPoolBindPackage = "t_sim_pool_bind_package"
TableSimBindPackageLogs = "t_sim_bind_package_logs"
TableOrder = "t_order"
TableOrderItem = "t_order_item"
TableSimFlowPackageInfo = "t_sim_flow_package_info"
TableSimFlowPackagePrice = "t_sim_flow_package_price"
TableSysDept = "sys_dept"
TableBatchMain = "batch_main"
TableBatchDetail = "batch_detail"
TableAgentBalance = "t_agent_balance"
TableBalanceDetail = "t_balance_detail"
TableSysOss = "sys_oss"
TableTResourceBatchDl = "t_resource_batch_download"
)
// typed row structs(worker 端未生成 DAO/DO/entity,故使用本地 typed struct)。
type SimCardInfo struct {
Id int64 `orm:"id"`
Iccid string `orm:"iccid"`
Msisdn string `orm:"msisdn"`
Imsi string `orm:"imsi"`
RegionCode string `orm:"region_code"`
DeptId int64 `orm:"dept_id"`
AllocationBatchNo string `orm:"allocation_batch_no"`
CurrentPackageCode string `orm:"current_package_code"`
PackageStatus int `orm:"package_status"`
ChanKey string `orm:"chan_key"`
OutboundBatchNo string `orm:"outbound_batch_no"`
InboundBatchNo string `orm:"inbound_batch_no"`
FlowPkgBeginAt *gtime.Time `orm:"flow_pkg_begin_at"`
FlowPkgPresentAt *gtime.Time `orm:"flow_pkg_present_at"`
FlowPkgRealPresent *gtime.Time `orm:"flow_pkg_real_present_at"`
FlowPkgEndAt *gtime.Time `orm:"flow_pkg_end_at"`
DataServiceStatus int `orm:"data_service_status"`
PackageTotalFlowMb float64 `orm:"package_total_flow_mb"`
UsedFlowMb float64 `orm:"used_flow_mb"`
TrafficCutoffEn int `orm:"traffic_cutoff_status"`
TrafficCutoffThr float64 `orm:"traffic_cutoff_threshold"`
SmsServiceStatus int `orm:"sms_service_status"`
PackageTotalSms int `orm:"package_total_sms"`
UsedSms int `orm:"used_sms"`
VoiceServiceStatus int `orm:"voice_service_status"`
PackageTotalVoice int `orm:"package_total_voice"`
UsedVoice int `orm:"used_voice"`
RoamServiceStatus int `orm:"roam_service_status"`
PkgTotalRoamFlowMb float64 `orm:"package_total_roam_flow_mb"`
UsedRoamFlowMb float64 `orm:"used_roam_flow_mb"`
TenantId string `orm:"tenant_id"`
CreatedDept int64 `orm:"created_dept"`
UpdatedBy int64 `orm:"updated_by"`
UpdatedAt *gtime.Time `orm:"updated_at"`
}
type SimCardBindPkg struct {
Id int64 `orm:"id"`
BatchNo string `orm:"batch_no"`
Iccid string `orm:"iccid"`
Msisdn string `orm:"msisdn"`
DeptId int64 `orm:"dept_id"`
PackageType int `orm:"package_type"`
PackageCode string `orm:"package_code"`
PreviousPackageCode string `orm:"previous_package_code"`
BusinessType int `orm:"business_type"`
EffectiveStatus int `orm:"effective_status"`
HandleTime *gtime.Time `orm:"handle_time"`
EffectiveTime *gtime.Time `orm:"effective_time"`
ExpectedExpireTime *gtime.Time `orm:"expected_expire_time"`
ActualExpireTime *gtime.Time `orm:"actual_expire_time"`
DurationMonths int `orm:"duration_months"`
GiftDurationMonths int `orm:"gift_duration_months"`
OrderCode string `orm:"order_code"`
SubscriptionNo string `orm:"subscription_no"`
Remark string `orm:"remark"`
TenantId string `orm:"tenant_id"`
CreatedDept int64 `orm:"created_dept"`
CreatedBy int64 `orm:"created_by"`
CreatedAt *gtime.Time `orm:"created_at"`
UpdatedBy int64 `orm:"updated_by"`
UpdatedAt *gtime.Time `orm:"updated_at"`
}
type SimBindPackageLogs struct {
Id int64 `orm:"id"`
SubscriptionNo string `orm:"subscription_no"`
BatchNo string `orm:"batch_no"`
Iccid string `orm:"iccid"`
Msisdn string `orm:"msisdn"`
DeptId int64 `orm:"dept_id"`
PackageType int `orm:"package_type"`
PackageCode string `orm:"package_code"`
PreviousPackageCod string `orm:"previous_package_code"`
BusinessType int `orm:"business_type"`
EffectiveStatus int `orm:"effective_status"`
HandleTime *gtime.Time `orm:"handle_time"`
EffectiveTime *gtime.Time `orm:"effective_time"`
ExpectedExpireTime *gtime.Time `orm:"expected_expire_time"`
ActualExpireTime *gtime.Time `orm:"actual_expire_time"`
DurationMonths int `orm:"duration_months"`
GiftDurationMonths int `orm:"gift_duration_months"`
OrderCode string `orm:"order_code"`
Remark string `orm:"remark"`
TenantId string `orm:"tenant_id"`
CreatedDept int64 `orm:"created_dept"`
CreatedBy int64 `orm:"created_by"`
CreatedAt *gtime.Time `orm:"created_at"`
UpdatedBy int64 `orm:"updated_by"`
UpdatedAt *gtime.Time `orm:"updated_at"`
}
type FlowPackageInfo struct {
Id int64 `orm:"id"`
PackageCode string `orm:"package_code"`
PoolPackageCode int64 `orm:"pool_package_code"`
PackageName string `orm:"package_name"`
PackageType int `orm:"package_type"`
BillingMethod int `orm:"billing_method"`
FlowMb float64 `orm:"flow_mb"`
CostPrice float64 `orm:"cost_price"`
SysDefaultRatio float64 `orm:"sys_default_ratio"`
SysDefaultProfit float64 `orm:"sys_default_profit"`
RenewableDurationConfig *gjson.Json `orm:"renewable_duration_config"`
RenewablePackageCodes *gjson.Json `orm:"renewable_package_codes"`
ChangeablePackageCodes *gjson.Json `orm:"changeable_package_codes"`
TopupPackageCodes *gjson.Json `orm:"topup_package_codes"`
Enabled int `orm:"enabled"`
TenantId string `orm:"tenant_id"`
}
type PoolBindPackageLogs struct {
Id int64 `orm:"id"`
SubscriptionNo string `orm:"subscription_no"`
BatchNo string `orm:"batch_no"`
PoolNo string `orm:"pool_no"`
DeptId int64 `orm:"dept_id"`
PackageType int `orm:"package_type"`
PackageCode string `orm:"package_code"`
PreviousPackageCode string `orm:"previous_package_code"`
BusinessType int `orm:"business_type"`
EffectiveStatus int `orm:"effective_status"`
HandleTime *gtime.Time `orm:"handle_time"`
EffectiveTime *gtime.Time `orm:"effective_time"`
ExpectedExpireTime *gtime.Time `orm:"expected_expire_time"`
ActualExpireTime *gtime.Time `orm:"actual_expire_time"`
DurationMonths int `orm:"duration_months"`
GiftDurationMonths int `orm:"gift_duration_months"`
OrderCode string `orm:"order_code"`
Remark string `orm:"remark"`
TenantId string `orm:"tenant_id"`
CreatedDept int64 `orm:"created_dept"`
CreatedBy int64 `orm:"created_by"`
CreatedAt *gtime.Time `orm:"created_at"`
UpdatedBy int64 `orm:"updated_by"`
UpdatedAt *gtime.Time `orm:"updated_at"`
}
type FlowPackagePrice struct {
Id int64 `orm:"id"`
TenantId string `orm:"tenant_id"`
DeptId int64 `orm:"dept_id"`
AgentScopeType int `orm:"agent_scope_type"`
TargetAgentId int64 `orm:"target_agent_id"`
PackageCode string `orm:"package_code"`
AgentCostRatio float64 `orm:"agent_cost_ratio"`
AgentCostProfit float64 `orm:"agent_cost_profit"`
}
type SysDeptRow struct {
DeptId int64 `orm:"dept_id"`
ParentId int64 `orm:"parent_id"`
DeptName string `orm:"dept_name"`
TenantId string `orm:"tenant_id"`
}
type BatchMainRow struct {
Id int64 `orm:"id"`
BatchNo string `orm:"batch_no"`
OrderSn string `orm:"order_sn"`
BatchType int `orm:"batch_type"`
BatchStatus int `orm:"batch_status"`
BatchAsyncStatus int `orm:"batch_async_status"`
SourceType int `orm:"source_type"`
SourceBatchNo string `orm:"source_batch_no"`
IsCrossDept int `orm:"is_cross_dept"`
TargetDeptId int64 `orm:"target_dept_id"`
DeptId int64 `orm:"dept_id"`
TotalCount int `orm:"total_count"`
ValidCount int `orm:"valid_count"`
SuccessCount int `orm:"success_count"`
FailCount int `orm:"fail_count"`
RunResult int `orm:"run_result"`
FailReason string `orm:"fail_reason"`
BatchInfo *gjson.Json `orm:"batch_info"`
StartTime *gtime.Time `orm:"start_time"`
EndTime *gtime.Time `orm:"end_time"`
TenantId string `orm:"tenant_id"`
CreatedDept int64 `orm:"created_dept"`
CreatedBy int64 `orm:"created_by"`
CreatedAt *gtime.Time `orm:"created_at"`
UpdatedBy int64 `orm:"updated_by"`
UpdatedAt *gtime.Time `orm:"updated_at"`
}
type BatchDetailRow struct {
Id int64 `orm:"id"`
BatchNo string `orm:"batch_no"`
BatchType int `orm:"batch_type"`
DetailStatus int `orm:"detail_status"`
BlankOutStatus int `orm:"blank_out_status"`
RowNo int `orm:"row_no"`
ItemId string `orm:"item_id"`
ErrorCode string `orm:"error_code"`
ErrorReason string `orm:"error_reason"`
Snapshot *gjson.Json `orm:"snapshot"`
TenantId string `orm:"tenant_id"`
CreatedDept int64 `orm:"created_dept"`
CreatedBy int64 `orm:"created_by"`
CreatedAt *gtime.Time `orm:"created_at"`
UpdatedBy int64 `orm:"updated_by"`
UpdatedAt *gtime.Time `orm:"updated_at"`
}
type SysOssRow struct {
Id int64 `orm:"id"`
TenantId string `orm:"tenant_id"`
FileName string `orm:"file_name"`
OriginalName string `orm:"original_name"`
FileSuffix string `orm:"file_suffix"`
Path string `orm:"path"`
Url string `orm:"url"`
Service string `orm:"service"`
Md5 string `orm:"md5"`
FileSize int64 `orm:"file_size"`
CreatedDept int64 `orm:"created_dept"`
CreatedBy int64 `orm:"created_by"`
UpdatedBy int64 `orm:"updated_by"`
}
type ResourceBatchDownloadRow struct {
Id int64 `orm:"id"`
BatchNo string `orm:"batch_no"`
TaskName string `orm:"task_name"`
ResourceType int `orm:"resource_type"`
ExportScene int `orm:"export_scene"`
TotalCount int `orm:"total_count"`
SuccessCount int `orm:"success_count"`
FailCount int `orm:"fail_count"`
DownloadStatus int `orm:"download_status"`
OssId int64 `orm:"oss_id"`
FileName string `orm:"file_name"`
FileExt string `orm:"file_ext"`
FileUrl string `orm:"file_url"`
FilePath string `orm:"file_path"`
FileSize int64 `orm:"file_size"`
FileMd5 string `orm:"file_md5"`
DownloadCount int `orm:"download_count"`
MaxDownloadCount int `orm:"max_download_count"`
DeptId int64 `orm:"dept_id"`
StartTime *gtime.Time `orm:"start_time"`
FinishTime *gtime.Time `orm:"finish_time"`
TenantId string `orm:"tenant_id"`
CreatedDept int64 `orm:"created_dept"`
CreatedBy int64 `orm:"created_by"`
UpdatedBy int64 `orm:"updated_by"`
}
// DB 默认从 g.DB() 取,避免和现有 repository 紧耦合。
func DB() gdb.DB {
return db()
}
func db() gdb.DB {
return g.DB()
}
func uniqueStrings(in []string) []string {
if len(in) == 0 {
return nil
}
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, v := range in {
v = strings.TrimSpace(v)
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
// RoundAmount 四舍五入到分(导出以便 service 层复用)。
func RoundAmount(value float64) float64 {
return roundAmount(value)
}
func roundAmount(value float64) float64 {
if value < 0 {
return 0
}
return float64(int(value*100+0.5)) / 100
}
// RenewableDurationOptions 解析套餐的可续费时长配置。
func RenewableDurationOptions(jsonValue *gjson.Json) ([]int, error) {
return renewableDurationOptions(jsonValue)
}
func renewableDurationOptions(jsonValue *gjson.Json) ([]int, error) {
if jsonValue == nil || jsonValue.IsNil() {
return nil, errRenewDurationEmpty
}
values := jsonValue.Interfaces()
out := make([]int, 0, len(values))
seen := make(map[int]struct{}, len(values))
for _, raw := range values {
var month int
switch v := raw.(type) {
case int:
month = v
case int32:
month = int(v)
case int64:
month = int(v)
case float32:
month = int(v)
case float64:
month = int(v)
case json.Number:
n, err := v.Int64()
if err != nil {
continue
}
month = int(n)
case string:
if t, err := time.Parse("2006-01-02", strings.TrimSpace(v)); err == nil {
// tolerate date strings, fall back to ignoring
_ = t
}
continue
default:
continue
}
if month <= 0 {
continue
}
if _, ok := seen[month]; ok {
continue
}
seen[month] = struct{}{}
out = append(out, month)
}
sort.Ints(out)
if len(out) == 0 {
return nil, errRenewDurationEmpty
}
return out, nil
}
// ContainsInt 检查切片中是否包含目标值。
func ContainsInt(values []int, target int) bool {
return containsInt(values, target)
}
func containsInt(values []int, target int) bool {
for _, v := range values {
if v == target {
return true
}
}
return false
}
@@ -0,0 +1,29 @@
package renewchange
import (
"encoding/json"
"testing"
"github.com/gogf/gf/v2/encoding/gjson"
)
func TestRenewableDurationOptionsAcceptsJsonNumber(t *testing.T) {
got, err := RenewableDurationOptions(gjson.New([]any{
json.Number("3"),
json.Number("1"),
json.Number("2"),
}))
if err != nil {
t.Fatalf("RenewableDurationOptions returned error: %v", err)
}
want := []int{1, 2, 3}
if len(got) != len(want) {
t.Fatalf("options = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("options = %v, want %v", got, want)
}
}
}
@@ -0,0 +1,49 @@
package simstatus
import (
"context"
"fmt"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
)
// Result is the database projection of a successful status query.
type Result struct {
CardID int64
CardStatus int
}
type Repository struct{}
func NewRepository() *Repository { return &Repository{} }
// ApplyBatch updates all successful results in one SQL statement. The status
// timestamp is written only for cards whose upstream query succeeded.
func (r *Repository) ApplyBatch(ctx context.Context, results []Result) error {
if len(results) == 0 {
return nil
}
ids := make([]int64, 0, len(results))
var statement strings.Builder
statement.WriteString("CASE " + dao.TSimCardInfo.Columns().Id + " ")
for _, result := range results {
if result.CardID <= 0 {
return fmt.Errorf("status result has invalid cardId %d", result.CardID)
}
fmt.Fprintf(&statement, "WHEN %d THEN %d ", result.CardID, result.CardStatus)
ids = append(ids, result.CardID)
}
statement.WriteString("END")
_, err := dao.TSimCardInfo.Ctx(ctx).
WhereIn(dao.TSimCardInfo.Columns().Id, ids).
Data(do.TSimCardInfo{
BossCardStatus: gdb.Raw(statement.String()),
StatusSyncTime: gtime.Now(),
}).Update()
return err
}
@@ -0,0 +1,749 @@
package trafficcutoff
import (
"context"
"sort"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"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"
trafficcutoff "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/trafficcutoff"
)
const (
resultFileStatusSuccess = "success"
resultFileStatusFailed = "failed"
validationFailureReason = "批次校验未通过,已跳过流量断网更新"
)
type Repository struct {
db gdb.DB
resultFiles batchresult.Generator
lockEnabled bool
}
func New() *Repository {
return &Repository{resultFiles: batchresult.New(), lockEnabled: true}
}
func (r *Repository) database() gdb.DB {
if r.db != nil {
return r.db
}
return g.DB()
}
func (r *Repository) applyLock(model *gdb.Model) *gdb.Model {
if r.lockEnabled {
return model.LockUpdate()
}
return model
}
func (r *Repository) Load(ctx context.Context, batchNo string) (*trafficcutoff.Task, error) {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return nil, gerror.New("traffic cutoff batch number is empty")
}
var main entity.BatchMain
if err := r.database().Model(dao.BatchMain.Table()).Safe().Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeTrafficCutoff).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Scan(&main); err != nil {
return nil, gerror.Wrap(err, "query traffic cutoff batch main")
}
if main.Id == 0 {
return nil, gerror.New("processing traffic cutoff batch does not exist")
}
var rawDetails []*entity.BatchDetail
if err := r.database().Model(dao.BatchDetail.Table()).Safe().Ctx(ctx).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeTrafficCutoff).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
OrderAsc(dao.BatchDetail.Columns().RowNo).
Scan(&rawDetails); err != nil {
return nil, gerror.Wrap(err, "query running traffic cutoff details")
}
if len(rawDetails) == 0 {
return nil, gerror.New("traffic cutoff batch has no running details")
}
trafficCutoffStatus, err := readTrafficCutoffStatus(main.BatchInfo)
if err != nil {
return nil, err
}
task := &trafficcutoff.Task{
Batch: trafficcutoff.Batch{
ID: main.Id,
BatchNo: main.BatchNo,
BatchType: main.BatchType,
TenantID: main.TenantId,
DeptID: main.DeptId,
CreatedBy: main.CreatedBy,
CreatedDept: main.CreatedDept,
StartTime: main.StartTime,
BatchInfo: main.BatchInfo,
},
TrafficCutoffStatus: trafficCutoffStatus,
TrafficCutoffThreshold: readTrafficCutoffThreshold(main.BatchInfo),
ThresholdUnit: readThresholdUnit(main.BatchInfo),
Details: make([]*trafficcutoff.Detail, 0, len(rawDetails)),
}
for _, raw := range rawDetails {
if raw == nil {
continue
}
task.Details = append(task.Details, buildDetail(raw))
}
if len(task.Details) == 0 {
return nil, gerror.New("traffic cutoff batch has no readable running details")
}
if err := r.attachCards(ctx, task); err != nil {
return nil, err
}
return task, nil
}
func buildDetail(raw *entity.BatchDetail) *trafficcutoff.Detail {
detail := &trafficcutoff.Detail{
ID: raw.Id,
RowNo: raw.RowNo,
ICCID: strings.TrimSpace(raw.ItemId),
Reason: strings.TrimSpace(raw.ErrorReason),
}
if raw.Snapshot != nil {
if value := firstSnapshot(raw.Snapshot, "ICCID", "iccid"); value != "" {
detail.ICCID = value
}
detail.InputMSISDN = firstSnapshot(raw.Snapshot, "MSISDN", "msisdn", "inputMsisdn", "input_msisdn")
detail.InputIMSI = firstSnapshot(raw.Snapshot, "IMSI", "imsi", "inputImsi", "input_imsi")
detail.TrafficCutoffCrossMonthHold = raw.Snapshot.Get("trafficCutoffCrossMonthHold").Int()
detail.MSISDN = detail.InputMSISDN
detail.IMSI = detail.InputIMSI
detail.Authorized = raw.Snapshot.Get("authorized").Bool()
}
return detail
}
func firstSnapshot(snapshot *gjson.Json, keys ...string) string {
if snapshot == nil {
return ""
}
for _, key := range keys {
if value := strings.TrimSpace(snapshot.Get(key).String()); value != "" {
return value
}
}
return ""
}
func (r *Repository) attachCards(ctx context.Context, task *trafficcutoff.Task) error {
cards, err := r.loadCards(ctx, task.Batch.TenantID, detailICCIDList(task.Details))
if err != nil {
return err
}
attachCards(task, cards)
return nil
}
func (r *Repository) loadCards(ctx context.Context, tenantID string, iccids []string) (map[string]*entity.TSimCardInfo, error) {
result := make(map[string]*entity.TSimCardInfo)
iccids = uniqueSortedStrings(iccids)
if len(iccids) == 0 {
return result, nil
}
var cards []*entity.TSimCardInfo
if err := r.database().Model(dao.TSimCardInfo.Table()).Safe().Ctx(ctx).
Where(dao.TSimCardInfo.Columns().TenantId, tenantID).
WhereIn(dao.TSimCardInfo.Columns().Iccid, iccids).
Scan(&cards); err != nil {
return nil, gerror.Wrap(err, "query traffic cutoff SIM cards")
}
for _, card := range cards {
if card != nil && strings.TrimSpace(card.Iccid) != "" {
result[strings.TrimSpace(card.Iccid)] = card
}
}
return result, nil
}
func attachCards(task *trafficcutoff.Task, cards map[string]*entity.TSimCardInfo) {
for _, detail := range task.Details {
if detail == nil {
continue
}
card := cards[strings.TrimSpace(detail.ICCID)]
if card == nil {
detail.Card = nil
continue
}
detail.Card = &trafficcutoff.Card{
ID: card.Id,
ICCID: strings.TrimSpace(card.Iccid),
MSISDN: strings.TrimSpace(card.Msisdn),
IMSI: strings.TrimSpace(card.Imsi),
BossCardStatus: card.BossCardStatus,
TrafficCutoffStatus: card.TrafficCutoffStatus,
TrafficCutoffThreshold: card.TrafficCutoffThreshold,
}
detail.MSISDN = detail.Card.MSISDN
detail.IMSI = detail.Card.IMSI
detail.BossCardStatusBefore = detail.Card.BossCardStatus
detail.TrafficCutoffStatusBefore = detail.Card.TrafficCutoffStatus
detail.TrafficCutoffThresholdBefore = detail.Card.TrafficCutoffThreshold
}
}
func (r *Repository) SaveValidationFailure(ctx context.Context, task *trafficcutoff.Task) error {
if task == nil {
return gerror.New("traffic cutoff task is nil")
}
reason := strings.TrimSpace(task.FailureReason)
if reason == "" {
reason = validationFailureReason
}
now := gtime.Now()
return r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if err := updateDetailsFailed(ctx, tx, task, reason); err != nil {
return err
}
return updateMainFailed(ctx, tx, task, len(task.Details), reason, now)
})
}
func (r *Repository) CommitBusiness(ctx context.Context, task *trafficcutoff.Task) error {
if task == nil {
return gerror.New("traffic cutoff task is nil")
}
now := gtime.Now()
iccids := uniqueSortedStrings(detailICCIDList(task.Details))
return r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
cards, err := r.lockCards(ctx, tx, task.Batch.TenantID, iccids)
if err != nil {
return gerror.Wrap(err, "lock traffic cutoff SIM cards")
}
attachCards(task, cards)
if !validateLockedTask(task) {
reason := strings.TrimSpace(task.FailureReason)
if reason == "" {
reason = validationFailureReason
}
if err = updateDetailsFailed(ctx, tx, task, reason); err != nil {
return err
}
return updateMainFailed(ctx, tx, task, len(task.Details), reason, now)
}
ids := detailCardIDs(task.Details)
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
Where(dao.TSimCardInfo.Columns().TenantId, task.Batch.TenantID).
WhereIn(dao.TSimCardInfo.Columns().Id, ids).
Data(do.TSimCardInfo{
TrafficCutoffStatus: task.TrafficCutoffStatus,
TrafficCutoffThreshold: task.TrafficCutoffThreshold,
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return gerror.Wrap(err, "update traffic cutoff fields")
}
affected, err := result.RowsAffected()
if err != nil {
return gerror.Wrap(err, "read traffic cutoff card update result")
}
if int(affected) != len(ids) {
return gerror.New("some traffic cutoff cards changed before update")
}
if err = markCrossMonthCutoffTriggered(ctx, tx, task); err != nil {
return err
}
for _, detail := range task.Details {
if detail == nil {
continue
}
detail.TrafficCutoffStatusAfter = task.TrafficCutoffStatus
detail.TrafficCutoffThresholdAfter = task.TrafficCutoffThreshold
}
if err = updateDetailsSuccess(ctx, tx, task); err != nil {
return err
}
return updateMainSuccess(ctx, tx, task, len(task.Details), now)
})
}
func markCrossMonthCutoffTriggered(ctx context.Context, tx gdb.TX, task *trafficcutoff.Task) error {
ids := make([]int64, 0, len(task.Details))
for _, detail := range task.Details {
if detail != nil && detail.Reason == "" && detail.TrafficCutoffCrossMonthHold == 1 && detail.Card != nil {
ids = append(ids, detail.Card.ID)
}
}
ids = uniqueSortedInt64(ids)
if len(ids) == 0 {
return nil
}
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
Where(dao.TSimCardInfo.Columns().TenantId, task.Batch.TenantID).
WhereIn(dao.TSimCardInfo.Columns().Id, ids).
Where("traffic_cutoff_cross_month_hold", 1).
Data(do.TSimCardTrafficCutoffConfig{
TrafficCutoffTriggeredAt: gtime.Now(),
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return gerror.Wrap(err, "update traffic cutoff triggered time")
}
affected, err := result.RowsAffected()
if err != nil {
return gerror.Wrap(err, "read traffic cutoff triggered time update result")
}
if int(affected) != len(ids) {
return gerror.New("some traffic cutoff triggered times changed before update")
}
return nil
}
func (r *Repository) lockCards(ctx context.Context, tx gdb.TX, tenantID string, iccids []string) (map[string]*entity.TSimCardInfo, error) {
result := make(map[string]*entity.TSimCardInfo)
if len(iccids) == 0 {
return result, nil
}
var cards []*entity.TSimCardInfo
if err := r.applyLock(tx.Ctx(ctx).Model(dao.TSimCardInfo.Table())).
Where(dao.TSimCardInfo.Columns().TenantId, tenantID).
WhereIn(dao.TSimCardInfo.Columns().Iccid, iccids).
Scan(&cards); err != nil {
return nil, err
}
for _, card := range cards {
if card != nil && strings.TrimSpace(card.Iccid) != "" {
result[strings.TrimSpace(card.Iccid)] = card
}
}
return result, nil
}
func validateLockedTask(task *trafficcutoff.Task) bool {
if task == nil || len(task.Details) == 0 {
if task != nil {
task.FailureReason = "流量断网批次无有效明细"
}
return false
}
if task.TrafficCutoffStatus != 1 || task.TrafficCutoffThreshold <= 0 {
task.FailureReason = validationFailureReason
for _, detail := range task.Details {
if detail != nil {
detail.Reason = validationFailureReason
}
}
return false
}
hasFailure := false
statusSet := make(map[int]struct{})
for _, detail := range task.Details {
if detail == nil {
hasFailure = true
continue
}
detail.Reason = ""
if detail.Card == nil {
detail.Reason = "ICCID不存在或不属于当前租户"
} else if !detail.Authorized {
detail.Reason = "ICCID不在当前操作员授权范围内"
} else if detail.InputMSISDN != "" && detail.InputMSISDN != detail.Card.MSISDN {
detail.Reason = "MSISDN与卡档案不一致"
} else if detail.InputIMSI != "" && detail.InputIMSI != detail.Card.IMSI {
detail.Reason = "IMSI与卡档案不一致"
} else if detail.Card.BossCardStatus == trafficcutoff.BossCardStatusCanceled {
detail.Reason = "ICCID已销户"
}
if detail.Reason != "" {
hasFailure = true
continue
}
statusSet[detail.Card.BossCardStatus] = struct{}{}
}
if len(statusSet) > 1 {
for _, detail := range task.Details {
if detail != nil && detail.Reason == "" {
detail.Reason = "同一批次内卡状态不一致"
}
}
hasFailure = true
}
if !hasFailure {
task.ValidationOK = true
return true
}
for _, detail := range task.Details {
if detail != nil && detail.Reason == "" {
detail.Reason = "同一批次存在无效流量断网明细,已跳过更新"
}
}
task.FailureReason = validationFailureReason
return false
}
func updateDetailsFailed(ctx context.Context, tx gdb.TX, task *trafficcutoff.Task, fallback string) error {
for _, detail := range task.Details {
if detail == nil {
continue
}
if strings.TrimSpace(detail.Reason) == "" {
detail.Reason = fallback
}
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().Id, detail.ID).
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeTrafficCutoff).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
Data(do.BatchDetail{
DetailStatus: consts.BatchDetailStatusFailed,
ErrorReason: detail.Reason,
Snapshot: detail.Snapshot(task),
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return gerror.Wrap(err, "update traffic cutoff failed detail")
}
affected, err := result.RowsAffected()
if err != nil {
return gerror.Wrap(err, "read traffic cutoff failed detail result")
}
if affected != 1 {
return gerror.Newf("traffic cutoff detail %d is no longer running", detail.ID)
}
}
return nil
}
func updateDetailsSuccess(ctx context.Context, tx gdb.TX, task *trafficcutoff.Task) error {
for _, detail := range task.Details {
if detail == nil {
continue
}
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
Where(dao.BatchDetail.Columns().Id, detail.ID).
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypeTrafficCutoff).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusRunning).
Data(do.BatchDetail{
DetailStatus: consts.BatchDetailStatusSuccess,
ErrorReason: "",
Snapshot: detail.Snapshot(task),
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return gerror.Wrap(err, "update traffic cutoff success detail")
}
affected, err := result.RowsAffected()
if err != nil {
return gerror.Wrap(err, "read traffic cutoff success detail result")
}
if affected != 1 {
return gerror.Newf("traffic cutoff detail %d is no longer running", detail.ID)
}
}
return nil
}
func updateMainFailed(ctx context.Context, tx gdb.TX, task *trafficcutoff.Task, total int, reason string, now *gtime.Time) error {
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeTrafficCutoff).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Data(do.BatchMain{
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
TotalCount: total,
ValidCount: total,
SuccessCount: 0,
FailCount: total,
RunResult: consts.BatchRunResultFailed,
FailReason: reason,
BatchInfo: buildBatchInfo(task, resultFileStatusFailed, 0, 0, ""),
EndTime: now,
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return gerror.Wrap(err, "update traffic cutoff batch main failed")
}
affected, err := result.RowsAffected()
if err != nil {
return gerror.Wrap(err, "read traffic cutoff failed main result")
}
if affected != 1 {
return gerror.New("traffic cutoff batch is no longer processing")
}
task.Batch.BatchInfo = buildBatchInfo(task, resultFileStatusFailed, 0, 0, "")
return nil
}
func updateMainSuccess(ctx context.Context, tx gdb.TX, task *trafficcutoff.Task, total int, now *gtime.Time) error {
info := buildBatchInfo(task, "pending", 0, 0, "")
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeTrafficCutoff).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
Data(do.BatchMain{
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
TotalCount: total,
ValidCount: total,
SuccessCount: total,
FailCount: 0,
RunResult: consts.BatchRunResultSuccess,
FailReason: "",
BatchInfo: info,
EndTime: now,
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return gerror.Wrap(err, "update traffic cutoff batch main success")
}
affected, err := result.RowsAffected()
if err != nil {
return gerror.Wrap(err, "read traffic cutoff success main result")
}
if affected != 1 {
return gerror.New("traffic cutoff batch is no longer processing")
}
task.Batch.BatchInfo = info
return nil
}
func (r *Repository) SaveResultFile(ctx context.Context, task *trafficcutoff.Task, request batchresult.Request) error {
if task == nil {
return gerror.New("traffic cutoff task is nil")
}
artifact, err := r.artifactGenerator().Create(ctx, request)
if err != nil {
return r.updateResultFileStatus(ctx, task, resultFileStatusFailed, 0, 0, err.Error())
}
now := gtime.Now()
var (
ossID int64
downloadID int64
)
txErr := r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
var insertErr error
ossID, insertErr = tx.Ctx(ctx).Model(dao.SysOss.Table()).Data(do.SysOss{
TenantId: task.Batch.TenantID,
FileName: artifact.FileName,
OriginalName: artifact.OriginalName,
FileSuffix: artifact.FileSuffix,
Path: artifact.Path,
Url: artifact.URL,
CreatedDept: task.Batch.CreatedDept,
CreatedBy: task.Batch.CreatedBy,
UpdatedBy: task.Batch.CreatedBy,
Service: "local",
Md5: artifact.MD5,
FileSize: artifact.Size,
}).InsertAndGetId()
if insertErr != nil {
return gerror.Wrap(insertErr, "insert traffic cutoff result OSS")
}
downloadID, insertErr = tx.Ctx(ctx).Model(dao.TResourceBatchDownload.Table()).Data(do.TResourceBatchDownload{
BatchNo: task.Batch.BatchNo,
TaskName: batchresult.TaskName(task.Batch.BatchType),
ResourceType: consts.ResourceTypeExcel,
ExportScene: task.Batch.BatchType,
TotalCount: len(task.Details),
SuccessCount: task.SuccessCount(),
FailCount: task.FailCount(),
DownloadStatus: consts.DownloadStatusGenerated,
OssId: ossID,
FileName: artifact.FileName,
FileExt: artifact.FileSuffix,
FileUrl: artifact.URL,
FilePath: artifact.Path,
FileSize: artifact.Size,
FileMd5: artifact.MD5,
DownloadCount: 0,
MaxDownloadCount: 0,
DeptId: task.Batch.CreatedDept,
StartTime: task.Batch.StartTime,
FinishTime: now,
TenantId: task.Batch.TenantID,
CreatedDept: task.Batch.CreatedDept,
CreatedBy: task.Batch.CreatedBy,
UpdatedBy: task.Batch.CreatedBy,
}).InsertAndGetId()
if insertErr != nil {
return gerror.Wrap(insertErr, "insert traffic cutoff result download")
}
return updateBatchInfoOnly(ctx, tx, task, buildBatchInfo(task, resultFileStatusSuccess, ossID, downloadID, ""))
})
if txErr != nil {
batchresult.RemoveLocalFile(artifact)
return r.updateResultFileStatus(ctx, task, resultFileStatusFailed, 0, 0, txErr.Error())
}
return nil
}
func (r *Repository) artifactGenerator() batchresult.Generator {
if r.resultFiles != nil {
return r.resultFiles
}
return batchresult.New()
}
func (r *Repository) updateResultFileStatus(ctx context.Context, task *trafficcutoff.Task, status string, ossID, downloadID int64, reason string) error {
return r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
return updateBatchInfoOnly(ctx, tx, task, buildBatchInfo(task, status, ossID, downloadID, reason))
})
}
func updateBatchInfoOnly(ctx context.Context, tx gdb.TX, task *trafficcutoff.Task, info *gjson.Json) error {
result, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
Where(dao.BatchMain.Columns().BatchNo, task.Batch.BatchNo).
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeTrafficCutoff).
Data(do.BatchMain{
BatchInfo: info,
UpdatedBy: task.Batch.CreatedBy,
}).Update()
if err != nil {
return gerror.Wrap(err, "update traffic cutoff result file status")
}
affected, err := result.RowsAffected()
if err != nil {
return gerror.Wrap(err, "read traffic cutoff result file update result")
}
if affected != 1 {
return gerror.New("traffic cutoff batch disappeared while saving result file")
}
task.Batch.BatchInfo = info
return nil
}
func buildBatchInfo(task *trafficcutoff.Task, resultStatus string, ossID, downloadID int64, reason string) *gjson.Json {
info := make(map[string]any)
if task.Batch.BatchInfo != nil {
for key, value := range task.Batch.BatchInfo.Map() {
info[key] = value
}
}
info["operation"] = "dataCut"
info["trafficCutoffStatus"] = task.TrafficCutoffStatus
info["trafficCutoffThreshold"] = task.TrafficCutoffThreshold
info["thresholdUnit"] = task.ThresholdUnit
if resultStatus != "" {
resultFile := map[string]any{"status": resultStatus}
if ossID > 0 {
resultFile["ossId"] = ossID
}
if downloadID > 0 {
resultFile["downloadId"] = downloadID
}
if strings.TrimSpace(reason) != "" {
resultFile["error"] = reason
}
info["resultFile"] = resultFile
}
return gjson.New(info)
}
func readTrafficCutoffStatus(info *gjson.Json) (int, error) {
if info == nil || info.IsNil() {
return 0, gerror.New("traffic cutoff batch missing trafficCutoffStatus")
}
if value := info.Get("trafficCutoffStatus"); !value.IsNil() {
return value.Int(), nil
}
return 0, gerror.New("traffic cutoff batch missing trafficCutoffStatus")
}
func readTrafficCutoffThreshold(info *gjson.Json) float64 {
if info == nil || info.IsNil() {
return 0
}
if value := info.Get("trafficCutoffThreshold"); !value.IsNil() {
return value.Float64()
}
return info.Get("cutoffThreshold").Float64()
}
func readThresholdUnit(info *gjson.Json) string {
if info != nil {
if value := strings.TrimSpace(info.Get("thresholdUnit").String()); value != "" {
return value
}
}
return "MB"
}
func detailICCIDList(details []*trafficcutoff.Detail) []string {
iccids := make([]string, 0, len(details))
for _, detail := range details {
if detail != nil && strings.TrimSpace(detail.ICCID) != "" {
iccids = append(iccids, strings.TrimSpace(detail.ICCID))
}
}
return iccids
}
func detailCardIDs(details []*trafficcutoff.Detail) []int64 {
ids := make([]int64, 0, len(details))
for _, detail := range details {
if detail != nil && detail.Card != nil {
ids = append(ids, detail.Card.ID)
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func uniqueSortedInt64(values []int64) []int64 {
if len(values) == 0 {
return nil
}
seen := make(map[int64]struct{}, len(values))
result := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
return result
}
func uniqueSortedStrings(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
sort.Strings(out)
return out
}
var _ trafficcutoff.Repository = (*Repository)(nil)
@@ -0,0 +1,359 @@
package trafficcutoff
import (
"context"
"testing"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
batchdomain "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
)
const (
testTenantID = "000000"
testDeptID = int64(100)
)
func newTestRepository(t *testing.T) *Repository {
t.Helper()
ctx := context.Background()
db, err := gdb.New(gdb.ConfigNode{
Type: "sqlite",
Link: "sqlite::@file(:memory:)",
})
if err != nil {
t.Fatalf("create sqlite database: %v", err)
}
statements := []string{
`CREATE TABLE batch_main (
source_channel TEXT NOT NULL DEFAULT 'legacy',
execution_mode INTEGER NOT NULL DEFAULT 0,
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_no TEXT NOT NULL,
batch_type INTEGER NOT NULL,
batch_async_status INTEGER NOT NULL,
run_result INTEGER NOT NULL DEFAULT 0,
total_count INTEGER NOT NULL DEFAULT 0,
valid_count INTEGER NOT NULL DEFAULT 0,
success_count INTEGER NOT NULL DEFAULT 0,
fail_count INTEGER NOT NULL DEFAULT 0,
fail_reason TEXT,
start_time DATETIME,
end_time DATETIME,
dept_id INTEGER NOT NULL DEFAULT 0,
created_by INTEGER NOT NULL DEFAULT 0,
created_dept INTEGER NOT NULL DEFAULT 0,
tenant_id TEXT NOT NULL DEFAULT '',
batch_info TEXT
)`,
`CREATE TABLE batch_detail (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_no TEXT NOT NULL,
batch_type INTEGER NOT NULL,
detail_status INTEGER NOT NULL,
error_reason TEXT,
snapshot TEXT,
item_id TEXT,
row_no INTEGER NOT NULL DEFAULT 0,
updated_by INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE t_sim_card_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
iccid TEXT NOT NULL,
msisdn TEXT,
imsi TEXT,
boss_card_status INTEGER NOT NULL DEFAULT 0,
traffic_cutoff_status INTEGER NOT NULL DEFAULT 0,
traffic_cutoff_threshold REAL NOT NULL DEFAULT 0,
tenant_id TEXT NOT NULL DEFAULT '',
updated_by INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE sys_oss (
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
)`,
}
for _, statement := range statements {
if _, err = db.Exec(ctx, statement); err != nil {
t.Fatalf("create test schema: %v", err)
}
}
t.Cleanup(func() { _ = db.Close(ctx) })
return &Repository{db: db, lockEnabled: false}
}
func seedProcessingBatch(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, threshold float64) {
t.Helper()
info := gjson.New(map[string]any{
"operation": "dataCut",
"trafficCutoffStatus": 1,
"trafficCutoffThreshold": threshold,
"thresholdUnit": "MB",
"resultFile": map[string]any{"status": "pending"},
})
if _, err := db.Model(dao.BatchMain.Table()).Ctx(ctx).Data(do.BatchMain{
BatchNo: batchNo,
BatchType: consts.BatchTypeTrafficCutoff,
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
RunResult: batchdomain.RunResultRunning,
DeptId: testDeptID,
CreatedBy: 7,
CreatedDept: testDeptID,
TenantId: testTenantID,
BatchInfo: info,
}).Insert(); err != nil {
t.Fatalf("insert batch main: %v", err)
}
}
func seedRunningDetail(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, rowNo int, iccid, msisdn, imsi string, authorized bool) {
t.Helper()
snapshot := gjson.New(map[string]any{
"ICCID": iccid,
"MSISDN": msisdn,
"IMSI": imsi,
"authorized": authorized,
})
if _, err := db.Model(dao.BatchDetail.Table()).Ctx(ctx).Data(do.BatchDetail{
BatchNo: batchNo,
BatchType: consts.BatchTypeTrafficCutoff,
DetailStatus: consts.BatchDetailStatusRunning,
RowNo: rowNo,
ItemId: iccid,
Snapshot: snapshot,
}).Insert(); err != nil {
t.Fatalf("insert batch detail: %v", err)
}
}
func seedCard(t *testing.T, ctx context.Context, db gdb.DB, iccid, msisdn, imsi string, status int) {
t.Helper()
if _, err := db.Model(dao.TSimCardInfo.Table()).Ctx(ctx).Data(do.TSimCardInfo{
Iccid: iccid,
Msisdn: msisdn,
Imsi: imsi,
BossCardStatus: status,
TrafficCutoffStatus: 0,
TrafficCutoffThreshold: 0,
TenantId: testTenantID,
}).Insert(); err != nil {
t.Fatalf("insert sim card: %v", err)
}
}
func TestReadTrafficCutoffStatusRequiresNewField(t *testing.T) {
tests := []struct {
name string
info *gjson.Json
want int
wantErr bool
}{
{name: "new field", info: gjson.New(map[string]any{"trafficCutoffStatus": 2}), want: 2},
{name: "old field only", info: gjson.New(map[string]any{"trafficCutoffEnabled": 1}), wantErr: true},
{name: "missing batch info", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := readTrafficCutoffStatus(tt.info)
if (err != nil) != tt.wantErr {
t.Fatalf("readTrafficCutoffStatus() error = %v, wantErr %v", err, tt.wantErr)
}
if got != tt.want {
t.Fatalf("readTrafficCutoffStatus() = %d, want %d", got, tt.want)
}
})
}
}
func TestCommitBusinessHappyPathUpdatesAllCards(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "DCT-1"
seedProcessingBatch(t, ctx, repo.db, batchNo, 2048.125)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", true)
seedRunningDetail(t, ctx, repo.db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", true)
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", 4)
seedCard(t, ctx, repo.db, "898600000000002", "13800000002", "460001000000002", 4)
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.CommitBusiness(ctx, task); err != nil {
t.Fatalf("CommitBusiness() error = %v", err)
}
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess)
assertDetailStatusCount(t, ctx, repo.db, batchNo, consts.BatchDetailStatusSuccess, 2)
assertTrafficCutoff(t, ctx, repo.db, "898600000000001", 1, 2048.125)
assertTrafficCutoff(t, ctx, repo.db, "898600000000002", 1, 2048.125)
}
func TestCommitBusinessFailsWholeBatchOnMixedStatusWithoutCardUpdate(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "DCT-2"
seedProcessingBatch(t, ctx, repo.db, batchNo, 1024)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", true)
seedRunningDetail(t, ctx, repo.db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", true)
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", 4)
seedCard(t, ctx, repo.db, "898600000000002", "13800000002", "460001000000002", 5)
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.CommitBusiness(ctx, task); err != nil {
t.Fatalf("CommitBusiness() error = %v", err)
}
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed)
assertDetailStatusCount(t, ctx, repo.db, batchNo, consts.BatchDetailStatusFailed, 2)
assertTrafficCutoff(t, ctx, repo.db, "898600000000001", 0, 0)
assertTrafficCutoff(t, ctx, repo.db, "898600000000002", 0, 0)
}
func TestCommitBusinessFailsWholeBatchOnCanceledCardWithoutCardUpdate(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "DCT-3"
seedProcessingBatch(t, ctx, repo.db, batchNo, 512)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", true)
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", 9)
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.CommitBusiness(ctx, task); err != nil {
t.Fatalf("CommitBusiness() error = %v", err)
}
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed)
assertTrafficCutoff(t, ctx, repo.db, "898600000000001", 0, 0)
}
func TestUpdateResultFileFailureOnlyMarksBatchInfo(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "DCT-4"
seedProcessingBatch(t, ctx, repo.db, batchNo, 512)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", true)
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.updateResultFileStatus(ctx, task, "failed", 0, 0, "disk full"); err != nil {
t.Fatalf("updateResultFileStatus() error = %v", err)
}
var row struct {
Info string `orm:"batch_info"`
}
if err = repo.db.Model(dao.BatchMain.Table()).Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Fields(dao.BatchMain.Columns().BatchInfo).
Scan(&row); err != nil {
t.Fatalf("query batch main: %v", err)
}
info := gjson.New([]byte(row.Info))
if info.Get("resultFile.status").String() != "failed" {
t.Fatalf("resultFile.status = %s, want failed", info.Get("resultFile.status").String())
}
if info.Get("resultFile.error").String() != "disk full" {
t.Fatalf("resultFile.error = %s, want disk full", info.Get("resultFile.error").String())
}
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusProcessing, batchdomain.RunResultRunning)
}
func assertBatchMainStatus(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, wantAsync, wantRun int) {
t.Helper()
var row struct {
Async int `orm:"batch_async_status"`
Run int `orm:"run_result"`
}
if err := db.Model(dao.BatchMain.Table()).Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Fields(dao.BatchMain.Columns().BatchAsyncStatus, dao.BatchMain.Columns().RunResult).
Scan(&row); err != nil {
t.Fatalf("query batch main: %v", err)
}
if row.Async != wantAsync || row.Run != wantRun {
t.Fatalf("batch status = (async=%d, run=%d), want (async=%d, run=%d)", row.Async, row.Run, wantAsync, wantRun)
}
}
func assertDetailStatusCount(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, status, want int) {
t.Helper()
count, err := db.Model(dao.BatchDetail.Table()).Ctx(ctx).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().DetailStatus, status).
Count()
if err != nil {
t.Fatalf("count details: %v", err)
}
if count != want {
t.Fatalf("detail status %d count = %d, want %d", status, count, want)
}
}
func assertTrafficCutoff(t *testing.T, ctx context.Context, db gdb.DB, iccid string, enabled int, threshold float64) {
t.Helper()
var row struct {
Enabled int `orm:"traffic_cutoff_status"`
Threshold float64 `orm:"traffic_cutoff_threshold"`
}
if err := db.Model(dao.TSimCardInfo.Table()).Ctx(ctx).
Where(dao.TSimCardInfo.Columns().Iccid, iccid).
Fields(dao.TSimCardInfo.Columns().TrafficCutoffStatus, dao.TSimCardInfo.Columns().TrafficCutoffThreshold).
Scan(&row); err != nil {
t.Fatalf("query traffic cutoff fields: %v", err)
}
if row.Enabled != enabled || row.Threshold != threshold {
t.Fatalf("%s cutoff = (%d, %v), want (%d, %v)", iccid, row.Enabled, row.Threshold, enabled, threshold)
}
}
@@ -0,0 +1,159 @@
package trafficusage
import (
"context"
"fmt"
"hash/fnv"
"math"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"github.com/gogf/gf/v2/errors/gerror"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/library/tdengine"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
)
var identifierPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
type Repository struct{}
func New() *Repository {
return &Repository{}
}
func (r *Repository) UpdateDaily(ctx context.Context, items []model.TrafficUsageDailyItem) error {
config := tdengine.GetConfig(ctx)
return writeUsage(ctx, config, config.DbNameFor(config.DailyStable), config.DailyStable, items, func(item model.TrafficUsageDailyItem) time.Time {
return snapshotDay(item.Date)
})
}
func (r *Repository) UpdateMonthly(ctx context.Context, items []model.TrafficUsageMonthlyItem) error {
config := tdengine.GetConfig(ctx)
return writeUsage(ctx, config, config.DbNameFor(config.MonthlyStable), config.MonthlyStable, items, func(item model.TrafficUsageMonthlyItem) time.Time {
return snapshotMonth(item.Month)
})
}
type usageItem interface {
model.TrafficUsageDailyItem | model.TrafficUsageMonthlyItem
}
func writeUsage[T usageItem](ctx context.Context, config tdengine.Config, dbName, stable string, items []T, timestamp func(T) time.Time) error {
if len(items) == 0 {
return nil
}
if !tdengine.Enabled(ctx) {
return gerror.New("TDengine disabled; usage was not persisted")
}
if !identifierPattern.MatchString(dbName) || !identifierPattern.MatchString(stable) {
return gerror.New("invalid tdengine database or stable name")
}
batchSize := config.BatchSize
if batchSize <= 0 {
batchSize = 200
}
for start := 0; start < len(items); start += batchSize {
end := min(start+batchSize, len(items))
var builder strings.Builder
builder.WriteString("INSERT INTO ")
for index, item := range items[start:end] {
base := usageBase(item)
if base.Iccid == "" {
return gerror.New("traffic usage item ICCID is empty")
}
if math.IsNaN(base.UsageMb) || math.IsInf(base.UsageMb, 0) || base.UsageMb < 0 {
return gerror.Newf("invalid traffic usage value: %v", base.UsageMb)
}
if index > 0 {
builder.WriteByte(' ')
}
builder.WriteString(fmt.Sprintf("%s.%s USING %s.%s TAGS (%s) VALUES (%s, %s, %s)",
dbName,
childTableName(stable, base.Iccid),
dbName,
stable,
sqlString(base.Iccid),
sqlTime(timestamp(item)),
strconv.FormatFloat(base.UsageMb, 'f', -1, 64),
sqlString(base.PackageCode),
))
}
if _, err := tdengine.ExecForDb(ctx, dbName, builder.String()); err != nil {
return gerror.Wrap(err, "write traffic usage failed")
}
}
return nil
}
func usageBase[T usageItem](item T) model.TrafficUsageBase {
switch value := any(item).(type) {
case model.TrafficUsageDailyItem:
return value.TrafficUsageBase
case model.TrafficUsageMonthlyItem:
return value.TrafficUsageBase
default:
return model.TrafficUsageBase{}
}
}
// childTableName builds the TDengine child-table name for one ICCID.
//
// The rule must stay byte-identical to Admin's childTableNameWithPrefix
// (iotlcm-admin-svr internal/logic/trafficusage/traffic_usage.go): drop every
// character that is not a letter, digit or underscore, and fall back to an FNV
// hash when the result is empty or longer than 190 characters. Admin's QueryDaily
// reads child tables by that same rule, so diverging here would silently split
// one card across two child tables and hide its data from the diagnosis page.
func childTableName(stable, iccid string) string {
prefix := "sim_card_usage_m_"
if stable == "t_sim_card_usage_daily" {
prefix = "sim_card_usage_d_"
}
var builder strings.Builder
builder.WriteString(prefix)
for _, char := range strings.ToLower(iccid) {
if unicode.IsLetter(char) || unicode.IsDigit(char) || char == '_' {
builder.WriteRune(char)
}
}
name := builder.String()
if name == prefix || len(name) > 190 {
return fmt.Sprintf("%s%x", prefix, hashString(iccid))
}
return name
}
// hashString mirrors Admin's FNV-1a fallback so both sides derive the same
// child-table name for identifiers that cannot be embedded verbatim.
func hashString(value string) uint32 {
hash := fnv.New32a()
_, _ = hash.Write([]byte(value))
return hash.Sum32()
}
func snapshotDay(value time.Time) time.Time {
if value.IsZero() {
value = time.Now()
}
return time.Date(value.Year(), value.Month(), value.Day(), 23, 59, 59, 0, value.Location())
}
func snapshotMonth(value time.Time) time.Time {
if value.IsZero() {
value = time.Now()
}
return time.Date(value.Year(), value.Month()+1, 0, 23, 59, 59, 0, value.Location())
}
func sqlString(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func sqlTime(value time.Time) string {
return sqlString(value.Format("2006-01-02 15:04:05.000"))
}
@@ -0,0 +1,149 @@
package trafficusage
import (
"fmt"
"hash/fnv"
"strings"
"testing"
"time"
"unicode"
)
// adminChildTableName is a verbatim copy of Admin's childTableNameWithPrefix
// (iotlcm-admin-svr internal/logic/trafficusage/traffic_usage.go). It exists so
// the worker rule is asserted against the production reader rule rather than
// against itself: Admin's QueryDaily resolves child tables with this rule, so
// any divergence here would silently hide a card's daily usage.
func adminChildTableName(prefix, value string) string {
var builder strings.Builder
builder.WriteString(prefix)
for _, r := range strings.ToLower(value) {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
builder.WriteRune(r)
}
}
name := builder.String()
if name == prefix || len(name) > 190 {
hash := fnv.New32a()
_, _ = hash.Write([]byte(value))
return fmt.Sprintf("%s%x", prefix, hash.Sum32())
}
return name
}
func adminChildTrafficTableName(stable, iccid string) string {
prefix := "sim_card_usage_m_"
if stable == "t_sim_card_usage_daily" {
prefix = "sim_card_usage_d_"
}
return adminChildTableName(prefix, iccid)
}
func TestChildTableNameMatchesAdminRule(t *testing.T) {
longICCID := strings.Repeat("8", 191)
veryLongICCID := strings.Repeat("89", 120)
cases := []string{
"",
"89860001123456789012",
"8986-0001",
"8986 0001",
"8986.0001",
"8986/0001",
"ICCID_8986",
"8986-0001-中文",
"中文卡号",
"____",
"8986+0001",
"8986'0001",
longICCID,
veryLongICCID,
}
for _, stable := range []string{"t_sim_card_usage_daily", "t_sim_card_usage_monthly"} {
for _, iccid := range cases {
want := adminChildTrafficTableName(stable, iccid)
got := childTableName(stable, iccid)
if got != want {
t.Errorf("childTableName(%q, %q) = %q, admin rule yields %q", stable, iccid, got, want)
}
if !strings.HasPrefix(got, "sim_card_usage_") {
t.Errorf("childTableName(%q, %q) = %q loses the table prefix", stable, iccid, got)
}
}
}
}
// TestChildTableNameDirtyICCIDSplitsOnce guards the original defect: the old
// worker rule replaced illegal characters with "_" while Admin dropped them, so
// a dirty ICCID could land in a different child table than the reader queried.
func TestChildTableNameDirtyICCIDSplitsOnce(t *testing.T) {
for _, iccid := range []string{"8986-0001", "8986 0001", "8986.0001"} {
got := childTableName("t_sim_card_usage_daily", iccid)
if strings.Contains(got, "__") {
t.Errorf("childTableName(%q) = %q still substitutes instead of dropping", iccid, got)
}
if got != "sim_card_usage_d_89860001" {
t.Errorf("childTableName(%q) = %q, want the illegal characters dropped", iccid, got)
}
}
}
func TestChildTableNameLongIdentifierUsesHashFallback(t *testing.T) {
iccid := strings.Repeat("8", 191)
got := childTableName("t_sim_card_usage_daily", iccid)
if len(got) > 190 {
t.Fatalf("childTableName kept an over-long name: len=%d", len(got))
}
if got != adminChildTrafficTableName("t_sim_card_usage_daily", iccid) {
t.Fatalf("hash fallback diverged from the admin rule: %q", got)
}
}
func TestSnapshotDayUsesEndOfDay(t *testing.T) {
// The dashboard "yesterday top 5" and the diagnosis page both select the
// 23:59:59.000 row, so the timestamp contract must not drift.
day := snapshotDay(mustParse(t, "2026-10-09T13:45:00+08:00"))
if got := day.Format("2006-01-02 15:04:05.000"); got != "2026-10-09 23:59:59.000" {
t.Fatalf("snapshotDay = %s, want 2026-10-09 23:59:59.000", got)
}
}
func mustParse(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse %q: %v", value, err)
}
return parsed
}
// TestSnapshotMonthMatchesAdminRule pins the monthly timestamp contract.
//
// The diagnosis page reads monthly history by ts and groups it with
// ts.Format("2006-01"), while admin's monthStart wrote the end-of-month
// 23:59:59.000 row. A migration that changed this would leave the page reading
// a month that never gets written.
func TestSnapshotMonthMatchesAdminRule(t *testing.T) {
// adminMonthStart is a verbatim copy of Admin's monthStart
// (iotlcm-admin-svr internal/logic/trafficusage/traffic_usage.go), despite
// its name returning the end of the month.
adminMonthStart := func(tm time.Time) time.Time {
if tm.IsZero() {
tm = time.Now()
}
return time.Date(tm.Year(), tm.Month()+1, 0, 23, 59, 59, 0, tm.Location())
}
location := time.FixedZone("UTC+8", 8*60*60)
for _, month := range []time.Time{
time.Date(2026, 9, 1, 0, 0, 0, 0, location),
time.Date(2026, 2, 1, 0, 0, 0, 0, location),
time.Date(2026, 12, 1, 0, 0, 0, 0, location),
// Leap February must land on the 29th.
time.Date(2024, 2, 1, 0, 0, 0, 0, location),
} {
want := adminMonthStart(month).Format("2006-01-02 15:04:05.000")
got := snapshotMonth(month).Format("2006-01-02 15:04:05.000")
if got != want {
t.Errorf("snapshotMonth(%s) = %s, admin rule yields %s", month.Format("2006-01"), got, want)
}
}
}
@@ -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")
}
}
@@ -0,0 +1,407 @@
package unsub
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
unsub "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/unsub"
)
const (
agentBalanceTable = "t_agent_balance"
balanceDetailTable = "t_balance_detail"
balanceAccountStatusNormal = 0
financeIncomeExpenseIncome = 0
financeTransactionTypeConsume = 1
financeTransactionChannelBalance = 3
financeTransactionStatusPaid = 1
refundStatusRefunded = "refunded"
refundStatusNoRefund = "no_refund"
refundStatusSkipped = "skipped"
)
type balanceAccountQuery struct {
Id int64 `orm:"id"`
DeptId int64 `orm:"dept_id"`
Balance float64 `orm:"balance"`
Status int `orm:"status"`
TenantId string `orm:"tenant_id"`
}
type balanceDetailQuery struct {
Id int64 `orm:"id"`
IncomeExpenseType int `orm:"income_expense_type"`
TransactionStatus int `orm:"transaction_status"`
Amount float64 `orm:"amount"`
}
func unsubscribeTime(task *unsub.Task, fallback *gtime.Time) *gtime.Time {
if task != nil && task.Batch.StartTime != nil {
return task.Batch.StartTime
}
return fallback
}
func (r *Repository) lockOrdersByOrderCodes(ctx context.Context, tx gdb.TX, tenantID string, orderCounts map[string]int) (map[string]*orderQuery, error) {
result := make(map[string]*orderQuery)
orderCodes := sortedOrderCodes(orderCounts)
if len(orderCodes) == 0 {
return result, nil
}
var rows []*orderQuery
model := r.applyLock(tx.Ctx(ctx).Model(orderTable)).
WhereIn("order_sn", orderCodes).
Fields("id", "order_sn", "customer_agent_id", "pay_type", "pay_status", "pay_order_sn", "total_amount", "paid_amount", "balance", "unrefund_amount", "tenant_id", "created_dept")
if strings.TrimSpace(tenantID) != "" {
model = model.Where("tenant_id", tenantID)
}
if err := model.Scan(&rows); err != nil {
return nil, err
}
for _, row := range rows {
if row != nil && strings.TrimSpace(row.OrderSn) != "" {
result[strings.TrimSpace(row.OrderSn)] = row
}
}
return result, nil
}
func (r *Repository) lockOrderItemsByOrderCodes(ctx context.Context, tx gdb.TX, tenantID string, orderCounts map[string]int) (map[string]*orderItemQuery, error) {
result := make(map[string]*orderItemQuery)
orderCodes := sortedOrderCodes(orderCounts)
if len(orderCodes) == 0 {
return result, nil
}
var rows []*orderItemQuery
model := r.applyLock(tx.Ctx(ctx).Model(orderItemTable)).
WhereIn("order_sn", orderCodes).
Fields("item_id", "order_sn", "price", "quantity", "service_begin_time", "service_end_time", "extra_sku_data")
if strings.TrimSpace(tenantID) != "" {
model = model.Where("tenant_id", tenantID)
}
if err := model.Scan(&rows); err != nil {
return nil, err
}
for _, row := range rows {
if row == nil || strings.TrimSpace(row.OrderSn) == "" {
continue
}
orderCode := strings.TrimSpace(row.OrderSn)
if _, exists := result[orderCode]; !exists {
result[orderCode] = row
}
}
return result, nil
}
func (r *Repository) refundOrders(
ctx context.Context,
tx gdb.TX,
task *unsub.Task,
orderCounts map[string]int,
bindsByIccid map[string]*bindPkgQuery,
orders map[string]*orderQuery,
orderItems map[string]*orderItemQuery,
activeCounts map[string]int,
cutoffTime, transactionTime *gtime.Time,
) (map[string]*unsub.OrderRefund, error) {
if cutoffTime == nil {
cutoffTime = transactionTime
}
if cutoffTime == nil {
return nil, gerror.New("unsub refund cutoff time is empty")
}
result := make(map[string]*unsub.OrderRefund, len(orderCounts))
orderBinds := make(map[string][]*bindPkgQuery, len(orderCounts))
for _, detail := range task.Details {
if detail == nil {
continue
}
bind := bindsByIccid[detail.ICCID]
if bind == nil {
continue
}
orderCode := strings.TrimSpace(bind.OrderCode)
if orderCode != "" {
orderBinds[orderCode] = append(orderBinds[orderCode], bind)
}
}
for _, orderCode := range sortedOrderCodes(orderCounts) {
refund := &unsub.OrderRefund{Status: refundStatusNoRefund}
result[orderCode] = refund
order := orders[orderCode]
if order == nil {
refund.Status = refundStatusSkipped
refund.Reason = "order not found"
g.Log().Warningf(ctx, "unsub balance refund skipped, order not found, batchNo=%s orderNo=%s", task.Batch.BatchNo, orderCode)
continue
}
if order.PayType != consts.PayTypeBalance {
refund.Status = refundStatusSkipped
refund.Reason = "order is not paid by balance"
g.Log().Warningf(ctx, "unsub balance refund skipped, order is not paid by balance, batchNo=%s orderNo=%s payType=%d", task.Batch.BatchNo, orderCode, order.PayType)
continue
}
if order.PayStatus != 0 && order.PayStatus != consts.PayStatusPaid {
refund.Status = refundStatusSkipped
refund.Reason = "order is not paid"
g.Log().Warningf(ctx, "unsub balance refund skipped, order is not paid, batchNo=%s orderNo=%s payStatus=%d", task.Batch.BatchNo, orderCode, order.PayStatus)
continue
}
binds := orderBinds[orderCode]
if len(binds) == 0 {
continue
}
item := orderItems[orderCode]
totalQuantity := resolveOrderQuantity(order, item, activeCounts[orderCode])
if totalQuantity <= 0 {
return nil, gerror.Newf("order %s lacks a valid subscription quantity for refund", orderCode)
}
paidAmount := orderPaidAmount(order, item, totalQuantity)
if paidAmount <= 0 {
refund.Reason = "order paid amount is zero"
continue
}
unitPaidAmount := paidAmount / float64(totalQuantity)
refundAmount := 0.0
for _, bind := range binds {
if bind == nil {
continue
}
if bind.EffectiveTime == nil || bind.DurationMonths <= 0 {
return nil, gerror.Newf("order %s has an invalid paid subscription period", orderCode)
}
refundAmount += unitPaidAmount * calculateRefundRatio(bind.EffectiveTime.Time, cutoffTime.Time, bind.DurationMonths)
}
refund.Amount = roundUnsubAmount(refundAmount)
if refund.Amount <= 0 {
refund.Reason = "paid subscription period has been fully used"
continue
}
refund.SerialNo = unsubscribeRefundSerialNo(task.Batch.BatchNo, orderCode)
existing, err := r.lockRefundDetail(ctx, tx, task.Batch.TenantID, refund.SerialNo)
if err != nil {
return nil, gerror.Wrap(err, "query existing unsub refund detail")
}
if existing.Id != 0 {
if existing.IncomeExpenseType != financeIncomeExpenseIncome || existing.TransactionStatus != financeTransactionStatusPaid {
return nil, gerror.Newf("existing unsub refund detail is invalid, order %s", orderCode)
}
refund.Amount = roundUnsubAmount(existing.Amount)
refund.Status = refundStatusRefunded
refund.Reason = "refund detail already exists"
continue
}
payerDeptID := refundPayerDeptID(order, binds)
if payerDeptID <= 0 {
return nil, gerror.Newf("order %s lacks a balance payer department", orderCode)
}
tenantID := strings.TrimSpace(order.TenantId)
if tenantID == "" {
tenantID = strings.TrimSpace(task.Batch.TenantID)
}
if tenantID == "" {
return nil, gerror.Newf("order %s lacks a tenant id for refund", orderCode)
}
var account balanceAccountQuery
accountModel := r.applyLock(tx.Ctx(ctx).Model(agentBalanceTable)).
Where("dept_id", payerDeptID).
Where("tenant_id", tenantID).
Fields("id", "dept_id", "balance", "status", "tenant_id")
if err := accountModel.Scan(&account); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, gerror.Wrap(err, "query balance account for unsub refund")
}
if account.Id == 0 {
return nil, gerror.Newf("balance account for order %s does not exist", orderCode)
}
if account.Status != balanceAccountStatusNormal {
return nil, gerror.Newf("balance account for order %s is not available", orderCode)
}
before := roundUnsubAmount(account.Balance)
after := roundUnsubAmount(before + refund.Amount)
accountUpdate, err := tx.Ctx(ctx).Model(agentBalanceTable).
Where("id", account.Id).
Data(g.Map{
"balance": after,
"updated_by": task.Batch.CreatedBy,
"updated_at": transactionTime,
}).Update()
if err != nil {
return nil, gerror.Wrap(err, "update balance account for unsub refund")
}
if affected, err := accountUpdate.RowsAffected(); err != nil {
return nil, gerror.Wrap(err, "read balance account update result")
} else if affected != 1 {
return nil, gerror.Newf("balance account for order %s changed during refund", orderCode)
}
createdDept := order.CreatedDept
if createdDept <= 0 {
createdDept = payerDeptID
}
if _, err := tx.Ctx(ctx).Model(balanceDetailTable).Data(g.Map{
"dept_id": payerDeptID,
"serial_no": refund.SerialNo,
"transaction_time": transactionTime,
"income_expense_type": financeIncomeExpenseIncome,
"transaction_type": financeTransactionTypeConsume,
"transaction_channel": financeTransactionChannelBalance,
"transaction_status": financeTransactionStatusPaid,
"amount": refund.Amount,
"before_balance": before,
"balance_after": after,
"order_sn": orderCode,
"remark": fmt.Sprintf("SIM card unsubscribe balance refund, batchNo=%s, orderNo=%s", task.Batch.BatchNo, orderCode),
"tenant_id": tenantID,
"created_dept": createdDept,
"created_by": task.Batch.CreatedBy,
"created_at": transactionTime,
}).Insert(); err != nil {
return nil, gerror.Wrap(err, "insert unsub refund detail")
}
refund.Status = refundStatusRefunded
}
return result, nil
}
func (r *Repository) lockRefundDetail(ctx context.Context, tx gdb.TX, tenantID, serialNo string) (*balanceDetailQuery, error) {
var row balanceDetailQuery
model := r.applyLock(tx.Ctx(ctx).Model(balanceDetailTable)).
Where("tenant_id", tenantID).
Where("serial_no", serialNo).
Fields("id", "income_expense_type", "transaction_status", "amount")
if err := model.Scan(&row); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
return &row, nil
}
func sortedOrderCodes(orderCounts map[string]int) []string {
orderCodes := make([]string, 0, len(orderCounts))
for orderCode := range orderCounts {
if strings.TrimSpace(orderCode) != "" {
orderCodes = append(orderCodes, orderCode)
}
}
sort.Strings(orderCodes)
return orderCodes
}
func resolveOrderQuantity(order *orderQuery, item *orderItemQuery, activeCount int) int {
if item != nil && item.Quantity > 0 {
return item.Quantity
}
paidAmount := orderPaidAmount(order, nil, 0)
if item != nil && item.Price > 0 && paidAmount > 0 {
quantity := int(math.Round(paidAmount / item.Price))
if quantity > 0 {
return quantity
}
}
return activeCount
}
func orderPaidAmount(order *orderQuery, item *orderItemQuery, quantity int) float64 {
if order != nil {
if order.Balance > 0 {
return order.Balance
}
if order.PaidAmount > 0 {
return order.PaidAmount
}
if order.TotalAmount > 0 {
return order.TotalAmount
}
}
if item != nil && item.Price > 0 && quantity > 0 {
return item.Price * float64(quantity)
}
return 0
}
func refundPayerDeptID(order *orderQuery, binds []*bindPkgQuery) int64 {
if order != nil && order.CustomerAgentId > 0 {
return order.CustomerAgentId
}
for _, bind := range binds {
if bind == nil {
continue
}
if deptID, err := strconv.ParseInt(strings.TrimSpace(bind.DeptId), 10, 64); err == nil && deptID > 0 {
return deptID
}
}
if order != nil && order.CreatedDept > 0 {
return order.CreatedDept
}
return 0
}
func unsubscribeRefundSerialNo(batchNo, orderCode string) string {
return fmt.Sprintf("UNSUBREFUND-%s-%s", strings.TrimSpace(batchNo), strings.TrimSpace(orderCode))
}
func roundUnsubAmount(value float64) float64 {
if value <= 0 {
return 0
}
return math.Round(value*100) / 100
}
func calculateRefundRatio(effectiveAt, cutoffAt time.Time, durationMonths int) float64 {
if effectiveAt.IsZero() || cutoffAt.IsZero() || durationMonths <= 0 {
return 0
}
paidEndAt := addRefundMonths(effectiveAt, durationMonths)
if !cutoffAt.After(effectiveAt) {
return 1
}
if !cutoffAt.Before(paidEndAt) {
return 0
}
totalSeconds := paidEndAt.Sub(effectiveAt).Seconds()
remainingSeconds := paidEndAt.Sub(cutoffAt).Seconds()
if totalSeconds <= 0 || remainingSeconds <= 0 {
return 0
}
ratio := remainingSeconds / totalSeconds
if ratio < 0 {
return 0
}
if ratio > 1 {
return 1
}
return ratio
}
func addRefundMonths(value time.Time, months int) time.Time {
if months <= 0 {
return value
}
targetMonth := time.Date(value.Year(), value.Month()+time.Month(months), 1, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location())
day := value.Day()
lastDay := time.Date(targetMonth.Year(), targetMonth.Month()+1, 0, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location()).Day()
if day > lastDay {
day = lastDay
}
return time.Date(targetMonth.Year(), targetMonth.Month(), day, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location())
}
@@ -0,0 +1,37 @@
package unsub
import (
"math"
"testing"
"time"
)
func TestCalculateRefundRatio(t *testing.T) {
effective := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
cases := []struct {
name string
cutoff time.Time
want float64
}{
{name: "before effective refunds all", cutoff: effective.Add(-time.Hour), want: 1},
{name: "half paid period", cutoff: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), want: 184.0 / 365.0},
{name: "during gift period refunds none", cutoff: time.Date(2027, 2, 1, 0, 0, 0, 0, time.UTC), want: 0},
{name: "after paid period refunds none", cutoff: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), want: 0},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
got := calculateRefundRatio(effective, testCase.cutoff, 12)
if math.Abs(got-testCase.want) > 0.000001 {
t.Fatalf("calculateRefundRatio() = %v, want %v", got, testCase.want)
}
})
}
}
func TestAddRefundMonthsClampsMonthEnd(t *testing.T) {
got := addRefundMonths(time.Date(2026, 1, 31, 12, 0, 0, 0, time.UTC), 1)
want := time.Date(2026, 2, 28, 12, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("addRefundMonths() = %v, want %v", got, want)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,790 @@
package unsub
import (
"context"
"math"
"testing"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
batchdomain "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
unsub "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/unsub"
)
const (
testTenantID = "000000"
testDeptID = int64(100)
testPackage = "PKG-A"
testSource = "OUT-1"
testInbound = "IN-1"
)
func newTestRepository(t *testing.T) *Repository {
t.Helper()
ctx := context.Background()
db, err := gdb.New(gdb.ConfigNode{
Type: "sqlite",
Link: "sqlite::@file(:memory:)",
})
if err != nil {
t.Fatalf("create sqlite database: %v", err)
}
statements := []string{
`CREATE TABLE batch_main (
source_channel TEXT NOT NULL DEFAULT 'legacy',
execution_mode INTEGER NOT NULL DEFAULT 0,
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_no TEXT NOT NULL,
batch_type INTEGER NOT NULL,
batch_async_status INTEGER NOT NULL,
run_result INTEGER NOT NULL DEFAULT 0,
fail_reason TEXT,
start_time DATETIME,
end_time DATETIME,
dept_id INTEGER NOT NULL DEFAULT 0,
source_batch_no TEXT,
created_by INTEGER NOT NULL DEFAULT 0,
created_dept INTEGER NOT NULL DEFAULT 0,
tenant_id TEXT NOT NULL DEFAULT '',
batch_info TEXT
)`,
`CREATE TABLE batch_detail (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_no TEXT NOT NULL,
batch_type INTEGER NOT NULL,
detail_status INTEGER NOT NULL,
error_reason TEXT,
snapshot TEXT,
item_id TEXT,
row_no INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE t_sim_card_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
iccid TEXT NOT NULL,
msisdn TEXT,
imsi TEXT,
imei TEXT,
chan_key TEXT,
dept_id INTEGER NOT NULL DEFAULT 0,
created_dept INTEGER NOT NULL DEFAULT 0,
current_package_code TEXT,
package_status INTEGER NOT NULL DEFAULT 0,
inbound_batch_no TEXT,
outbound_batch_no TEXT,
tenant_id TEXT NOT NULL DEFAULT ''
)`,
`CREATE TABLE t_sim_card_bind_pkg (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_no TEXT,
batch_no TEXT,
iccid TEXT,
msisdn TEXT,
dept_id TEXT,
package_type INTEGER NOT NULL DEFAULT 0,
package_code TEXT,
previous_package_code TEXT,
business_type INTEGER NOT NULL DEFAULT 0,
effective_status INTEGER NOT NULL DEFAULT 0,
handle_time DATETIME,
effective_time DATETIME,
expected_expire_time DATETIME,
actual_expire_time DATETIME,
duration_months INTEGER NOT NULL DEFAULT 0,
gift_duration_months INTEGER NOT NULL DEFAULT 0,
order_code TEXT,
remark TEXT,
tenant_id TEXT NOT NULL DEFAULT '',
created_dept INTEGER NOT NULL DEFAULT 0,
created_by INTEGER NOT NULL DEFAULT 0,
created_at DATETIME
)`,
`CREATE TABLE t_sim_bind_package_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_no TEXT,
batch_no TEXT,
iccid TEXT,
msisdn TEXT,
dept_id TEXT,
package_type INTEGER,
package_code TEXT,
previous_package_code TEXT,
business_type INTEGER,
effective_status INTEGER,
handle_time DATETIME,
effective_time DATETIME,
expected_expire_time DATETIME,
actual_expire_time DATETIME,
duration_months INTEGER,
gift_duration_months INTEGER,
order_code TEXT,
remark TEXT,
tenant_id TEXT,
created_dept INTEGER,
created_by INTEGER,
updated_by INTEGER
)`,
`CREATE TABLE t_sim_card_stock (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_no TEXT,
iccid TEXT,
msisdn TEXT,
imsi TEXT,
imei TEXT,
chan_key TEXT,
tenant_id TEXT NOT NULL DEFAULT '',
created_dept INTEGER NOT NULL DEFAULT 0,
created_by INTEGER NOT NULL DEFAULT 0,
updated_by INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE t_order (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_sn TEXT,
customer_agent_id INTEGER NOT NULL DEFAULT 0,
pay_type INTEGER NOT NULL DEFAULT 0,
pay_status INTEGER NOT NULL DEFAULT 0,
pay_order_sn TEXT,
total_amount REAL NOT NULL DEFAULT 0,
paid_amount REAL NOT NULL DEFAULT 0,
balance REAL NOT NULL DEFAULT 0,
unrefund_amount REAL NOT NULL DEFAULT 0,
tenant_id TEXT NOT NULL DEFAULT '',
created_dept INTEGER NOT NULL DEFAULT 0,
return_status INTEGER NOT NULL DEFAULT 0,
updated_by INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE t_order_item (
item_id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER,
order_sn TEXT,
price REAL NOT NULL DEFAULT 0,
quantity INTEGER NOT NULL DEFAULT 0,
service_begin_time DATETIME,
service_end_time DATETIME,
tenant_id TEXT NOT NULL DEFAULT '',
extra_sku_data TEXT,
updated_by INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE t_agent_balance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dept_id INTEGER NOT NULL DEFAULT 0,
balance REAL NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 0,
tenant_id TEXT NOT NULL DEFAULT ''
)`,
`CREATE TABLE t_balance_detail (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dept_id INTEGER NOT NULL DEFAULT 0,
serial_no TEXT NOT NULL,
transaction_time DATETIME,
income_expense_type INTEGER NOT NULL DEFAULT 0,
transaction_type INTEGER NOT NULL DEFAULT 0,
transaction_channel INTEGER NOT NULL DEFAULT 0,
transaction_status INTEGER NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
before_balance REAL NOT NULL DEFAULT 0,
balance_after REAL NOT NULL DEFAULT 0,
order_sn TEXT,
remark TEXT,
tenant_id TEXT NOT NULL DEFAULT '',
created_dept INTEGER NOT NULL DEFAULT 0,
created_by INTEGER NOT NULL DEFAULT 0,
created_at DATETIME
)`,
}
for _, statement := range statements {
if _, err = db.Exec(ctx, statement); err != nil {
t.Fatalf("create test schema: %v", err)
}
}
t.Cleanup(func() { _ = db.Close(ctx) })
return &Repository{db: db, lockEnabled: false}
}
func seedProcessingBatch(t *testing.T, ctx context.Context, db gdb.DB, batchNo, sourceBatchNo string) {
t.Helper()
info := gjson.New(map[string]any{
"sourceBatchNo": sourceBatchNo,
})
if _, err := db.Model(dao.BatchMain.Table()).Ctx(ctx).Data(do.BatchMain{
BatchNo: batchNo,
BatchType: consts.BatchTypeSimCardUnsub,
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
RunResult: batchdomain.RunResultRunning,
DeptId: testDeptID,
SourceBatchNo: sourceBatchNo,
CreatedBy: 1,
CreatedDept: 1,
TenantId: testTenantID,
BatchInfo: info,
}).Insert(); err != nil {
t.Fatalf("insert batch main: %v", err)
}
}
func seedRunningDetail(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, rowNo int, iccid, msisdn, imsi, sourceBatchNo, reason string) int64 {
t.Helper()
snap := gjson.New(map[string]any{
"ICCID": iccid,
"MSISDN": msisdn,
"IMSI": imsi,
"authorized": true,
"sourceBatchNo": sourceBatchNo,
})
id, err := db.Model(dao.BatchDetail.Table()).Ctx(ctx).Data(do.BatchDetail{
BatchNo: batchNo,
BatchType: consts.BatchTypeSimCardUnsub,
DetailStatus: consts.BatchDetailStatusRunning,
RowNo: rowNo,
ItemId: iccid,
Snapshot: snap,
ErrorReason: reason,
}).InsertAndGetId()
if err != nil {
t.Fatalf("insert batch detail: %v", err)
}
return id
}
func seedCard(t *testing.T, ctx context.Context, db gdb.DB, iccid, msisdn, imsi, packageCode string, deptID int64) {
t.Helper()
_, err := db.Model(cardInfoTable).Ctx(ctx).Data(do.TSimCardInfo{
Iccid: iccid,
Msisdn: msisdn,
Imsi: imsi,
DeptId: deptID,
CreatedDept: deptID,
CurrentPackageCode: packageCode,
PackageStatus: pkgStatusEffective,
InboundBatchNo: testInbound,
OutboundBatchNo: testSource,
TenantId: testTenantID,
}).Insert()
if err != nil {
t.Fatalf("insert sim card: %v", err)
}
}
func seedActiveBind(t *testing.T, ctx context.Context, db gdb.DB, iccid, packageCode, orderCode, sourceBatchNo string) {
t.Helper()
_, err := db.Model(cardBindPkgTable).Ctx(ctx).Data(map[string]any{
"subscription_no": "SUB-" + iccid,
"batch_no": sourceBatchNo,
"iccid": iccid,
"msisdn": "13" + iccid[len(iccid)-8:],
"dept_id": "100",
"package_type": 1,
"package_code": packageCode,
"business_type": 0,
"effective_status": bindStatusEffective,
"order_code": orderCode,
"remark": "",
"tenant_id": testTenantID,
"created_dept": 1,
"created_by": 1,
}).Insert()
if err != nil {
t.Fatalf("insert bind: %v", err)
}
}
func seedStock(t *testing.T, ctx context.Context, db gdb.DB, iccid string) {
t.Helper()
_, err := db.Model(stockTable).Ctx(ctx).Data(map[string]any{
"iccid": iccid,
"tenant_id": testTenantID,
}).Insert()
if err != nil {
t.Fatalf("insert stock: %v", err)
}
}
func seedOrder(t *testing.T, ctx context.Context, db gdb.DB, orderCode string) {
t.Helper()
_, err := db.Model(orderTable).Ctx(ctx).Data(map[string]any{
"order_sn": orderCode,
"return_status": 0,
"tenant_id": testTenantID,
}).Insert()
if err != nil {
t.Fatalf("insert order: %v", err)
}
}
func seedOrderItem(t *testing.T, ctx context.Context, db gdb.DB, orderCode, extra string) {
t.Helper()
_, err := db.Model(orderItemTable).Ctx(ctx).Data(map[string]any{
"order_sn": orderCode,
"tenant_id": testTenantID,
"extra_sku_data": extra,
}).Insert()
if err != nil {
t.Fatalf("insert order item: %v", err)
}
}
func assertTableCount(t *testing.T, ctx context.Context, db gdb.DB, table string, expected int, where string, args ...any) {
t.Helper()
model := db.Model(table).Ctx(ctx).Where(where, args...)
count, err := model.Count()
if err != nil {
t.Fatalf("count %s: %v", table, err)
}
if count != expected {
t.Fatalf("%s count = %d, want %d", table, count, expected)
}
}
func assertBatchMainStatus(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, wantAsync, wantRun int) {
t.Helper()
var row struct {
Async int `orm:"batch_async_status"`
Run int `orm:"run_result"`
}
if err := db.Model(dao.BatchMain.Table()).Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Fields("batch_async_status", "run_result").
Scan(&row); err != nil {
t.Fatalf("query batch main: %v", err)
}
if row.Async != wantAsync || row.Run != wantRun {
t.Fatalf("batch %s status = (async=%d, run=%d), want (async=%d, run=%d)", batchNo, row.Async, row.Run, wantAsync, wantRun)
}
}
func assertBatchDetailStatus(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, want int) {
t.Helper()
var row struct {
Status int `orm:"detail_status"`
}
if err := db.Model(dao.BatchDetail.Table()).Ctx(ctx).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Fields("detail_status").
Scan(&row); err != nil {
t.Fatalf("query batch detail: %v", err)
}
if row.Status != want {
t.Fatalf("batch %s detail status = %d, want %d", batchNo, row.Status, want)
}
}
func TestLoadReadsTaskAndDetails(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "UNS-1"
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
seedRunningDetail(t, ctx, repo.db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", testSource, "")
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if task.Batch.BatchNo != batchNo {
t.Fatalf("batch no = %q, want %q", task.Batch.BatchNo, batchNo)
}
if task.Batch.SourceBatchNo != testSource {
t.Fatalf("source batch = %q, want %q", task.Batch.SourceBatchNo, testSource)
}
if len(task.Details) != 2 {
t.Fatalf("details = %d, want 2", len(task.Details))
}
if task.Details[0].ICCID != "898600000000001" {
t.Fatalf("first detail ICCID = %q", task.Details[0].ICCID)
}
}
func TestLoadTreatsSelfBatchNoAsEmptySourceBatch(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "UNS-1"
seedProcessingBatch(t, ctx, repo.db, batchNo, batchNo)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", "", "")
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if task.Batch.SourceBatchNo != "" {
t.Fatalf("source batch = %q, want empty when equals batchNo", task.Batch.SourceBatchNo)
}
}
func TestSaveValidationFailureMarksAllDetailsAndMainFailed(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "UNS-1"
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "snapshot has reason")
seedRunningDetail(t, ctx, repo.db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", testSource, "")
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.SaveValidationFailure(ctx, task); err != nil {
t.Fatalf("SaveValidationFailure() error = %v", err)
}
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed)
assertBatchDetailStatus(t, ctx, repo.db, batchNo, consts.BatchDetailStatusFailed)
assertTableCount(t, ctx, repo.db, cardInfoTable, 0, "iccid IN (?, ?)", "898600000000001", "898600000000002")
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 0, "iccid IN (?, ?)", "898600000000001", "898600000000002")
assertTableCount(t, ctx, repo.db, stockTable, 0, "1=1")
}
func TestCommitBusinessHappyPath(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "UNS-1"
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
seedRunningDetail(t, ctx, repo.db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", testSource, "")
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, testDeptID)
seedCard(t, ctx, repo.db, "898600000000002", "13800000002", "460001000000002", testPackage, testDeptID)
seedActiveBind(t, ctx, repo.db, "898600000000001", testPackage, "ORD-1", testSource)
seedActiveBind(t, ctx, repo.db, "898600000000002", testPackage, "ORD-1", testSource)
seedOrder(t, ctx, repo.db, "ORD-1")
seedOrderItem(t, ctx, repo.db, "ORD-1", `{}`)
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.CommitBusiness(ctx, task); err != nil {
t.Fatalf("CommitBusiness() error = %v", err)
}
assertTableCount(t, ctx, repo.db, cardInfoTable, 0, "iccid IN (?, ?)", "898600000000001", "898600000000002")
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 0, "iccid IN (?, ?)", "898600000000001", "898600000000002")
assertTableCount(t, ctx, repo.db, bindPackageLogsTable, 2, "iccid IN (?, ?)", "898600000000001", "898600000000002")
assertTableCount(t, ctx, repo.db, stockTable, 2, "iccid IN (?, ?)", "898600000000001", "898600000000002")
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess)
assertBatchDetailStatus(t, ctx, repo.db, batchNo, consts.BatchDetailStatusSuccess)
assertOrderReturnStatus(t, ctx, repo.db, "ORD-1", orderReturnStatusSuccess)
assertOrderItemExtra(t, ctx, repo.db, "ORD-1", batchNo, 2)
}
func TestCommitBusinessRefundsBalanceByPaidPeriodOnly(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "UNS-REFUND-1"
orderCode := "ORD-REFUND-1"
effectiveAt := gtime.New("2026-01-01 00:00:00")
cutoffAt := gtime.New("2026-07-01 00:00:00")
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
if _, err := repo.db.Model(dao.BatchMain.Table()).Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Data(map[string]any{"start_time": cutoffAt}).Update(); err != nil {
t.Fatalf("update batch start time: %v", err)
}
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, testDeptID)
seedActiveBind(t, ctx, repo.db, "898600000000001", testPackage, orderCode, testSource)
if _, err := repo.db.Model(cardBindPkgTable).Ctx(ctx).
Where("order_code", orderCode).
Data(map[string]any{
"effective_time": effectiveAt,
"expected_expire_time": gtime.New("2027-04-01 23:59:59"),
"duration_months": 12,
"gift_duration_months": 3,
}).Update(); err != nil {
t.Fatalf("update bind period: %v", err)
}
if _, err := repo.db.Model(orderTable).Ctx(ctx).Data(map[string]any{
"order_sn": orderCode,
"customer_agent_id": testDeptID,
"pay_type": consts.PayTypeBalance,
"pay_status": consts.PayStatusPaid,
"pay_order_sn": "RENPAY-REFUND-1",
"total_amount": 120,
"paid_amount": 120,
"balance": 120,
"unrefund_amount": 120,
"tenant_id": testTenantID,
"created_dept": testDeptID,
}).Insert(); err != nil {
t.Fatalf("insert refund order: %v", err)
}
if _, err := repo.db.Model(orderItemTable).Ctx(ctx).Data(map[string]any{
"order_sn": orderCode,
"price": 120,
"quantity": 1,
"service_begin_time": effectiveAt,
"service_end_time": gtime.New("2027-04-01 23:59:59"),
"tenant_id": testTenantID,
}).Insert(); err != nil {
t.Fatalf("insert refund order item: %v", err)
}
if _, err := repo.db.Model(agentBalanceTable).Ctx(ctx).Data(map[string]any{
"dept_id": testDeptID,
"balance": 10,
"status": 0,
"tenant_id": testTenantID,
}).Insert(); err != nil {
t.Fatalf("insert refund account: %v", err)
}
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.CommitBusiness(ctx, task); err != nil {
t.Fatalf("CommitBusiness() error = %v", err)
}
var account struct {
Balance float64 `orm:"balance"`
}
if err = repo.db.Model(agentBalanceTable).Ctx(ctx).Where("dept_id", testDeptID).Scan(&account); err != nil {
t.Fatalf("query refund account: %v", err)
}
if want := 70.49; math.Abs(account.Balance-want) > 0.01 {
t.Fatalf("refund balance = %.2f, want %.2f", account.Balance, want)
}
assertTableCount(t, ctx, repo.db, balanceDetailTable, 1, "serial_no = ?", unsubscribeRefundSerialNo(batchNo, orderCode))
assertOrderReturnStatus(t, ctx, repo.db, orderCode, orderReturnStatusSuccess)
}
func TestCommitBusinessFailsOnNoActiveBind(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "UNS-1"
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, testDeptID)
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.CommitBusiness(ctx, task); err != nil {
t.Fatalf("CommitBusiness() returned error = %v, want nil (validation failure handled internally)", err)
}
assertTableCount(t, ctx, repo.db, cardInfoTable, 1, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 0, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, bindPackageLogsTable, 0, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, stockTable, 0, "iccid = ?", "898600000000001")
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed)
assertBatchDetailStatus(t, ctx, repo.db, batchNo, consts.BatchDetailStatusFailed)
}
func TestCommitBusinessFailsOnPackageMismatch(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "UNS-1"
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, testDeptID)
seedActiveBind(t, ctx, repo.db, "898600000000001", "PKG-OTHER", "ORD-1", testSource)
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.CommitBusiness(ctx, task); err != nil {
t.Fatalf("CommitBusiness() returned error = %v, want nil", err)
}
assertTableCount(t, ctx, repo.db, cardInfoTable, 1, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 1, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, bindPackageLogsTable, 0, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, stockTable, 0, "iccid = ?", "898600000000001")
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed)
}
func TestCommitBusinessFailsOnDeptMismatch(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "UNS-1"
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, 999)
seedActiveBind(t, ctx, repo.db, "898600000000001", testPackage, "ORD-1", testSource)
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.CommitBusiness(ctx, task); err != nil {
t.Fatalf("CommitBusiness() returned error = %v, want nil", err)
}
assertTableCount(t, ctx, repo.db, cardInfoTable, 1, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 1, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, bindPackageLogsTable, 0, "iccid = ?", "898600000000001")
}
func TestCommitBusinessFailsOnSourceBatchMismatch(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "UNS-1"
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, testDeptID)
seedActiveBind(t, ctx, repo.db, "898600000000001", testPackage, "ORD-1", "OUT-OTHER")
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.CommitBusiness(ctx, task); err != nil {
t.Fatalf("CommitBusiness() returned error = %v, want nil", err)
}
assertTableCount(t, ctx, repo.db, cardInfoTable, 1, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 1, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, stockTable, 0, "iccid = ?", "898600000000001")
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed)
}
func TestCommitBusinessFailsOnExistingStock(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "UNS-1"
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, testDeptID)
seedActiveBind(t, ctx, repo.db, "898600000000001", testPackage, "ORD-1", testSource)
seedStock(t, ctx, repo.db, "898600000000001")
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.CommitBusiness(ctx, task); err != nil {
t.Fatalf("CommitBusiness() returned error = %v, want nil", err)
}
assertTableCount(t, ctx, repo.db, cardInfoTable, 1, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 1, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, stockTable, 1, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, bindPackageLogsTable, 0, "iccid = ?", "898600000000001")
}
func TestCommitBusinessFailsOnMultiplePackagesInBatch(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "UNS-1"
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
seedRunningDetail(t, ctx, repo.db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", testSource, "")
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", "PKG-A", testDeptID)
seedCard(t, ctx, repo.db, "898600000000002", "13800000002", "460001000000002", "PKG-B", testDeptID)
seedActiveBind(t, ctx, repo.db, "898600000000001", "PKG-A", "ORD-1", testSource)
seedActiveBind(t, ctx, repo.db, "898600000000002", "PKG-B", "ORD-2", testSource)
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.CommitBusiness(ctx, task); err != nil {
t.Fatalf("CommitBusiness() returned error = %v, want nil", err)
}
assertTableCount(t, ctx, repo.db, cardInfoTable, 2, "iccid IN (?, ?)", "898600000000001", "898600000000002")
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 2, "iccid IN (?, ?)", "898600000000001", "898600000000002")
assertTableCount(t, ctx, repo.db, bindPackageLogsTable, 0, "1=1")
assertTableCount(t, ctx, repo.db, stockTable, 0, "1=1")
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed)
}
func TestCommitBusinessRollsBackOnOrderUpdateFailure(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "UNS-1"
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, testDeptID)
seedActiveBind(t, ctx, repo.db, "898600000000001", testPackage, "ORD-1", testSource)
seedOrder(t, ctx, repo.db, "ORD-1")
seedOrderItem(t, ctx, repo.db, "ORD-1", `{}`)
if _, err := repo.db.Exec(ctx, "DROP TABLE t_order"); err != nil {
t.Fatalf("drop order table: %v", err)
}
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.CommitBusiness(ctx, task); err == nil {
t.Fatal("expected CommitBusiness to fail when order table is missing")
}
assertTableCount(t, ctx, repo.db, cardInfoTable, 1, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 1, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, bindPackageLogsTable, 0, "iccid = ?", "898600000000001")
assertTableCount(t, ctx, repo.db, stockTable, 0, "iccid = ?", "898600000000001")
assertBatchMainStatus(t, ctx, repo.db, batchNo, batchdomain.AsyncStatusProcessing, batchdomain.RunResultRunning)
}
func TestSaveResultFileFailureOnlyMarksBatchInfo(t *testing.T) {
ctx := context.Background()
repo := newTestRepository(t)
batchNo := "UNS-1"
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
task, err := repo.Load(ctx, batchNo)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err = repo.updateResultFileStatus(ctx, task, "failed", 0, 0, "disk full"); err != nil {
t.Fatalf("updateResultFileStatus() error = %v", err)
}
var row struct {
Info string `orm:"batch_info"`
}
if err = repo.db.Model(dao.BatchMain.Table()).Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, batchNo).
Fields("batch_info").
Scan(&row); err != nil {
t.Fatalf("query batch main: %v", err)
}
info := gjson.New([]byte(row.Info))
if info.Get("resultFile.status").String() != "failed" {
t.Fatalf("resultFile.status = %s, want failed", info.Get("resultFile.status").String())
}
if info.Get("resultFile.error").String() != "disk full" {
t.Fatalf("resultFile.error = %s, want disk full", info.Get("resultFile.error").String())
}
assertBatchMainStatus(t, ctx, repo.db, batchNo, batchdomain.AsyncStatusProcessing, batchdomain.RunResultRunning)
}
func assertOrderReturnStatus(t *testing.T, ctx context.Context, db gdb.DB, orderCode string, want int) {
t.Helper()
var row struct {
ReturnStatus int `orm:"return_status"`
}
if err := db.Model(orderTable).Ctx(ctx).
Where("order_sn", orderCode).
Fields("return_status").
Scan(&row); err != nil {
t.Fatalf("query order return status: %v", err)
}
if row.ReturnStatus != want {
t.Fatalf("order %s return_status = %d, want %d", orderCode, row.ReturnStatus, want)
}
}
func assertOrderItemExtra(t *testing.T, ctx context.Context, db gdb.DB, orderCode, batchNo string, wantQuantity int) {
t.Helper()
var row struct {
Extra string `orm:"extra_sku_data"`
}
if err := db.Model(orderItemTable).Ctx(ctx).
Where("order_sn", orderCode).
Fields("extra_sku_data").
Scan(&row); err != nil {
t.Fatalf("query order item extra: %v", err)
}
info := gjson.New([]byte(row.Extra))
if info.Get("lastUnsubscribeBatchNo").String() != batchNo {
t.Fatalf("lastUnsubscribeBatchNo = %s, want %s", info.Get("lastUnsubscribeBatchNo").String(), batchNo)
}
if int(info.Get("unsubscribedQuantity").Int()) != wantQuantity {
t.Fatalf("unsubscribedQuantity = %d, want %d", info.Get("unsubscribedQuantity").Int(), wantQuantity)
}
}
var _ unsub.Repository = (*Repository)(nil)