chore: 初始开源发布

iotlcm-worker-svr:异步执行中枢(RabbitMQ 消费者 + 全量同步编排)。

- Go module:iotlcm-worker-svr → git.5iot.com/iotlcm-public/iotlcm-worker-svr(214 文件)
- 凭据与内部标识脱敏:MySQL/Redis 凭据、私网与生产 IP、内网域名、
  库名 xiujie_admin → iotlcm、哨兵名占位
- 159 处真实手机号 → 合法 11 位编造号(保留号段与后 4 位,维持互异,
  不破坏「校验 MSISDN 格式」等测试断言)
- 移除内部资产:CI 触发器、Jenkins 触发测试文档、预演门禁与镜像
  digest 记录
- Dockerfile 改用公共基础镜像(golang:1.25-alpine → alpine:3.23),
  移除写死的 git 凭据
- README 重写(架构/全量同步模型/配置/已知失败),补 Apache-2.0
  LICENSE,LF 行尾 + .gitattributes

已知:3 个测试包在源仓同一提交上同样失败(client/upstream、
service/allocate、service/inbound),属既有缺陷,本仓未引入亦未修复。
This commit is contained in:
MiFaZhan
2026-10-22 16:37:56 +08:00
commit 2bec257289
361 changed files with 71524 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)
}
}
+200
View File
@@ -0,0 +1,200 @@
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/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")
}
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: 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,204 @@
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/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 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 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
+514
View File
@@ -0,0 +1,514 @@
package data
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"
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) {
if batchNo == "" {
return nil, gerror.New("data service 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.BatchTypeDataCut, consts.BatchTypeDataStart, consts.BatchTypeDataStop}).
Where(dao.BatchMain.Columns().BatchAsyncStatus, consts.BatchAsyncStatusProcessing).
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
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 data service details")
}
if len(batchDetails) == 0 {
return nil, gerror.New("data service batch has no running 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,
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.ChanKey = strings.TrimSpace(batchDetail.Snapshot.Get("chanKey").String())
detail.TrafficCutoffCrossMonthHold = batchDetail.Snapshot.Get("trafficCutoffCrossMonthHold").Int()
}
task.Details = append(task.Details, detail)
}
if len(task.Details) == 0 {
return nil, gerror.New("data service batch has no readable running details")
}
if err = r.loadCards(ctx, task); err != nil {
return nil, err
}
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
}
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 = "SIM card record is missing"
} 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 := 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 = strconv.Itoa(failCount) + " SIM cards failed"
}
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 {
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")
}
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,
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")
}
return nil
})
if err != nil {
batchresult.RemoveLocalFile(artifact)
return err
}
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,
ICCID: detail.ICCID,
MSISDN: detail.MSISDN,
IMSI: detail.IMSI,
Operation: operationLabel(task.Operation),
ApnName: task.ApnName,
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 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
info["resultFileOssId"] = ossID
info["resultDownloadId"] = downloadID
return gjson.New(info)
}
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
}
}
+134
View File
@@ -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")
}
})
}
}
+133
View File
@@ -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
}
+281
View File
@@ -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)
}
}
}
+865
View File
@@ -0,0 +1,865 @@
package inbound
import (
"context"
"os"
"path/filepath"
"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"
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 {
return r.database().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
batchInfo := buildInboundBatchInfo(task)
task.Batch.BatchInfo = batchInfo
details := buildBatchDetails(task, consts.BatchDetailStatusFailed)
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: len(task.Rows),
RunResult: consts.BatchRunResultFailed,
FailReason: task.FailureReason,
BatchInfo: batchInfo,
EndTime: gtime.Now(),
UpdatedBy: task.Batch.CreatedBy,
})
})
}
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: row.ICCID,
ErrorReason: 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: len(task.Rows) - successRows(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
}
@@ -0,0 +1,227 @@
package inbound
import (
"os"
"path/filepath"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
serviceinbound "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/inbound"
"github.com/gogf/gf/v2/encoding/gjson"
)
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")
}
}
+432
View File
@@ -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 = "SIM card record is missing"
} 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 = "SIM card local status changed"
}
}
}
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 = strconv.Itoa(failCount) + " SIM cards failed"
}
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
}
}
+610
View File
@@ -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 = "restart validation failed; upstream request was skipped"
}
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 = "SIM card record is missing"
} 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 = "SIM card local status changed"
}
}
}
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 = strconv.Itoa(failCount) + " SIM cards failed"
}
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)
}
}
+39
View File
@@ -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("预处理批次缺少关键快照")
)
+54
View File
@@ -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)
}
+195
View File
@@ -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
}
+392
View File
@@ -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 = "batch validation failed; traffic cutoff update skipped"
)
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 = "traffic cutoff batch has no valid details"
}
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 does not exist in SIM card records"
} else if !detail.Authorized {
detail.Reason = "ICCID is outside the current operator authorization scope"
} else if detail.InputMSISDN != "" && detail.InputMSISDN != detail.Card.MSISDN {
detail.Reason = "MSISDN does not match the SIM card record"
} else if detail.InputIMSI != "" && detail.InputIMSI != detail.Card.IMSI {
detail.Reason = "IMSI does not match the SIM card record"
} else if detail.Card.BossCardStatus == trafficcutoff.BossCardStatusCanceled {
detail.Reason = "ICCID is already canceled"
}
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 = "same batch contains different card statuses"
}
}
hasFailure = true
}
if !hasFailure {
task.ValidationOK = true
return true
}
for _, detail := range task.Details {
if detail != nil && detail.Reason == "" {
detail.Reason = "same batch has invalid traffic cutoff rows; update skipped"
}
}
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)
}
}
}
+407
View File
@@ -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())
}
+37
View File
@@ -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)