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:
@@ -0,0 +1,35 @@
|
||||
package batch
|
||||
|
||||
import (
|
||||
"context"
|
||||
batchdomain "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExecutionPreservesOriginMetadata(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := newTestRepository(t, ctx)
|
||||
insertBatch(t, ctx, r.db, "META", batchdomain.Type103)
|
||||
if _, err := r.db.Model(dao.BatchMain.Table()).Ctx(ctx).Where("batch_no", "META").Data(do.BatchMain{SourceChannel: "mini_program", ExecutionMode: 1}).Update(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claimed, err := r.MarkProcessing(ctx, "META", batchdomain.Type103); err != nil || !claimed {
|
||||
t.Fatal(claimed, err)
|
||||
}
|
||||
if marked, err := r.MarkFailed(ctx, "META", batchdomain.Type103, "test failure"); err != nil || !marked {
|
||||
t.Fatal(marked, err)
|
||||
}
|
||||
main, err := r.FindMain(ctx, "META")
|
||||
if err != nil || main.SourceChannel != "mini_program" || main.ExecutionMode != 1 {
|
||||
t.Fatalf("metadata changed: %+v err=%v", main, err)
|
||||
}
|
||||
if marked, err := r.MarkFailed(ctx, "META", batchdomain.Type103, "duplicate"); err != nil || marked {
|
||||
t.Fatal(marked, err)
|
||||
}
|
||||
main, err = r.FindMain(ctx, "META")
|
||||
if err != nil || main.SourceChannel != "mini_program" || main.ExecutionMode != 1 {
|
||||
t.Fatal("duplicate mutated origin", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
dataService "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/data"
|
||||
)
|
||||
|
||||
func TestPreferredDataServiceChanKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appBatch bool
|
||||
snapshot string
|
||||
card string
|
||||
want string
|
||||
}{
|
||||
{name: "app resolved snapshot wins", appBatch: true, snapshot: " legacy-71 ", card: "stale-1", want: "legacy-71"},
|
||||
{name: "console keeps card channel", snapshot: "legacy-71", card: " card-112 ", want: "card-112"},
|
||||
{name: "card fallback", card: " card-112 ", want: "card-112"},
|
||||
{name: "empty", want: ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := preferredDataServiceChanKey(test.appBatch, test.snapshot, test.card); got != test.want {
|
||||
t.Fatalf("preferredDataServiceChanKey() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataAppBatchUsesSnapshotChannel(t *testing.T) {
|
||||
if !dataAppBatchUsesSnapshotChannel(gjson.New(map[string]any{"source": "APP"})) {
|
||||
t.Fatal("app batch should use the resolved snapshot channel")
|
||||
}
|
||||
if dataAppBatchUsesSnapshotChannel(gjson.New(map[string]any{"source": "console"})) {
|
||||
t.Fatal("console batch must continue using the current card channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResultDoesNotClearTrafficCutoffFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(:memory:)"})
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlite database: %v", err)
|
||||
}
|
||||
statements := []string{
|
||||
"CREATE TABLE t_sim_card_info (id INTEGER PRIMARY KEY, tenant_id TEXT NOT NULL, data_service_status INTEGER NOT NULL DEFAULT 1, status_sync_time DATETIME, traffic_cutoff_status INTEGER NOT NULL DEFAULT 0, traffic_cutoff_threshold REAL NOT NULL DEFAULT 0, traffic_cutoff_cross_month_hold INTEGER NOT NULL DEFAULT 0, traffic_cutoff_triggered_at DATETIME, updated_by INTEGER NOT NULL DEFAULT 0, updated_at DATETIME)",
|
||||
"CREATE TABLE batch_detail (id INTEGER PRIMARY KEY, batch_no TEXT NOT NULL, detail_status INTEGER NOT NULL, error_code TEXT, error_reason TEXT, snapshot TEXT, updated_by INTEGER NOT NULL DEFAULT 0, updated_at DATETIME)",
|
||||
"INSERT INTO t_sim_card_info (id, tenant_id, data_service_status, traffic_cutoff_status, traffic_cutoff_threshold, traffic_cutoff_cross_month_hold, updated_by) VALUES (1, '000000', 1, 1, 512, 1, 7)",
|
||||
"INSERT INTO batch_detail (id, batch_no, detail_status) VALUES (9, 'DST-TEST', 1)",
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err = db.Exec(ctx, statement); err != nil {
|
||||
t.Fatalf("execute setup SQL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
repo := &Repository{db: db}
|
||||
task := &dataService.Task{
|
||||
Batch: dataService.Batch{
|
||||
BatchNo: "DST-TEST",
|
||||
BatchType: consts.BatchTypeDataStop,
|
||||
TenantID: "000000",
|
||||
CreatedBy: 13,
|
||||
},
|
||||
Operation: dataService.Operation{
|
||||
BatchType: consts.BatchTypeDataStop,
|
||||
TargetStatus: consts.SimDataServiceStatusDisabled,
|
||||
},
|
||||
}
|
||||
detail := &dataService.Detail{
|
||||
ID: 9,
|
||||
Card: &dataService.Card{ID: 1, DataServiceStatus: consts.SimDataServiceStatusEnabled},
|
||||
}
|
||||
if _, err = repo.SaveResult(ctx, task, detail, dataService.Outcome{Success: true}); err != nil {
|
||||
t.Fatalf("SaveResult() error = %v", err)
|
||||
}
|
||||
|
||||
row, err := db.GetOne(ctx, "SELECT data_service_status, traffic_cutoff_status, traffic_cutoff_threshold, traffic_cutoff_cross_month_hold FROM t_sim_card_info WHERE id = 1")
|
||||
if err != nil {
|
||||
t.Fatalf("query card result: %v", err)
|
||||
}
|
||||
if got := row["data_service_status"].Int(); got != consts.SimDataServiceStatusDisabled {
|
||||
t.Fatalf("data_service_status = %d", got)
|
||||
}
|
||||
if got := row["traffic_cutoff_status"].Int(); got != 1 {
|
||||
t.Fatalf("traffic_cutoff_status = %d, want 1", got)
|
||||
}
|
||||
if got := row["traffic_cutoff_threshold"].Float64(); got != 512 {
|
||||
t.Fatalf("traffic_cutoff_threshold = %v, want 512", got)
|
||||
}
|
||||
if got := row["traffic_cutoff_cross_month_hold"].Int(); got != 1 {
|
||||
t.Fatalf("traffic_cutoff_cross_month_hold = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataServiceUpdateDataTracksOnlyCrossMonthCutoff(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
batchType int
|
||||
hold int
|
||||
wantNil bool
|
||||
}{
|
||||
{name: "cutoff with hold", batchType: consts.BatchTypeDataCut, hold: 1, wantNil: false},
|
||||
{name: "cutoff without hold", batchType: consts.BatchTypeDataCut, hold: 0, wantNil: true},
|
||||
{name: "manual stop", batchType: consts.BatchTypeDataStop, hold: 1, wantNil: true},
|
||||
{name: "data start clears trigger", batchType: consts.BatchTypeDataStart, hold: 1, wantNil: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
task := &dataService.Task{
|
||||
Batch: dataService.Batch{BatchType: tt.batchType, CreatedBy: 7},
|
||||
Operation: dataService.Operation{TargetStatus: consts.SimDataServiceStatusDisabled},
|
||||
}
|
||||
detail := &dataService.Detail{TrafficCutoffCrossMonthHold: tt.hold}
|
||||
update := dataServiceUpdateData(task, detail)
|
||||
if gotNil := update.TrafficCutoffTriggeredAt == nil; gotNil != tt.wantNil {
|
||||
t.Fatalf("TrafficCutoffTriggeredAt nil = %v, want %v", gotNil, tt.wantNil)
|
||||
}
|
||||
if update.DataServiceStatus != task.Operation.TargetStatus {
|
||||
t.Fatalf("DataServiceStatus = %v, want %d", update.DataServiceStatus, task.Operation.TargetStatus)
|
||||
}
|
||||
if update.StatusSyncTime == nil {
|
||||
t.Fatal("StatusSyncTime must be set")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package imei
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
)
|
||||
|
||||
// imeiValuePattern bounds what may be written into t_sim_card_info.imei.
|
||||
//
|
||||
// The column is varchar(32) and the carrier value is digits only (15-17 for a
|
||||
// real IMEI). This pattern is the primary defence for the CASE WHEN statement
|
||||
// below, which is assembled as SQL text and therefore cannot rely on bound
|
||||
// parameters for the value itself.
|
||||
//
|
||||
// The separator is intentionally absent: this service never concatenates
|
||||
// multiple IMEIs (see the handler's multi-value note), so allowing ';' would
|
||||
// widen the column contract for a case that cannot occur.
|
||||
var imeiValuePattern = regexp.MustCompile(`^[0-9]{1,32}$`)
|
||||
|
||||
// maxImeiSyncBatch bounds one ApplyBatch statement. The caller (the ZSet
|
||||
// consumer) claims in batches of claimBatch (default 100), so this is a guard
|
||||
// rather than a working limit: it keeps a single UPDATE statement from growing
|
||||
// without bound if a future caller raises the claim size.
|
||||
const maxImeiSyncBatch = 1000
|
||||
|
||||
// Result is the database projection of one IMEI sync outcome.
|
||||
//
|
||||
// Two update kinds are represented by one struct:
|
||||
//
|
||||
// - Synced: the carrier answered successfully (Code == 0). imei_sync_time is
|
||||
// written because the card was covered by this polling pass.
|
||||
// - ValueChanged: the carrier returned a non-empty IMEI that differs from the
|
||||
// stored value. Only then are imei and imei_update_time written.
|
||||
//
|
||||
// A Synced-but-empty result (carrier answered, no IMEI available) is therefore
|
||||
// a heartbeat-only update: it records coverage without touching the value. That
|
||||
// asymmetry is what makes the whole chain "additive": an upstream outage that
|
||||
// returns empty values can never clear a stored IMEI.
|
||||
type Result struct {
|
||||
CardID int64
|
||||
Synced bool
|
||||
ValueChanged bool
|
||||
Imei string
|
||||
}
|
||||
|
||||
type Repository struct{}
|
||||
|
||||
func NewRepository() *Repository { return &Repository{} }
|
||||
|
||||
// ApplyBatch writes a batch of IMEI sync results in as few statements as
|
||||
// possible.
|
||||
//
|
||||
// Two mutually exclusive groups are emitted, because they update different
|
||||
// column sets and mixing them in one CASE expression would force every card to
|
||||
// write every column:
|
||||
//
|
||||
// 1. heartbeat only (synced, value unchanged or empty) → imei_sync_time
|
||||
// 2. value changed → imei + imei_update_time + imei_sync_time
|
||||
//
|
||||
// A statement is only issued when its group is non-empty, so an all-empty
|
||||
// upstream pass still records coverage (group 1) without writing any value.
|
||||
func (r *Repository) ApplyBatch(ctx context.Context, results []Result) error {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(results) > maxImeiSyncBatch {
|
||||
return fmt.Errorf("imei sync batch too large: %d > %d", len(results), maxImeiSyncBatch)
|
||||
}
|
||||
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
heartbeatIDs := make([]int64, 0, len(results))
|
||||
changedIDs := make([]int64, 0, len(results))
|
||||
var changedCase strings.Builder
|
||||
changedCase.WriteString("CASE " + columns.Id + " ")
|
||||
|
||||
for _, result := range results {
|
||||
if result.CardID <= 0 {
|
||||
return fmt.Errorf("imei result has invalid cardId %d", result.CardID)
|
||||
}
|
||||
if !result.Synced {
|
||||
// Only successfully synchronized cards get a heartbeat; a failed
|
||||
// query says nothing about coverage.
|
||||
continue
|
||||
}
|
||||
if !result.ValueChanged {
|
||||
heartbeatIDs = append(heartbeatIDs, result.CardID)
|
||||
continue
|
||||
}
|
||||
if !imeiValuePattern.MatchString(result.Imei) {
|
||||
// Reject the whole batch rather than write a value that violates
|
||||
// the column contract. The caller logs the message; the card is
|
||||
// retried in a later round.
|
||||
return fmt.Errorf("imei result for card %d has invalid value %q", result.CardID, result.Imei)
|
||||
}
|
||||
fmt.Fprintf(&changedCase, "WHEN %d THEN '%s' ", result.CardID, result.Imei)
|
||||
changedIDs = append(changedIDs, result.CardID)
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
// Value changes first: if the second statement fails, the value and its
|
||||
// change timestamp are already consistent, and the heartbeat is only a
|
||||
// coverage marker that the next pass will re-write anyway.
|
||||
if len(changedIDs) > 0 {
|
||||
changedCase.WriteString("END")
|
||||
if _, err := dao.TSimCardInfo.Ctx(ctx).
|
||||
WhereIn(columns.Id, changedIDs).
|
||||
Data(do.TSimCardInfo{
|
||||
Imei: gdb.Raw(changedCase.String()),
|
||||
ImeiUpdateTime: now,
|
||||
ImeiSyncTime: now,
|
||||
}).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(heartbeatIDs) > 0 {
|
||||
if _, err := dao.TSimCardInfo.Ctx(ctx).
|
||||
WhereIn(columns.Id, heartbeatIDs).
|
||||
Data(do.TSimCardInfo{
|
||||
ImeiSyncTime: now,
|
||||
}).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package imei
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// setupDB builds a minimal card table mirroring the columns ApplyBatch writes.
|
||||
func setupDB(t *testing.T, ctx context.Context) {
|
||||
t.Helper()
|
||||
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = g.DB().Close(ctx) })
|
||||
if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, `CREATE TABLE t_sim_card_info (
|
||||
id INTEGER PRIMARY KEY,
|
||||
imei TEXT,
|
||||
imei_sync_time DATETIME,
|
||||
imei_update_time DATETIME
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, `INSERT INTO t_sim_card_info (id, imei, imei_sync_time, imei_update_time) VALUES
|
||||
(1,'OLD-IMEI-1',NULL,NULL),
|
||||
(2,'OLD-IMEI-2',NULL,NULL),
|
||||
(3,'OLD-IMEI-3',NULL,NULL),
|
||||
(4,'',NULL,NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// readCard returns the stored imei and both timestamps for one card.
|
||||
func readCard(t *testing.T, ctx context.Context, id int64) (imei string, syncTime, updateTime *string) {
|
||||
t.Helper()
|
||||
var row struct {
|
||||
Imei string `orm:"imei"`
|
||||
ImeiSyncTime *string `orm:"imei_sync_time"`
|
||||
ImeiUpdateTime *string `orm:"imei_update_time"`
|
||||
}
|
||||
if err := g.DB().Model("t_sim_card_info").Where("id", id).Scan(&row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row.Imei, row.ImeiSyncTime, row.ImeiUpdateTime
|
||||
}
|
||||
|
||||
// TestApplyBatchValueChangeWritesAllThreeColumns 值变更时三列同时写。
|
||||
func TestApplyBatchValueChangeWritesAllThreeColumns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setupDB(t, ctx)
|
||||
|
||||
repo := NewRepository()
|
||||
if err := repo.ApplyBatch(ctx, []Result{
|
||||
{CardID: 1, Synced: true, ValueChanged: true, Imei: "865466070817587"},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyBatch: %v", err)
|
||||
}
|
||||
|
||||
imei, syncTime, updateTime := readCard(t, ctx, 1)
|
||||
if imei != "865466070817587" {
|
||||
t.Fatalf("imei = %q", imei)
|
||||
}
|
||||
if syncTime == nil || updateTime == nil {
|
||||
t.Fatalf("值变更应同时写两个时间戳: sync=%v update=%v", syncTime, updateTime)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyBatchHeartbeatDoesNotTouchValue 心跳只写 imei_sync_time,
|
||||
// 绝不改动 imei 与 imei_update_time。这是 §5.2 的核心保证。
|
||||
func TestApplyBatchHeartbeatDoesNotTouchValue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setupDB(t, ctx)
|
||||
|
||||
repo := NewRepository()
|
||||
if err := repo.ApplyBatch(ctx, []Result{
|
||||
{CardID: 1, Synced: true, ValueChanged: false},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyBatch: %v", err)
|
||||
}
|
||||
|
||||
imei, syncTime, updateTime := readCard(t, ctx, 1)
|
||||
if imei != "OLD-IMEI-1" {
|
||||
t.Fatalf("心跳不得改动 imei: %q", imei)
|
||||
}
|
||||
if syncTime == nil {
|
||||
t.Fatal("心跳应写 imei_sync_time")
|
||||
}
|
||||
if updateTime != nil {
|
||||
t.Fatalf("心跳不得写 imei_update_time: %v", *updateTime)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyBatchUnsyncedIsSkipped 未同步(查询失败)的卡完全不入库。
|
||||
func TestApplyBatchUnsyncedIsSkipped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setupDB(t, ctx)
|
||||
|
||||
repo := NewRepository()
|
||||
if err := repo.ApplyBatch(ctx, []Result{
|
||||
{CardID: 1, Synced: false},
|
||||
{CardID: 2, Synced: false, ValueChanged: true, Imei: "999999999999999"},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyBatch: %v", err)
|
||||
}
|
||||
|
||||
for _, id := range []int64{1, 2} {
|
||||
imei, syncTime, updateTime := readCard(t, ctx, id)
|
||||
if syncTime != nil || updateTime != nil {
|
||||
t.Fatalf("card %d: 未同步不应写时间戳: sync=%v update=%v", id, syncTime, updateTime)
|
||||
}
|
||||
_ = imei
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyBatchMixedGroups 混合批次:值变更与心跳分组写入,互不干扰。
|
||||
func TestApplyBatchMixedGroups(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setupDB(t, ctx)
|
||||
|
||||
repo := NewRepository()
|
||||
if err := repo.ApplyBatch(ctx, []Result{
|
||||
{CardID: 1, Synced: true, ValueChanged: true, Imei: "865466070817587"}, // 值变更
|
||||
{CardID: 2, Synced: true, ValueChanged: false}, // 心跳
|
||||
{CardID: 3, Synced: false}, // 失败跳过
|
||||
{CardID: 4, Synced: true, ValueChanged: true, Imei: "111222333444555"}, // 从空到有值
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyBatch: %v", err)
|
||||
}
|
||||
|
||||
if imei, syncTime, updateTime := readCard(t, ctx, 1); imei != "865466070817587" || syncTime == nil || updateTime == nil {
|
||||
t.Fatalf("card 1 应值变更: imei=%q sync=%v update=%v", imei, syncTime, updateTime)
|
||||
}
|
||||
if imei, syncTime, updateTime := readCard(t, ctx, 2); imei != "OLD-IMEI-2" || syncTime == nil || updateTime != nil {
|
||||
t.Fatalf("card 2 应仅心跳: imei=%q sync=%v update=%v", imei, syncTime, updateTime)
|
||||
}
|
||||
if imei, syncTime, updateTime := readCard(t, ctx, 3); imei != "OLD-IMEI-3" || syncTime != nil || updateTime != nil {
|
||||
t.Fatalf("card 3 应完全不动: imei=%q sync=%v update=%v", imei, syncTime, updateTime)
|
||||
}
|
||||
if imei, syncTime, updateTime := readCard(t, ctx, 4); imei != "111222333444555" || syncTime == nil || updateTime == nil {
|
||||
t.Fatalf("card 4 应从空到有值: imei=%q sync=%v update=%v", imei, syncTime, updateTime)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyBatchRejectsInvalidImeiValue 非数字值必须被拒绝,
|
||||
// 因为 CASE WHEN 是拼接 SQL,值本身没有绑定参数保护。
|
||||
func TestApplyBatchRejectsInvalidImeiValue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setupDB(t, ctx)
|
||||
|
||||
repo := NewRepository()
|
||||
malicious := []string{
|
||||
"865466070817587'", // 引号逃逸
|
||||
"865466070817587'; DROP TABLE x; --", // 注入尝试
|
||||
"865466070817587 OR 1=1", // 逻辑注入
|
||||
"abc", // 非数字
|
||||
"865466070817587;999999999999999", // 分号拼接(本链路不支持多值)
|
||||
strings.Repeat("9", 33), // 超长
|
||||
}
|
||||
for _, value := range malicious {
|
||||
err := repo.ApplyBatch(ctx, []Result{
|
||||
{CardID: 1, Synced: true, ValueChanged: true, Imei: value},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("非法值 %q 应被拒绝", value)
|
||||
}
|
||||
}
|
||||
|
||||
// 拒绝后数据必须保持原样
|
||||
if imei, _, _ := readCard(t, ctx, 1); imei != "OLD-IMEI-1" {
|
||||
t.Fatalf("被拒绝的批次不得改动数据: %q", imei)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyBatchAcceptsBoundaryLengths 15/17 位正常 IMEI 与列宽边界值应通过。
|
||||
func TestApplyBatchAcceptsBoundaryLengths(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setupDB(t, ctx)
|
||||
|
||||
repo := NewRepository()
|
||||
values := []string{
|
||||
"865466070817587", // 15 位
|
||||
"86546607081758712", // 17 位
|
||||
strings.Repeat("9", 32), // varchar(32) 上限
|
||||
}
|
||||
for i, value := range values {
|
||||
cardID := int64(i + 1)
|
||||
if err := repo.ApplyBatch(ctx, []Result{
|
||||
{CardID: cardID, Synced: true, ValueChanged: true, Imei: value},
|
||||
}); err != nil {
|
||||
t.Fatalf("合法值 %q 应被接受: %v", value, err)
|
||||
}
|
||||
if imei, _, _ := readCard(t, ctx, cardID); imei != value {
|
||||
t.Fatalf("card %d: imei = %q, want %q", cardID, imei, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyBatchRejectsInvalidCardID 非法卡 ID 拒绝整批。
|
||||
func TestApplyBatchRejectsInvalidCardID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setupDB(t, ctx)
|
||||
|
||||
repo := NewRepository()
|
||||
if err := repo.ApplyBatch(ctx, []Result{
|
||||
{CardID: 1, Synced: true, ValueChanged: true, Imei: "865466070817587"},
|
||||
{CardID: 0, Synced: true, ValueChanged: true, Imei: "111222333444555"},
|
||||
}); err == nil {
|
||||
t.Fatal("非法 cardId 应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyBatchRejectsOversizedBatch 超大批次拒绝,防止单条 SQL 无限增长。
|
||||
func TestApplyBatchRejectsOversizedBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setupDB(t, ctx)
|
||||
|
||||
repo := NewRepository()
|
||||
results := make([]Result, 0, maxImeiSyncBatch+1)
|
||||
for i := 0; i <= maxImeiSyncBatch; i++ {
|
||||
results = append(results, Result{CardID: int64(i + 1), Synced: true})
|
||||
}
|
||||
if err := repo.ApplyBatch(ctx, results); err == nil {
|
||||
t.Fatal("超大批次应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyBatchEmptyIsNoop 空批次不发 SQL。
|
||||
func TestApplyBatchEmptyIsNoop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setupDB(t, ctx)
|
||||
|
||||
repo := NewRepository()
|
||||
if err := repo.ApplyBatch(ctx, nil); err != nil {
|
||||
t.Fatalf("空批次应无操作: %v", err)
|
||||
}
|
||||
if err := repo.ApplyBatch(ctx, []Result{}); err != nil {
|
||||
t.Fatalf("空切片应无操作: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyBatchAllUnsyncedIssuesNoSQL 全部未同步时不应发任何 UPDATE。
|
||||
func TestApplyBatchAllUnsyncedIssuesNoSQL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setupDB(t, ctx)
|
||||
|
||||
repo := NewRepository()
|
||||
if err := repo.ApplyBatch(ctx, []Result{
|
||||
{CardID: 1, Synced: false},
|
||||
{CardID: 2, Synced: false},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyBatch: %v", err)
|
||||
}
|
||||
for _, id := range []int64{1, 2} {
|
||||
if _, syncTime, updateTime := readCard(t, ctx, id); syncTime != nil || updateTime != nil {
|
||||
t.Fatalf("card %d 不应被改动", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestImeiValuePattern documents the accepted value grammar.
|
||||
func TestImeiValuePattern(t *testing.T) {
|
||||
valid := []string{"1", "865466070817587", strings.Repeat("9", 32)}
|
||||
invalid := []string{"", "abc", "865466070817587'", "865-466", " 865466070817587", strings.Repeat("9", 33), "865466070817587;1"}
|
||||
for _, value := range valid {
|
||||
if !imeiValuePattern.MatchString(value) {
|
||||
t.Fatalf("%q 应被接受", value)
|
||||
}
|
||||
}
|
||||
for _, value := range invalid {
|
||||
if imeiValuePattern.MatchString(value) {
|
||||
t.Fatalf("%q 应被拒绝", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package renewchange
|
||||
|
||||
import "github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
var (
|
||||
errRenewDurationEmpty = gerror.New("续费/变更套餐未配置可续费时长")
|
||||
errPkgNotFound = gerror.New("套餐不存在")
|
||||
errPkgDisabled = gerror.New("套餐未启用或不存在")
|
||||
errDeptNotFound = gerror.New("客户不存在")
|
||||
errNoChangeablePackages = gerror.New("当前套餐未配置可变更套餐范围")
|
||||
errCardNotFound = gerror.New("ICCID不存在正式卡记录")
|
||||
errLatestBindMissing = gerror.New("ICCID最新订购记录不存在")
|
||||
errCardNotOwned = gerror.New("ICCID不属于当前机构")
|
||||
errCardNoCurrentPackage = gerror.New("ICCID没有当前套餐")
|
||||
errCardPackageMismatch = gerror.New("ICCID当前套餐与最新订购记录不一致")
|
||||
errCardExpireMissing = gerror.New("ICCID最新订购没有预计到期时间")
|
||||
errCardSourceBatchMismatch = gerror.New("ICCID不属于来源出库批次")
|
||||
errDuplicateICCID = gerror.New("ICCID重复")
|
||||
errPrepareIncomplete = gerror.New("预处理结果不完整")
|
||||
errMultiplePackages = gerror.New("当前批次下存在多个套餐,不能统一处理")
|
||||
errMultipleCustomers = gerror.New("当前批次下存在多个客户,不能统一处理")
|
||||
errSameBatchInvalid = gerror.New("同批存在不满足条件的卡,本次未执行")
|
||||
errSameBatchChangeInvalid = gerror.New("同批存在不满足变更条件的卡,本行未执行")
|
||||
errNoPendingBinds = gerror.New("ICCID没有有效订购记录")
|
||||
errActiveBindNotFound = gerror.New("ICCID当前有效订购不存在")
|
||||
errActiveBindMismatch = gerror.New("ICCID当前有效订购与当前套餐不一致")
|
||||
errActiveBindSourceBatch = gerror.New("ICCID当前有效订购不属于来源批次")
|
||||
errMsisdnMismatch = gerror.New("MSISDN与SIM卡记录不一致")
|
||||
errImsiMismatch = gerror.New("IMSI与SIM卡记录不一致")
|
||||
errRenewableDurationMiss = gerror.New("续费时长不在当前套餐可续费范围内")
|
||||
errPriceChanged = gerror.New("当前续费价格已变化,请重新校验")
|
||||
errPackageTypeMismatch = gerror.New("目标套餐类型与当前套餐类型不一致")
|
||||
errNewPackageNotInChange = gerror.New("新套餐不在当前套餐可变更范围内")
|
||||
errNewPackageSameAsOld = gerror.New("新套餐不能与当前套餐相同")
|
||||
errTargetPkgNotFound = gerror.New("目标套餐不存在")
|
||||
errPrepareBatchNotFound = gerror.New("预处理批次不存在或已执行")
|
||||
errPrepareBatchTypeWrong = gerror.New("批次不是续费/变更预处理批次")
|
||||
errPrepareInfoMissing = gerror.New("预处理批次缺少关键快照")
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
const businessNoAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
// NextOrderSN returns a production order number before any INSERT is issued.
|
||||
func NextOrderSN(now time.Time) (string, error) {
|
||||
suffix, err := randomBusinessNoSuffix(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("SO%s%s", now.Format("20060102150405"), suffix), nil
|
||||
}
|
||||
|
||||
func randomBusinessNoSuffix(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", gerror.New("business number suffix length must be greater than zero")
|
||||
}
|
||||
randomBytes := make([]byte, length)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return "", gerror.Wrap(err, "generate business number suffix failed")
|
||||
}
|
||||
buf := make([]byte, length)
|
||||
for i, value := range randomBytes {
|
||||
buf[i] = businessNoAlphabet[int(value)%len(businessNoAlphabet)]
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
// GenerateOrderSn 形如 "yyyyMMddHHmmssfff"+6位随机后缀
|
||||
func GenerateOrderSn(t *time.Time) string {
|
||||
now := time.Now()
|
||||
if t != nil {
|
||||
now = *t
|
||||
}
|
||||
return fmt.Sprintf("%s%06d", now.UTC().Format("20060102150405.000")[0:14], now.Nanosecond()/1000%1000000)
|
||||
}
|
||||
|
||||
// GenerateSubscriptionNo 形如 "SUB" + yyyyMMddHHmmss + 6位后缀
|
||||
func GenerateSubscriptionNo(prefix string) string {
|
||||
now := time.Now()
|
||||
if strings.TrimSpace(prefix) == "" {
|
||||
prefix = "SUB"
|
||||
}
|
||||
return fmt.Sprintf("%s%s%06d", prefix, now.UTC().Format("20060102150405"), now.Nanosecond()/1000%1000000)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// OrderDO mirrors the production t_order table. It is repository-local
|
||||
// because Worker does not generate DAO/DO/entity code for the order domain.
|
||||
type OrderDO struct {
|
||||
g.Meta `orm:"table:t_order, do:true"`
|
||||
Id any `orm:"id"`
|
||||
OrderSn any `orm:"order_sn"`
|
||||
ParentOrderId any `orm:"parent_order_id"`
|
||||
ParentOrderSn any `orm:"parent_order_sn"`
|
||||
CustomerType any `orm:"customer_type"`
|
||||
CustomerAgentId any `orm:"customer_agent_id"`
|
||||
EndUserId any `orm:"end_user_id"`
|
||||
AgentId any `orm:"agent_id"`
|
||||
OrderType any `orm:"order_type"`
|
||||
OrderStatus any `orm:"order_status"`
|
||||
ShippingStatus any `orm:"shipping_status"`
|
||||
ReturnStatus any `orm:"return_status"`
|
||||
Consignee any `orm:"consignee"`
|
||||
Mobile any `orm:"mobile"`
|
||||
Email any `orm:"email"`
|
||||
Address any `orm:"address"`
|
||||
AreaCode any `orm:"area_code"`
|
||||
BuyerNote any `orm:"buyer_note"`
|
||||
AdminNote any `orm:"admin_note"`
|
||||
Remark any `orm:"remark"`
|
||||
ShippingMethod any `orm:"shipping_method"`
|
||||
LogisticsId any `orm:"logistics_id"`
|
||||
LogisticsName any `orm:"logistics_name"`
|
||||
ShippingTypeId any `orm:"shipping_type_id"`
|
||||
ShippingTypeName any `orm:"shipping_type_name"`
|
||||
TrackingNo any `orm:"tracking_no"`
|
||||
ShippingTime *gtime.Time `orm:"shipping_time"`
|
||||
ReceivedTime *gtime.Time `orm:"received_time"`
|
||||
PayType any `orm:"pay_type"`
|
||||
PayStatus any `orm:"pay_status"`
|
||||
PayOrderSn any `orm:"pay_order_sn"`
|
||||
PayTime *gtime.Time `orm:"pay_time"`
|
||||
TotalAmount any `orm:"total_amount"`
|
||||
ProductAmount any `orm:"product_amount"`
|
||||
ShippingFee any `orm:"shipping_fee"`
|
||||
ServiceFee any `orm:"service_fee"`
|
||||
InvoiceFee any `orm:"invoice_fee"`
|
||||
CouponAmount any `orm:"coupon_amount"`
|
||||
DiscountAmount any `orm:"discount_amount"`
|
||||
Balance any `orm:"balance"`
|
||||
PaidAmount any `orm:"paid_amount"`
|
||||
OnlinePaidAmount any `orm:"online_paid_amount"`
|
||||
OfflinePaidAmount any `orm:"offline_paid_amount"`
|
||||
UnpaidAmount any `orm:"unpaid_amount"`
|
||||
UnrefundAmount any `orm:"unrefund_amount"`
|
||||
IsNeedCommission any `orm:"is_need_commission"`
|
||||
DistributionStatus any `orm:"distribution_status"`
|
||||
TenantId any `orm:"tenant_id"`
|
||||
CreatedDept any `orm:"created_dept"`
|
||||
CreatedBy any `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy any `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
// OrderItemDO mirrors the production t_order_item table.
|
||||
type OrderItemDO struct {
|
||||
g.Meta `orm:"table:t_order_item, do:true"`
|
||||
ItemId any `orm:"item_id"`
|
||||
OrderId any `orm:"order_id"`
|
||||
OrderSn any `orm:"order_sn"`
|
||||
CustomerType any `orm:"customer_type"`
|
||||
CustomerAgentId any `orm:"customer_agent_id"`
|
||||
EndUserId any `orm:"end_user_id"`
|
||||
Price any `orm:"price"`
|
||||
Quantity any `orm:"quantity"`
|
||||
ProductId any `orm:"product_id"`
|
||||
ProductName any `orm:"product_name"`
|
||||
ProductSn any `orm:"product_sn"`
|
||||
PicThumb any `orm:"pic_thumb"`
|
||||
SkuId any `orm:"sku_id"`
|
||||
SkuData any `orm:"sku_data"`
|
||||
DeliveryQuantity any `orm:"delivery_quantity"`
|
||||
ProductType any `orm:"product_type"`
|
||||
IsGift any `orm:"is_gift"`
|
||||
AgentId any `orm:"agent_id"`
|
||||
PrepayPrice any `orm:"prepay_price"`
|
||||
Commission any `orm:"commission"`
|
||||
OriginPrice any `orm:"origin_price"`
|
||||
PromotionData any `orm:"promotion_data"`
|
||||
ExtraSkuData any `orm:"extra_sku_data"`
|
||||
PayMode any `orm:"pay_mode"`
|
||||
ServiceBeginTime *gtime.Time `orm:"service_begin_time"`
|
||||
ServiceEndTime *gtime.Time `orm:"service_end_time"`
|
||||
TenantId any `orm:"tenant_id"`
|
||||
CreatedDept any `orm:"created_dept"`
|
||||
CreatedBy any `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy any `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
// InsertContractOrder inserts a fully built order and returns its primary key.
|
||||
// The order number must exist before the INSERT is issued.
|
||||
func (r *Repository) InsertContractOrder(ctx context.Context, tx gdb.TX, order *OrderDO) (int64, error) {
|
||||
if err := ValidateOrderDO(order); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, err := tx.Ctx(ctx).Model(TableOrder).Data(order).InsertAndGetId()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "insert order failed")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// InsertContractOrderItem inserts the aggregate order item for an order.
|
||||
func (r *Repository) InsertContractOrderItem(ctx context.Context, tx gdb.TX, item *OrderItemDO) error {
|
||||
if err := ValidateOrderItemDO(item); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(TableOrderItem).Data(item).Insert()
|
||||
return gerror.Wrap(err, "insert order item failed")
|
||||
}
|
||||
|
||||
func ValidateOrderDO(order *OrderDO) error {
|
||||
if order == nil {
|
||||
return gerror.New("order cannot be nil")
|
||||
}
|
||||
if strings.TrimSpace(gconv.String(order.OrderSn)) == "" {
|
||||
return gerror.New("order number cannot be empty")
|
||||
}
|
||||
if gconv.Int64(order.CustomerAgentId) <= 0 {
|
||||
return gerror.New("order customer agent id must be greater than zero")
|
||||
}
|
||||
if gconv.Int64(order.AgentId) <= 0 {
|
||||
return gerror.New("order seller agent id must be greater than zero")
|
||||
}
|
||||
if strings.TrimSpace(gconv.String(order.TenantId)) == "" {
|
||||
return gerror.New("order tenant id cannot be empty")
|
||||
}
|
||||
if gconv.Int64(order.CreatedDept) <= 0 || gconv.Int64(order.CreatedBy) <= 0 || gconv.Int64(order.UpdatedBy) <= 0 {
|
||||
return gerror.New("order creator organization is incomplete")
|
||||
}
|
||||
if order.CreatedAt == nil || order.UpdatedAt == nil {
|
||||
return gerror.New("order audit time cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateOrderItemDO(item *OrderItemDO) error {
|
||||
if item == nil {
|
||||
return gerror.New("order item cannot be nil")
|
||||
}
|
||||
if gconv.Int64(item.OrderId) <= 0 {
|
||||
return gerror.New("order item order id must be greater than zero")
|
||||
}
|
||||
if strings.TrimSpace(gconv.String(item.OrderSn)) == "" {
|
||||
return gerror.New("order item order number cannot be empty")
|
||||
}
|
||||
if gconv.Int64(item.CustomerAgentId) <= 0 {
|
||||
return gerror.New("order item customer agent id must be greater than zero")
|
||||
}
|
||||
if gconv.Int64(item.AgentId) <= 0 {
|
||||
return gerror.New("order item seller agent id must be greater than zero")
|
||||
}
|
||||
if gconv.Int(item.Quantity) <= 0 {
|
||||
return gerror.New("order item quantity must be greater than zero")
|
||||
}
|
||||
if gconv.Int64(item.ProductId) <= 0 {
|
||||
return gerror.New("order item product id must be greater than zero")
|
||||
}
|
||||
if strings.TrimSpace(gconv.String(item.ProductName)) == "" || strings.TrimSpace(gconv.String(item.ProductSn)) == "" {
|
||||
return gerror.New("order item product contract is incomplete")
|
||||
}
|
||||
if item.ServiceBeginTime == nil || item.ServiceEndTime == nil || !item.ServiceEndTime.After(item.ServiceBeginTime) {
|
||||
return gerror.New("order item service period is invalid")
|
||||
}
|
||||
if strings.TrimSpace(gconv.String(item.TenantId)) == "" {
|
||||
return gerror.New("order item tenant id cannot be empty")
|
||||
}
|
||||
if gconv.Int64(item.CreatedDept) <= 0 || gconv.Int64(item.CreatedBy) <= 0 || gconv.Int64(item.UpdatedBy) <= 0 {
|
||||
return gerror.New("order item creator organization is incomplete")
|
||||
}
|
||||
if item.CreatedAt == nil || item.UpdatedAt == nil {
|
||||
return gerror.New("order item audit time cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func TestOrderDOLocalSchemaMatchesProductionContract(t *testing.T) {
|
||||
want := []string{
|
||||
"Id", "OrderSn", "ParentOrderId", "ParentOrderSn", "CustomerType",
|
||||
"CustomerAgentId", "EndUserId", "AgentId", "OrderType", "OrderStatus",
|
||||
"ShippingStatus", "ReturnStatus", "Consignee", "Mobile", "Email", "Address",
|
||||
"AreaCode", "BuyerNote", "AdminNote", "Remark", "ShippingMethod", "LogisticsId",
|
||||
"LogisticsName", "ShippingTypeId", "ShippingTypeName", "TrackingNo", "ShippingTime",
|
||||
"ReceivedTime", "PayType", "PayStatus", "PayOrderSn", "PayTime", "TotalAmount",
|
||||
"ProductAmount", "ShippingFee", "ServiceFee", "InvoiceFee", "CouponAmount",
|
||||
"DiscountAmount", "Balance", "PaidAmount", "OnlinePaidAmount", "OfflinePaidAmount",
|
||||
"UnpaidAmount", "UnrefundAmount", "IsNeedCommission", "DistributionStatus", "TenantId",
|
||||
"CreatedDept", "CreatedBy", "CreatedAt", "UpdatedBy", "UpdatedAt",
|
||||
}
|
||||
assertDOFields(t, reflect.TypeOf(OrderDO{}), want)
|
||||
}
|
||||
|
||||
func TestOrderItemDOLocalSchemaMatchesProductionContract(t *testing.T) {
|
||||
want := []string{
|
||||
"ItemId", "OrderId", "OrderSn", "CustomerType", "CustomerAgentId", "EndUserId",
|
||||
"Price", "Quantity", "ProductId", "ProductName", "ProductSn", "PicThumb", "SkuId",
|
||||
"SkuData", "DeliveryQuantity", "ProductType", "IsGift", "AgentId", "PrepayPrice",
|
||||
"Commission", "OriginPrice", "PromotionData", "ExtraSkuData", "PayMode",
|
||||
"ServiceBeginTime", "ServiceEndTime", "TenantId", "CreatedDept", "CreatedBy",
|
||||
"CreatedAt", "UpdatedBy", "UpdatedAt",
|
||||
}
|
||||
assertDOFields(t, reflect.TypeOf(OrderItemDO{}), want)
|
||||
}
|
||||
|
||||
func TestOrderDOsHaveExplicitORMColumnMappings(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
typ reflect.Type
|
||||
}{
|
||||
{name: "order", typ: reflect.TypeOf(OrderDO{})},
|
||||
{name: "order item", typ: reflect.TypeOf(OrderItemDO{})},
|
||||
{name: "change history", typ: reflect.TypeOf(ChangeBindHistoryDO{})},
|
||||
{name: "change order update", typ: reflect.TypeOf(changeOrderUpdateDO{})},
|
||||
{name: "change order item update", typ: reflect.TypeOf(changeOrderItemUpdateDO{})},
|
||||
{name: "change card update", typ: reflect.TypeOf(changeCardUpdateDO{})},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
for i := 0; i < testCase.typ.NumField(); i++ {
|
||||
field := testCase.typ.Field(i)
|
||||
if field.Name == "Meta" {
|
||||
continue
|
||||
}
|
||||
if got := field.Tag.Get("orm"); got == "" {
|
||||
t.Errorf("%s has no explicit orm column mapping", field.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderDOTagsMatchProductionColumns(t *testing.T) {
|
||||
assertDOColumnTags(t, reflect.TypeOf(OrderDO{}), map[string]string{
|
||||
"OrderSn": "order_sn", "CustomerAgentId": "customer_agent_id", "EndUserId": "end_user_id", "AgentId": "agent_id",
|
||||
"OrderType": "order_type", "OrderStatus": "order_status", "ShippingStatus": "shipping_status", "ReturnStatus": "return_status",
|
||||
"BuyerNote": "buyer_note", "AdminNote": "admin_note", "Remark": "remark", "PayType": "pay_type", "PayStatus": "pay_status",
|
||||
"PayTime": "pay_time", "TotalAmount": "total_amount", "ProductAmount": "product_amount", "PaidAmount": "paid_amount",
|
||||
"OnlinePaidAmount": "online_paid_amount", "OfflinePaidAmount": "offline_paid_amount", "TenantId": "tenant_id",
|
||||
"CreatedDept": "created_dept", "CreatedBy": "created_by", "CreatedAt": "created_at", "UpdatedBy": "updated_by", "UpdatedAt": "updated_at",
|
||||
})
|
||||
assertDOColumnTags(t, reflect.TypeOf(OrderItemDO{}), map[string]string{
|
||||
"ItemId": "item_id", "OrderId": "order_id", "OrderSn": "order_sn", "CustomerAgentId": "customer_agent_id", "EndUserId": "end_user_id",
|
||||
"Price": "price", "Quantity": "quantity", "ProductId": "product_id", "ProductName": "product_name", "ProductSn": "product_sn",
|
||||
"SkuData": "sku_data", "ProductType": "product_type", "IsGift": "is_gift", "AgentId": "agent_id", "OriginPrice": "origin_price",
|
||||
"ExtraSkuData": "extra_sku_data", "PayMode": "pay_mode", "ServiceBeginTime": "service_begin_time", "ServiceEndTime": "service_end_time",
|
||||
"TenantId": "tenant_id", "CreatedDept": "created_dept", "CreatedBy": "created_by", "CreatedAt": "created_at", "UpdatedBy": "updated_by", "UpdatedAt": "updated_at",
|
||||
})
|
||||
}
|
||||
|
||||
func TestNextOrderSNIsGeneratedBeforeInsert(t *testing.T) {
|
||||
got, err := NextOrderSN(time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local))
|
||||
if err != nil {
|
||||
t.Fatalf("NextOrderSN returned error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(got, "SO20260803100250") {
|
||||
t.Fatalf("NextOrderSN = %q, want SO prefix and timestamp", got)
|
||||
}
|
||||
if len(got) != len("SO20260803100250")+8 {
|
||||
t.Fatalf("NextOrderSN length = %d, want %d", len(got), len("SO20260803100250")+8)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContractOrderRequiredFields(t *testing.T) {
|
||||
now := gtime.NewFromTime(time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local))
|
||||
order := &OrderDO{
|
||||
OrderSn: "SO20260803100250ABCDEFGH",
|
||||
CustomerAgentId: int64(200),
|
||||
AgentId: int64(100),
|
||||
TenantId: "000000",
|
||||
CreatedDept: int64(100),
|
||||
CreatedBy: int64(1),
|
||||
CreatedAt: now,
|
||||
UpdatedBy: int64(1),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := ValidateOrderDO(order); err != nil {
|
||||
t.Fatalf("ValidateOrderDO returned error: %v", err)
|
||||
}
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
clear func(*OrderDO)
|
||||
}{
|
||||
{name: "order number", clear: func(order *OrderDO) { order.OrderSn = "" }},
|
||||
{name: "customer agent", clear: func(order *OrderDO) { order.CustomerAgentId = int64(0) }},
|
||||
{name: "seller agent", clear: func(order *OrderDO) { order.AgentId = int64(0) }},
|
||||
{name: "tenant", clear: func(order *OrderDO) { order.TenantId = "" }},
|
||||
{name: "creator department", clear: func(order *OrderDO) { order.CreatedDept = int64(0) }},
|
||||
{name: "creator", clear: func(order *OrderDO) { order.CreatedBy = int64(0) }},
|
||||
{name: "updater", clear: func(order *OrderDO) { order.UpdatedBy = int64(0) }},
|
||||
{name: "created time", clear: func(order *OrderDO) { order.CreatedAt = nil }},
|
||||
{name: "updated time", clear: func(order *OrderDO) { order.UpdatedAt = nil }},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
invalid := *order
|
||||
testCase.clear(&invalid)
|
||||
if err := ValidateOrderDO(&invalid); err == nil {
|
||||
t.Fatalf("ValidateOrderDO accepted missing %s", testCase.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContractOrderItemRequiredFields(t *testing.T) {
|
||||
beginAt := gtime.NewFromTime(time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local))
|
||||
endAt := gtime.NewFromTime(time.Date(2027, 8, 3, 0, 0, 0, 0, time.Local))
|
||||
item := &OrderItemDO{
|
||||
OrderId: int64(1),
|
||||
OrderSn: "SO20260803100250ABCDEFGH",
|
||||
CustomerAgentId: int64(200),
|
||||
AgentId: int64(100),
|
||||
Quantity: 2,
|
||||
ProductId: int64(300),
|
||||
ProductName: "package",
|
||||
ProductSn: "PKG001",
|
||||
ServiceBeginTime: beginAt,
|
||||
ServiceEndTime: endAt,
|
||||
TenantId: "000000",
|
||||
CreatedDept: int64(100),
|
||||
CreatedBy: int64(1),
|
||||
CreatedAt: beginAt,
|
||||
UpdatedBy: int64(1),
|
||||
UpdatedAt: beginAt,
|
||||
}
|
||||
if err := ValidateOrderItemDO(item); err != nil {
|
||||
t.Fatalf("ValidateOrderItemDO returned error: %v", err)
|
||||
}
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
clear func(*OrderItemDO)
|
||||
}{
|
||||
{name: "order id", clear: func(item *OrderItemDO) { item.OrderId = int64(0) }},
|
||||
{name: "order number", clear: func(item *OrderItemDO) { item.OrderSn = "" }},
|
||||
{name: "customer agent", clear: func(item *OrderItemDO) { item.CustomerAgentId = int64(0) }},
|
||||
{name: "seller agent", clear: func(item *OrderItemDO) { item.AgentId = int64(0) }},
|
||||
{name: "quantity", clear: func(item *OrderItemDO) { item.Quantity = 0 }},
|
||||
{name: "product id", clear: func(item *OrderItemDO) { item.ProductId = int64(0) }},
|
||||
{name: "product name", clear: func(item *OrderItemDO) { item.ProductName = "" }},
|
||||
{name: "product code", clear: func(item *OrderItemDO) { item.ProductSn = "" }},
|
||||
{name: "service start", clear: func(item *OrderItemDO) { item.ServiceBeginTime = nil }},
|
||||
{name: "service end", clear: func(item *OrderItemDO) { item.ServiceEndTime = nil }},
|
||||
{name: "tenant", clear: func(item *OrderItemDO) { item.TenantId = "" }},
|
||||
{name: "creator department", clear: func(item *OrderItemDO) { item.CreatedDept = int64(0) }},
|
||||
{name: "creator", clear: func(item *OrderItemDO) { item.CreatedBy = int64(0) }},
|
||||
{name: "updater", clear: func(item *OrderItemDO) { item.UpdatedBy = int64(0) }},
|
||||
{name: "created time", clear: func(item *OrderItemDO) { item.CreatedAt = nil }},
|
||||
{name: "updated time", clear: func(item *OrderItemDO) { item.UpdatedAt = nil }},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
invalid := *item
|
||||
testCase.clear(&invalid)
|
||||
if err := ValidateOrderItemDO(&invalid); err == nil {
|
||||
t.Fatalf("ValidateOrderItemDO accepted missing %s", testCase.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertDOFields(t *testing.T, typ reflect.Type, want []string) {
|
||||
t.Helper()
|
||||
got := make([]string, 0, typ.NumField()-1)
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
if field.Name == "Meta" {
|
||||
continue
|
||||
}
|
||||
got = append(got, field.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%s fields mismatch\n got: %v\nwant: %v", typ.Name(), got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertDOColumnTags(t *testing.T, typ reflect.Type, want map[string]string) {
|
||||
t.Helper()
|
||||
for fieldName, column := range want {
|
||||
field, ok := typ.FieldByName(fieldName)
|
||||
if !ok {
|
||||
t.Fatalf("%s missing field %s", typ.Name(), fieldName)
|
||||
}
|
||||
if got := field.Tag.Get("orm"); got != column {
|
||||
t.Fatalf("%s.%s orm tag = %q, want %q", typ.Name(), fieldName, got, column)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizePriceOverrideResultTreatsNoRowsAsMissing(t *testing.T) {
|
||||
got, err := normalizePriceOverrideResult(&FlowPackagePrice{}, sql.ErrNoRows)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizePriceOverrideResult returned error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("normalizePriceOverrideResult returned %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePriceOverrideResultReturnsOtherErrors(t *testing.T) {
|
||||
wantErr := errors.New("database unavailable")
|
||||
got, err := normalizePriceOverrideResult(nil, wantErr)
|
||||
if got != nil {
|
||||
t.Fatalf("normalizePriceOverrideResult returned %+v on error", got)
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want wrapped %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePriceOverrideResultKeepsPriceRow(t *testing.T) {
|
||||
want := &FlowPackagePrice{Id: 7, AgentCostProfit: 1.25}
|
||||
got, err := normalizePriceOverrideResult(want, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizePriceOverrideResult returned error: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("row pointer = %p, want %p", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
)
|
||||
|
||||
// LockRenewBatchMain locks the business batch before the renew transaction
|
||||
// starts. This closes the gap between runtime claiming and business writes
|
||||
// when a duplicate message is delivered concurrently.
|
||||
func (r *Repository) LockRenewBatchMain(ctx context.Context, tx gdb.TX, batchNo string, batchType int) (*BatchMainRow, error) {
|
||||
var main BatchMainRow
|
||||
err := tx.Ctx(ctx).Model(TableBatchMain).
|
||||
Where("batch_no", strings.TrimSpace(batchNo)).
|
||||
Where("batch_type", batchType).
|
||||
LockUpdate().
|
||||
Scan(&main)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "lock renew batch main failed")
|
||||
}
|
||||
if main.Id == 0 {
|
||||
return nil, gerror.New("renew batch main not found")
|
||||
}
|
||||
return &main, nil
|
||||
}
|
||||
|
||||
// LockRenewDetails reloads every detail row inside the business transaction.
|
||||
// The initial consumer read is only a routing hint; the transaction must lock
|
||||
// the exact rows and let the service reject any row that is no longer running.
|
||||
func (r *Repository) LockRenewDetails(ctx context.Context, tx gdb.TX, batchNo string, batchType int) ([]*BatchDetailRow, error) {
|
||||
var rows []*BatchDetailRow
|
||||
if err := tx.Ctx(ctx).Model(TableBatchDetail).
|
||||
Where("batch_no", strings.TrimSpace(batchNo)).
|
||||
Where("batch_type", batchType).
|
||||
OrderAsc("row_no").
|
||||
LockUpdate().
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "lock renew details failed")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// LoadRenewPackage reads the package inside the business transaction so the
|
||||
// order item is built from a real package row, not a hand-written snapshot.
|
||||
func (r *Repository) LoadRenewPackage(ctx context.Context, tx gdb.TX, tenantID, packageCode string) (*FlowPackageInfo, error) {
|
||||
var pkg FlowPackageInfo
|
||||
query := tx.Ctx(ctx).Model(TableSimFlowPackageInfo).
|
||||
Where("package_code", strings.TrimSpace(packageCode))
|
||||
if strings.TrimSpace(tenantID) != "" {
|
||||
query = query.Where("tenant_id", strings.TrimSpace(tenantID))
|
||||
}
|
||||
query = query.LockUpdate()
|
||||
if err := query.Scan(&pkg); err != nil {
|
||||
return nil, gerror.Wrap(err, "load renew package failed")
|
||||
}
|
||||
if pkg.Id == 0 {
|
||||
return nil, gerror.New("renew package not found")
|
||||
}
|
||||
return &pkg, nil
|
||||
}
|
||||
|
||||
// UpdateRenewCard writes only the fields allowed by the renew contract.
|
||||
func (r *Repository) UpdateRenewCard(ctx context.Context, tx gdb.TX, iccid string, update *do.TSimCardInfo) error {
|
||||
if strings.TrimSpace(iccid) == "" {
|
||||
return gerror.New("renew card ICCID is empty")
|
||||
}
|
||||
if update == nil {
|
||||
return gerror.New("renew card update is empty")
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(TableSimCardInfo).
|
||||
Where("iccid", strings.TrimSpace(iccid)).
|
||||
Data(update).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "update renew card failed")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "read renew card update count failed")
|
||||
}
|
||||
if affected != 1 {
|
||||
return gerror.Newf("renew card changed during execution: %s", iccid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
)
|
||||
|
||||
// Repository 提供对续费/变更相关表的 typed 访问。
|
||||
// 表名/列名都是常量(见 tables.go),与 Admin 同步链路保持一致。
|
||||
type Repository struct{}
|
||||
|
||||
func New() *Repository {
|
||||
return &Repository{}
|
||||
}
|
||||
|
||||
const simCardRenewChangeFields = "id,iccid,msisdn,imsi,dept_id,current_package_code,package_status,outbound_batch_no,flow_pkg_begin_at,flow_pkg_end_at,tenant_id,created_dept,updated_by,updated_at"
|
||||
|
||||
func (r *Repository) database() gdb.DB {
|
||||
return db()
|
||||
}
|
||||
|
||||
// DB 返回底层 gdb.DB,供 service 层发起事务。
|
||||
func (r *Repository) DB() gdb.DB {
|
||||
return r.database()
|
||||
}
|
||||
|
||||
// LoadBatchMain 按 batchNo+batchType 加载。
|
||||
func (r *Repository) LoadBatchMain(ctx context.Context, batchNo string, batchType int) (*BatchMainRow, error) {
|
||||
if batchNo == "" {
|
||||
return nil, gerror.New("batch number is empty")
|
||||
}
|
||||
m := r.database().Model(TableBatchMain).Safe().Ctx(ctx).
|
||||
Where("batch_no", batchNo).
|
||||
Where("batch_type", batchType)
|
||||
var out BatchMainRow
|
||||
if err := m.Scan(&out); err != nil {
|
||||
return nil, gerror.Wrap(err, "query batch main failed")
|
||||
}
|
||||
if out.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// LoadBatchDetails 加载指定批次下指定状态集合的明细。
|
||||
func (r *Repository) LoadBatchDetails(ctx context.Context, batchNo string, batchType int, statuses []int) ([]*BatchDetailRow, error) {
|
||||
if batchNo == "" {
|
||||
return nil, nil
|
||||
}
|
||||
m := r.database().Model(TableBatchDetail).Safe().Ctx(ctx).
|
||||
Where("batch_no", batchNo).
|
||||
Where("batch_type", batchType).
|
||||
OrderAsc("row_no")
|
||||
if len(statuses) > 0 {
|
||||
m = m.WhereIn("detail_status", statuses)
|
||||
}
|
||||
var out []*BatchDetailRow
|
||||
if err := m.Scan(&out); err != nil {
|
||||
return nil, gerror.Wrap(err, "query batch details failed")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) LoadCardsByIdentityValues(ctx context.Context, tenantID string, values []string) ([]*SimCardInfo, error) {
|
||||
uniq := uniqueStrings(values)
|
||||
if len(uniq) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
byID := map[int64]*SimCardInfo{}
|
||||
for _, column := range []string{"iccid", "msisdn", "imsi"} {
|
||||
var rows []*SimCardInfo
|
||||
query := r.database().Model(TableSimCardInfo).Safe().Ctx(ctx).
|
||||
Fields(simCardRenewChangeFields).
|
||||
WhereIn(column, uniq)
|
||||
if tenantID != "" {
|
||||
query = query.Where("tenant_id", tenantID)
|
||||
}
|
||||
if err := query.Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "query sim cards by identity failed")
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row != nil && row.Id > 0 {
|
||||
byID[row.Id] = row
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]*SimCardInfo, 0, len(byID))
|
||||
for _, row := range byID {
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateBatchMainStatus 在事务内更新主表。
|
||||
func (r *Repository) UpdateBatchMainStatus(ctx context.Context, tx gdb.TX, batchNo string, batchType int, data *BatchMainRow) error {
|
||||
updateData := map[string]any{
|
||||
"batch_type": data.BatchType,
|
||||
"batch_async_status": data.BatchAsyncStatus,
|
||||
"run_result": data.RunResult,
|
||||
"total_count": data.TotalCount,
|
||||
"valid_count": data.ValidCount,
|
||||
"success_count": data.SuccessCount,
|
||||
"fail_count": data.FailCount,
|
||||
"fail_reason": data.FailReason,
|
||||
"batch_info": data.BatchInfo,
|
||||
"end_time": data.EndTime,
|
||||
"updated_by": data.UpdatedBy,
|
||||
"updated_at": gtime.Now(),
|
||||
"order_sn": data.OrderSn,
|
||||
"target_dept_id": data.TargetDeptId,
|
||||
}
|
||||
if data.StartTime != nil && !data.StartTime.IsZero() {
|
||||
updateData["start_time"] = data.StartTime
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(TableBatchMain).
|
||||
Where("batch_no", batchNo).
|
||||
Where("batch_type", batchType).
|
||||
Data(updateData).
|
||||
Update()
|
||||
return gerror.Wrap(err, "update batch main failed")
|
||||
}
|
||||
|
||||
// ConvertBatchDetails 将指定状态的明细转换为目标类型与状态。
|
||||
func (r *Repository) ConvertBatchDetails(ctx context.Context, tx gdb.TX, batchNo string, fromTypes []int, toType, toStatus int, data map[string]any) (int64, error) {
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
data["batch_type"] = toType
|
||||
data["detail_status"] = toStatus
|
||||
data["updated_at"] = gtime.Now()
|
||||
result, err := tx.Ctx(ctx).Model(TableBatchDetail).
|
||||
Where("batch_no", batchNo).
|
||||
WhereIn("batch_type", fromTypes).
|
||||
Data(data).
|
||||
Update()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "convert batch details failed")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// UpdateBatchDetailSnapshot 覆盖指定明细的 snapshot 与状态。
|
||||
func (r *Repository) UpdateBatchDetailSnapshot(ctx context.Context, tx gdb.TX, batchNo string, batchType, rowNo int, status int, snapshot *gjson.Json, reason string, userID int64) error {
|
||||
data := map[string]any{
|
||||
"detail_status": status,
|
||||
"snapshot": snapshot,
|
||||
"updated_at": gtime.Now(),
|
||||
"updated_by": userID,
|
||||
}
|
||||
if reason != "" {
|
||||
data["error_reason"] = reason
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(TableBatchDetail).
|
||||
Where("batch_no", batchNo).
|
||||
Where("batch_type", batchType).
|
||||
Where("row_no", rowNo).
|
||||
Data(data).
|
||||
Update()
|
||||
return gerror.Wrap(err, "update batch detail snapshot failed")
|
||||
}
|
||||
|
||||
// UpsertBatchDetail 按 (batch_no, batch_type, row_no) 写入明细(覆盖式)。
|
||||
func (r *Repository) UpsertBatchDetail(ctx context.Context, tx gdb.TX, detail *BatchDetailRow) error {
|
||||
_, err := tx.Ctx(ctx).Model(TableBatchDetail).
|
||||
Where("batch_no", detail.BatchNo).
|
||||
Where("batch_type", detail.BatchType).
|
||||
Where("row_no", detail.RowNo).
|
||||
Data(map[string]any{
|
||||
"detail_status": detail.DetailStatus,
|
||||
"blank_out_status": detail.BlankOutStatus,
|
||||
"item_id": detail.ItemId,
|
||||
"error_code": detail.ErrorCode,
|
||||
"error_reason": detail.ErrorReason,
|
||||
"snapshot": detail.Snapshot,
|
||||
"updated_at": gtime.Now(),
|
||||
"updated_by": detail.UpdatedBy,
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "update batch detail failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadCardsByIccids 加载正式卡。
|
||||
func (r *Repository) LoadCardsByIccids(ctx context.Context, iccids []string) (map[string]*SimCardInfo, error) {
|
||||
out := make(map[string]*SimCardInfo, len(iccids))
|
||||
uniq := uniqueStrings(iccids)
|
||||
if len(uniq) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []*SimCardInfo
|
||||
if err := r.database().Model(TableSimCardInfo).Safe().Ctx(ctx).
|
||||
Fields(simCardRenewChangeFields).
|
||||
WhereIn("iccid", uniq).
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "query sim cards failed")
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
if row.Iccid == "" {
|
||||
continue
|
||||
}
|
||||
out[row.Iccid] = row
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LockCardsByIccids 在事务内按 ICCID 锁行加载正式卡。
|
||||
func (r *Repository) LockCardsByIccids(ctx context.Context, tx gdb.TX, iccids []string) (map[string]*SimCardInfo, error) {
|
||||
out := make(map[string]*SimCardInfo, len(iccids))
|
||||
uniq := uniqueStrings(iccids)
|
||||
if len(uniq) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []*SimCardInfo
|
||||
if err := tx.Ctx(ctx).Model(TableSimCardInfo).
|
||||
Fields(simCardRenewChangeFields).
|
||||
WhereIn("iccid", uniq).
|
||||
LockUpdate().
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "lock sim cards failed")
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
if row.Iccid == "" {
|
||||
continue
|
||||
}
|
||||
out[row.Iccid] = row
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LoadActiveBindRowsByIccids loads every pending/effective formal package
|
||||
// subscription for prepare validation. Add-on package rows (103/104) are
|
||||
// intentionally excluded from current-subscription selection.
|
||||
func (r *Repository) LoadActiveBindRowsByIccids(ctx context.Context, iccids []string) ([]*SimCardBindPkg, error) {
|
||||
uniq := uniqueStrings(iccids)
|
||||
if len(uniq) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []*SimCardBindPkg
|
||||
if err := r.database().Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
WhereIn("iccid", uniq).
|
||||
WhereIn("package_type", consts.FormalPackageTypes()).
|
||||
WhereIn("effective_status", []int{consts.EffectiveStatusPending, consts.EffectiveStatusEffective}).
|
||||
OrderDesc("effective_status").
|
||||
OrderAsc("effective_time").
|
||||
OrderAsc("id").
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "load active binds failed")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// LoadRenewBindRowsByIccids includes expired formal-package history so renewal
|
||||
// can restart after a card has naturally expired. Add-on package rows (103/104)
|
||||
// never participate in renewal selection.
|
||||
func (r *Repository) LoadRenewBindRowsByIccids(ctx context.Context, iccids []string) ([]*SimCardBindPkg, error) {
|
||||
uniq := uniqueStrings(iccids)
|
||||
if len(uniq) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []*SimCardBindPkg
|
||||
if err := r.database().Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
WhereIn("iccid", uniq).
|
||||
WhereIn("package_type", consts.FormalPackageTypes()).
|
||||
WhereIn("effective_status", []int{
|
||||
consts.EffectiveStatusPending,
|
||||
consts.EffectiveStatusEffective,
|
||||
consts.EffectiveStatusExpired,
|
||||
}).
|
||||
OrderDesc("effective_status").
|
||||
OrderAsc("effective_time").
|
||||
OrderAsc("id").
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "load renew binds failed")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// LockActiveBindRowsByIccids is the transactional counterpart used by change
|
||||
// execution before any business write. It locks formal package rows only.
|
||||
func (r *Repository) LockActiveBindRowsByIccids(ctx context.Context, tx gdb.TX, iccids []string) ([]*SimCardBindPkg, error) {
|
||||
uniq := uniqueStrings(iccids)
|
||||
if len(uniq) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []*SimCardBindPkg
|
||||
if err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
WhereIn("iccid", uniq).
|
||||
WhereIn("package_type", consts.FormalPackageTypes()).
|
||||
WhereIn("effective_status", []int{consts.EffectiveStatusPending, consts.EffectiveStatusEffective}).
|
||||
OrderDesc("effective_status").
|
||||
OrderAsc("effective_time").
|
||||
OrderAsc("id").
|
||||
LockUpdate().
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "lock active binds failed")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// LockRenewBindRowsByIccids includes expired formal-package history for renewal
|
||||
// execution. Change execution keeps using LockActiveBindRowsByIccids.
|
||||
func (r *Repository) LockRenewBindRowsByIccids(ctx context.Context, tx gdb.TX, iccids []string) ([]*SimCardBindPkg, error) {
|
||||
uniq := uniqueStrings(iccids)
|
||||
if len(uniq) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []*SimCardBindPkg
|
||||
if err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
WhereIn("iccid", uniq).
|
||||
WhereIn("package_type", consts.FormalPackageTypes()).
|
||||
WhereIn("effective_status", []int{
|
||||
consts.EffectiveStatusPending,
|
||||
consts.EffectiveStatusEffective,
|
||||
consts.EffectiveStatusExpired,
|
||||
}).
|
||||
OrderDesc("effective_status").
|
||||
OrderAsc("effective_time").
|
||||
OrderAsc("id").
|
||||
LockUpdate().
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "lock renew binds failed")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// LockPendingBindsByIccids 锁行加载 pending 状态的未来订购。
|
||||
func (r *Repository) LockPendingBindsByIccids(ctx context.Context, tx gdb.TX, iccids []string) ([]*SimCardBindPkg, error) {
|
||||
uniq := uniqueStrings(iccids)
|
||||
if len(uniq) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []*SimCardBindPkg
|
||||
if err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
WhereIn("iccid", uniq).
|
||||
WhereIn("package_type", consts.FormalPackageTypes()).
|
||||
Where("effective_status", consts.EffectiveStatusPending).
|
||||
OrderAsc("effective_time").
|
||||
OrderAsc("id").
|
||||
LockUpdate().
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "lock pending binds failed")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// DeletePendingBindsBySubscriptionNos 删除(作废)一批订阅号。
|
||||
func (r *Repository) DeletePendingBindsBySubscriptionNos(ctx context.Context, tx gdb.TX, subscriptionNos []string) (int64, error) {
|
||||
uniq := uniqueStrings(subscriptionNos)
|
||||
if len(uniq) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
WhereIn("subscription_no", uniq).
|
||||
Delete()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "delete pending binds failed")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// LoadPackageByCode 按 packageCode 读取套餐。
|
||||
func (r *Repository) LoadPackageByCode(ctx context.Context, tenantID, packageCode string) (*FlowPackageInfo, error) {
|
||||
if packageCode == "" {
|
||||
return nil, errPkgNotFound
|
||||
}
|
||||
var row FlowPackageInfo
|
||||
err := r.database().Model(TableSimFlowPackageInfo).Safe().Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("package_code", strings.TrimSpace(packageCode)).
|
||||
Scan(&row)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "query package failed")
|
||||
}
|
||||
if row.Id == 0 {
|
||||
return nil, errPkgNotFound
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// LoadDeptName 按 deptId 取客户名。
|
||||
func (r *Repository) LoadDeptName(ctx context.Context, tenantID string, deptID int64) (string, error) {
|
||||
row, err := r.LoadDept(ctx, tenantID, deptID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(row.DeptName), nil
|
||||
}
|
||||
|
||||
// LoadDept returns the current department and its parent. Renewals use the
|
||||
// parent department as the price owner for the current card owner.
|
||||
func (r *Repository) LoadDept(ctx context.Context, tenantID string, deptID int64) (*SysDeptRow, error) {
|
||||
if deptID <= 0 {
|
||||
return nil, errDeptNotFound
|
||||
}
|
||||
var row SysDeptRow
|
||||
err := r.database().Model(TableSysDept).Safe().Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("dept_id", deptID).
|
||||
Scan(&row)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "query dept failed")
|
||||
}
|
||||
if row.DeptId == 0 {
|
||||
return nil, errDeptNotFound
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// LoadPriceOverride 读取代理续费/变更价格覆盖。
|
||||
func (r *Repository) LoadPriceOverride(ctx context.Context, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*FlowPackagePrice, error) {
|
||||
var row FlowPackagePrice
|
||||
err := r.database().Model(TableSimFlowPackagePrice).Safe().Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("dept_id", deptID).
|
||||
Where("agent_scope_type", scopeType).
|
||||
Where("target_agent_id", targetAgentID).
|
||||
Where("package_code", strings.TrimSpace(packageCode)).
|
||||
Where("enabled", 1).
|
||||
Scan(&row)
|
||||
return normalizePriceOverrideResult(&row, err)
|
||||
}
|
||||
|
||||
func normalizePriceOverrideResult(row *FlowPackagePrice, err error) (*FlowPackagePrice, error) {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "query price override failed")
|
||||
}
|
||||
if row == nil || row.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// UpdateCardPackageFields 续费/变更后更新卡的当前套餐与流量状态。
|
||||
func (r *Repository) UpdateCardPackageFields(ctx context.Context, tx gdb.TX, iccid string, fields map[string]any, userID int64) error {
|
||||
if iccid == "" {
|
||||
return gerror.New("iccid is empty")
|
||||
}
|
||||
fields["updated_by"] = userID
|
||||
fields["updated_at"] = gtime.Now()
|
||||
_, err := tx.Ctx(ctx).Model(TableSimCardInfo).
|
||||
Where("iccid", iccid).
|
||||
Data(fields).
|
||||
Update()
|
||||
return gerror.Wrap(err, "update sim card failed")
|
||||
}
|
||||
|
||||
// InsertOrder 插入订单(返回 orderSn)。
|
||||
// InsertOrderItem 插入订单项。
|
||||
// UpdateOrderReturnStatus 更新订单退订状态。
|
||||
// UpdateOrderItemExtra 合并并写入订单项 extraSkuData(JSON 字符串)。
|
||||
// ActiveBindCountByOrderCodes 统计订单当前未作废的订购数。
|
||||
func (r *Repository) ActiveBindCountByOrderCodes(ctx context.Context, tx gdb.TX, orderSNs []string) (map[string]int, error) {
|
||||
out := make(map[string]int, len(orderSNs))
|
||||
uniq := uniqueStrings(orderSNs)
|
||||
if len(uniq) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
type row struct {
|
||||
OrderCode string `orm:"order_code"`
|
||||
Count int `orm:"count"`
|
||||
}
|
||||
var rows []row
|
||||
if err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
Fields("order_code", "COUNT(1) AS count").
|
||||
WhereIn("order_code", uniq).
|
||||
Group("order_code").
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "count active binds failed")
|
||||
}
|
||||
for _, r := range rows {
|
||||
out[strings.TrimSpace(r.OrderCode)] = r.Count
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InsertBind 插入新的订购记录。
|
||||
func (r *Repository) InsertBind(ctx context.Context, tx gdb.TX, bind *SimCardBindPkg) error {
|
||||
_, err := tx.Ctx(ctx).Model(TableSimCardBindPkg).Data(bind).Insert()
|
||||
return gerror.Wrap(err, "insert bind failed")
|
||||
}
|
||||
|
||||
// InsertSysOss 插入 OSS 记录,返回 ID。
|
||||
func (r *Repository) InsertSysOss(ctx context.Context, tx gdb.TX, row *SysOssRow) (int64, error) {
|
||||
id, err := tx.Ctx(ctx).Model(TableSysOss).Data(row).InsertAndGetId()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "insert sys oss failed")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// InsertBatchDownload 插入 t_resource_batch_download 记录,返回 ID。
|
||||
func (r *Repository) InsertBatchDownload(ctx context.Context, tx gdb.TX, row *ResourceBatchDownloadRow) (int64, error) {
|
||||
id, err := tx.Ctx(ctx).Model(TableTResourceBatchDl).Data(row).InsertAndGetId()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "insert batch download failed")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
const (
|
||||
TableSimCardInfo = "t_sim_card_info"
|
||||
TableSimCardBindPkg = "t_sim_card_bind_pkg"
|
||||
TableSimPoolBindPackage = "t_sim_pool_bind_package"
|
||||
TableSimBindPackageLogs = "t_sim_bind_package_logs"
|
||||
TableOrder = "t_order"
|
||||
TableOrderItem = "t_order_item"
|
||||
TableSimFlowPackageInfo = "t_sim_flow_package_info"
|
||||
TableSimFlowPackagePrice = "t_sim_flow_package_price"
|
||||
TableSysDept = "sys_dept"
|
||||
TableBatchMain = "batch_main"
|
||||
TableBatchDetail = "batch_detail"
|
||||
TableAgentBalance = "t_agent_balance"
|
||||
TableBalanceDetail = "t_balance_detail"
|
||||
TableSysOss = "sys_oss"
|
||||
TableTResourceBatchDl = "t_resource_batch_download"
|
||||
)
|
||||
|
||||
// typed row structs(worker 端未生成 DAO/DO/entity,故使用本地 typed struct)。
|
||||
type SimCardInfo struct {
|
||||
Id int64 `orm:"id"`
|
||||
Iccid string `orm:"iccid"`
|
||||
Msisdn string `orm:"msisdn"`
|
||||
Imsi string `orm:"imsi"`
|
||||
RegionCode string `orm:"region_code"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
AllocationBatchNo string `orm:"allocation_batch_no"`
|
||||
CurrentPackageCode string `orm:"current_package_code"`
|
||||
PackageStatus int `orm:"package_status"`
|
||||
ChanKey string `orm:"chan_key"`
|
||||
OutboundBatchNo string `orm:"outbound_batch_no"`
|
||||
InboundBatchNo string `orm:"inbound_batch_no"`
|
||||
FlowPkgBeginAt *gtime.Time `orm:"flow_pkg_begin_at"`
|
||||
FlowPkgPresentAt *gtime.Time `orm:"flow_pkg_present_at"`
|
||||
FlowPkgRealPresent *gtime.Time `orm:"flow_pkg_real_present_at"`
|
||||
FlowPkgEndAt *gtime.Time `orm:"flow_pkg_end_at"`
|
||||
DataServiceStatus int `orm:"data_service_status"`
|
||||
PackageTotalFlowMb float64 `orm:"package_total_flow_mb"`
|
||||
UsedFlowMb float64 `orm:"used_flow_mb"`
|
||||
TrafficCutoffEn int `orm:"traffic_cutoff_status"`
|
||||
TrafficCutoffThr float64 `orm:"traffic_cutoff_threshold"`
|
||||
SmsServiceStatus int `orm:"sms_service_status"`
|
||||
PackageTotalSms int `orm:"package_total_sms"`
|
||||
UsedSms int `orm:"used_sms"`
|
||||
VoiceServiceStatus int `orm:"voice_service_status"`
|
||||
PackageTotalVoice int `orm:"package_total_voice"`
|
||||
UsedVoice int `orm:"used_voice"`
|
||||
RoamServiceStatus int `orm:"roam_service_status"`
|
||||
PkgTotalRoamFlowMb float64 `orm:"package_total_roam_flow_mb"`
|
||||
UsedRoamFlowMb float64 `orm:"used_roam_flow_mb"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type SimCardBindPkg struct {
|
||||
Id int64 `orm:"id"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
Iccid string `orm:"iccid"`
|
||||
Msisdn string `orm:"msisdn"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
PackageType int `orm:"package_type"`
|
||||
PackageCode string `orm:"package_code"`
|
||||
PreviousPackageCode string `orm:"previous_package_code"`
|
||||
BusinessType int `orm:"business_type"`
|
||||
EffectiveStatus int `orm:"effective_status"`
|
||||
HandleTime *gtime.Time `orm:"handle_time"`
|
||||
EffectiveTime *gtime.Time `orm:"effective_time"`
|
||||
ExpectedExpireTime *gtime.Time `orm:"expected_expire_time"`
|
||||
ActualExpireTime *gtime.Time `orm:"actual_expire_time"`
|
||||
DurationMonths int `orm:"duration_months"`
|
||||
GiftDurationMonths int `orm:"gift_duration_months"`
|
||||
OrderCode string `orm:"order_code"`
|
||||
SubscriptionNo string `orm:"subscription_no"`
|
||||
Remark string `orm:"remark"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type SimBindPackageLogs struct {
|
||||
Id int64 `orm:"id"`
|
||||
SubscriptionNo string `orm:"subscription_no"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
Iccid string `orm:"iccid"`
|
||||
Msisdn string `orm:"msisdn"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
PackageType int `orm:"package_type"`
|
||||
PackageCode string `orm:"package_code"`
|
||||
PreviousPackageCod string `orm:"previous_package_code"`
|
||||
BusinessType int `orm:"business_type"`
|
||||
EffectiveStatus int `orm:"effective_status"`
|
||||
HandleTime *gtime.Time `orm:"handle_time"`
|
||||
EffectiveTime *gtime.Time `orm:"effective_time"`
|
||||
ExpectedExpireTime *gtime.Time `orm:"expected_expire_time"`
|
||||
ActualExpireTime *gtime.Time `orm:"actual_expire_time"`
|
||||
DurationMonths int `orm:"duration_months"`
|
||||
GiftDurationMonths int `orm:"gift_duration_months"`
|
||||
OrderCode string `orm:"order_code"`
|
||||
Remark string `orm:"remark"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type FlowPackageInfo struct {
|
||||
Id int64 `orm:"id"`
|
||||
PackageCode string `orm:"package_code"`
|
||||
PoolPackageCode int64 `orm:"pool_package_code"`
|
||||
PackageName string `orm:"package_name"`
|
||||
PackageType int `orm:"package_type"`
|
||||
BillingMethod int `orm:"billing_method"`
|
||||
FlowMb float64 `orm:"flow_mb"`
|
||||
CostPrice float64 `orm:"cost_price"`
|
||||
SysDefaultRatio float64 `orm:"sys_default_ratio"`
|
||||
SysDefaultProfit float64 `orm:"sys_default_profit"`
|
||||
RenewableDurationConfig *gjson.Json `orm:"renewable_duration_config"`
|
||||
RenewablePackageCodes *gjson.Json `orm:"renewable_package_codes"`
|
||||
ChangeablePackageCodes *gjson.Json `orm:"changeable_package_codes"`
|
||||
TopupPackageCodes *gjson.Json `orm:"topup_package_codes"`
|
||||
Enabled int `orm:"enabled"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
}
|
||||
|
||||
type PoolBindPackageLogs struct {
|
||||
Id int64 `orm:"id"`
|
||||
SubscriptionNo string `orm:"subscription_no"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
PoolNo string `orm:"pool_no"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
PackageType int `orm:"package_type"`
|
||||
PackageCode string `orm:"package_code"`
|
||||
PreviousPackageCode string `orm:"previous_package_code"`
|
||||
BusinessType int `orm:"business_type"`
|
||||
EffectiveStatus int `orm:"effective_status"`
|
||||
HandleTime *gtime.Time `orm:"handle_time"`
|
||||
EffectiveTime *gtime.Time `orm:"effective_time"`
|
||||
ExpectedExpireTime *gtime.Time `orm:"expected_expire_time"`
|
||||
ActualExpireTime *gtime.Time `orm:"actual_expire_time"`
|
||||
DurationMonths int `orm:"duration_months"`
|
||||
GiftDurationMonths int `orm:"gift_duration_months"`
|
||||
OrderCode string `orm:"order_code"`
|
||||
Remark string `orm:"remark"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type FlowPackagePrice struct {
|
||||
Id int64 `orm:"id"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
AgentScopeType int `orm:"agent_scope_type"`
|
||||
TargetAgentId int64 `orm:"target_agent_id"`
|
||||
PackageCode string `orm:"package_code"`
|
||||
AgentCostRatio float64 `orm:"agent_cost_ratio"`
|
||||
AgentCostProfit float64 `orm:"agent_cost_profit"`
|
||||
}
|
||||
|
||||
type SysDeptRow struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
ParentId int64 `orm:"parent_id"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
}
|
||||
|
||||
type BatchMainRow struct {
|
||||
Id int64 `orm:"id"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
OrderSn string `orm:"order_sn"`
|
||||
BatchType int `orm:"batch_type"`
|
||||
BatchStatus int `orm:"batch_status"`
|
||||
BatchAsyncStatus int `orm:"batch_async_status"`
|
||||
SourceType int `orm:"source_type"`
|
||||
SourceBatchNo string `orm:"source_batch_no"`
|
||||
IsCrossDept int `orm:"is_cross_dept"`
|
||||
TargetDeptId int64 `orm:"target_dept_id"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
TotalCount int `orm:"total_count"`
|
||||
ValidCount int `orm:"valid_count"`
|
||||
SuccessCount int `orm:"success_count"`
|
||||
FailCount int `orm:"fail_count"`
|
||||
RunResult int `orm:"run_result"`
|
||||
FailReason string `orm:"fail_reason"`
|
||||
BatchInfo *gjson.Json `orm:"batch_info"`
|
||||
StartTime *gtime.Time `orm:"start_time"`
|
||||
EndTime *gtime.Time `orm:"end_time"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type BatchDetailRow struct {
|
||||
Id int64 `orm:"id"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
BatchType int `orm:"batch_type"`
|
||||
DetailStatus int `orm:"detail_status"`
|
||||
BlankOutStatus int `orm:"blank_out_status"`
|
||||
RowNo int `orm:"row_no"`
|
||||
ItemId string `orm:"item_id"`
|
||||
ErrorCode string `orm:"error_code"`
|
||||
ErrorReason string `orm:"error_reason"`
|
||||
Snapshot *gjson.Json `orm:"snapshot"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type SysOssRow struct {
|
||||
Id int64 `orm:"id"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
FileName string `orm:"file_name"`
|
||||
OriginalName string `orm:"original_name"`
|
||||
FileSuffix string `orm:"file_suffix"`
|
||||
Path string `orm:"path"`
|
||||
Url string `orm:"url"`
|
||||
Service string `orm:"service"`
|
||||
Md5 string `orm:"md5"`
|
||||
FileSize int64 `orm:"file_size"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
}
|
||||
|
||||
type ResourceBatchDownloadRow struct {
|
||||
Id int64 `orm:"id"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
TaskName string `orm:"task_name"`
|
||||
ResourceType int `orm:"resource_type"`
|
||||
ExportScene int `orm:"export_scene"`
|
||||
TotalCount int `orm:"total_count"`
|
||||
SuccessCount int `orm:"success_count"`
|
||||
FailCount int `orm:"fail_count"`
|
||||
DownloadStatus int `orm:"download_status"`
|
||||
OssId int64 `orm:"oss_id"`
|
||||
FileName string `orm:"file_name"`
|
||||
FileExt string `orm:"file_ext"`
|
||||
FileUrl string `orm:"file_url"`
|
||||
FilePath string `orm:"file_path"`
|
||||
FileSize int64 `orm:"file_size"`
|
||||
FileMd5 string `orm:"file_md5"`
|
||||
DownloadCount int `orm:"download_count"`
|
||||
MaxDownloadCount int `orm:"max_download_count"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
StartTime *gtime.Time `orm:"start_time"`
|
||||
FinishTime *gtime.Time `orm:"finish_time"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
}
|
||||
|
||||
// DB 默认从 g.DB() 取,避免和现有 repository 紧耦合。
|
||||
func DB() gdb.DB {
|
||||
return db()
|
||||
}
|
||||
|
||||
func db() gdb.DB {
|
||||
return g.DB()
|
||||
}
|
||||
|
||||
func uniqueStrings(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, v := range in {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RoundAmount 四舍五入到分(导出以便 service 层复用)。
|
||||
func RoundAmount(value float64) float64 {
|
||||
return roundAmount(value)
|
||||
}
|
||||
|
||||
func roundAmount(value float64) float64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(int(value*100+0.5)) / 100
|
||||
}
|
||||
|
||||
// RenewableDurationOptions 解析套餐的可续费时长配置。
|
||||
func RenewableDurationOptions(jsonValue *gjson.Json) ([]int, error) {
|
||||
return renewableDurationOptions(jsonValue)
|
||||
}
|
||||
|
||||
func renewableDurationOptions(jsonValue *gjson.Json) ([]int, error) {
|
||||
if jsonValue == nil || jsonValue.IsNil() {
|
||||
return nil, errRenewDurationEmpty
|
||||
}
|
||||
values := jsonValue.Interfaces()
|
||||
out := make([]int, 0, len(values))
|
||||
seen := make(map[int]struct{}, len(values))
|
||||
for _, raw := range values {
|
||||
var month int
|
||||
switch v := raw.(type) {
|
||||
case int:
|
||||
month = v
|
||||
case int32:
|
||||
month = int(v)
|
||||
case int64:
|
||||
month = int(v)
|
||||
case float32:
|
||||
month = int(v)
|
||||
case float64:
|
||||
month = int(v)
|
||||
case json.Number:
|
||||
n, err := v.Int64()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
month = int(n)
|
||||
case string:
|
||||
if t, err := time.Parse("2006-01-02", strings.TrimSpace(v)); err == nil {
|
||||
// tolerate date strings, fall back to ignoring
|
||||
_ = t
|
||||
}
|
||||
continue
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if month <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[month]; ok {
|
||||
continue
|
||||
}
|
||||
seen[month] = struct{}{}
|
||||
out = append(out, month)
|
||||
}
|
||||
sort.Ints(out)
|
||||
if len(out) == 0 {
|
||||
return nil, errRenewDurationEmpty
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ContainsInt 检查切片中是否包含目标值。
|
||||
func ContainsInt(values []int, target int) bool {
|
||||
return containsInt(values, target)
|
||||
}
|
||||
|
||||
func containsInt(values []int, target int) bool {
|
||||
for _, v := range values {
|
||||
if v == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
)
|
||||
|
||||
func TestRenewableDurationOptionsAcceptsJsonNumber(t *testing.T) {
|
||||
got, err := RenewableDurationOptions(gjson.New([]any{
|
||||
json.Number("3"),
|
||||
json.Number("1"),
|
||||
json.Number("2"),
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("RenewableDurationOptions returned error: %v", err)
|
||||
}
|
||||
|
||||
want := []int{1, 2, 3}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("options = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("options = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package simstatus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
)
|
||||
|
||||
// Result is the database projection of a successful status query.
|
||||
type Result struct {
|
||||
CardID int64
|
||||
CardStatus int
|
||||
}
|
||||
|
||||
type Repository struct{}
|
||||
|
||||
func NewRepository() *Repository { return &Repository{} }
|
||||
|
||||
// ApplyBatch updates all successful results in one SQL statement. The status
|
||||
// timestamp is written only for cards whose upstream query succeeded.
|
||||
func (r *Repository) ApplyBatch(ctx context.Context, results []Result) error {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]int64, 0, len(results))
|
||||
var statement strings.Builder
|
||||
statement.WriteString("CASE " + dao.TSimCardInfo.Columns().Id + " ")
|
||||
for _, result := range results {
|
||||
if result.CardID <= 0 {
|
||||
return fmt.Errorf("status result has invalid cardId %d", result.CardID)
|
||||
}
|
||||
fmt.Fprintf(&statement, "WHEN %d THEN %d ", result.CardID, result.CardStatus)
|
||||
ids = append(ids, result.CardID)
|
||||
}
|
||||
statement.WriteString("END")
|
||||
_, err := dao.TSimCardInfo.Ctx(ctx).
|
||||
WhereIn(dao.TSimCardInfo.Columns().Id, ids).
|
||||
Data(do.TSimCardInfo{
|
||||
BossCardStatus: gdb.Raw(statement.String()),
|
||||
StatusSyncTime: gtime.Now(),
|
||||
}).Update()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
package trafficcutoff
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/batchresult"
|
||||
trafficcutoff "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/trafficcutoff"
|
||||
)
|
||||
|
||||
const (
|
||||
resultFileStatusSuccess = "success"
|
||||
resultFileStatusFailed = "failed"
|
||||
|
||||
validationFailureReason = "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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package unsub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
unsub "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/unsub"
|
||||
)
|
||||
|
||||
const (
|
||||
agentBalanceTable = "t_agent_balance"
|
||||
balanceDetailTable = "t_balance_detail"
|
||||
|
||||
balanceAccountStatusNormal = 0
|
||||
financeIncomeExpenseIncome = 0
|
||||
financeTransactionTypeConsume = 1
|
||||
financeTransactionChannelBalance = 3
|
||||
financeTransactionStatusPaid = 1
|
||||
|
||||
refundStatusRefunded = "refunded"
|
||||
refundStatusNoRefund = "no_refund"
|
||||
refundStatusSkipped = "skipped"
|
||||
)
|
||||
|
||||
type balanceAccountQuery struct {
|
||||
Id int64 `orm:"id"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
Balance float64 `orm:"balance"`
|
||||
Status int `orm:"status"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
}
|
||||
|
||||
type balanceDetailQuery struct {
|
||||
Id int64 `orm:"id"`
|
||||
IncomeExpenseType int `orm:"income_expense_type"`
|
||||
TransactionStatus int `orm:"transaction_status"`
|
||||
Amount float64 `orm:"amount"`
|
||||
}
|
||||
|
||||
func unsubscribeTime(task *unsub.Task, fallback *gtime.Time) *gtime.Time {
|
||||
if task != nil && task.Batch.StartTime != nil {
|
||||
return task.Batch.StartTime
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (r *Repository) lockOrdersByOrderCodes(ctx context.Context, tx gdb.TX, tenantID string, orderCounts map[string]int) (map[string]*orderQuery, error) {
|
||||
result := make(map[string]*orderQuery)
|
||||
orderCodes := sortedOrderCodes(orderCounts)
|
||||
if len(orderCodes) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []*orderQuery
|
||||
model := r.applyLock(tx.Ctx(ctx).Model(orderTable)).
|
||||
WhereIn("order_sn", orderCodes).
|
||||
Fields("id", "order_sn", "customer_agent_id", "pay_type", "pay_status", "pay_order_sn", "total_amount", "paid_amount", "balance", "unrefund_amount", "tenant_id", "created_dept")
|
||||
if strings.TrimSpace(tenantID) != "" {
|
||||
model = model.Where("tenant_id", tenantID)
|
||||
}
|
||||
if err := model.Scan(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row != nil && strings.TrimSpace(row.OrderSn) != "" {
|
||||
result[strings.TrimSpace(row.OrderSn)] = row
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) lockOrderItemsByOrderCodes(ctx context.Context, tx gdb.TX, tenantID string, orderCounts map[string]int) (map[string]*orderItemQuery, error) {
|
||||
result := make(map[string]*orderItemQuery)
|
||||
orderCodes := sortedOrderCodes(orderCounts)
|
||||
if len(orderCodes) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []*orderItemQuery
|
||||
model := r.applyLock(tx.Ctx(ctx).Model(orderItemTable)).
|
||||
WhereIn("order_sn", orderCodes).
|
||||
Fields("item_id", "order_sn", "price", "quantity", "service_begin_time", "service_end_time", "extra_sku_data")
|
||||
if strings.TrimSpace(tenantID) != "" {
|
||||
model = model.Where("tenant_id", tenantID)
|
||||
}
|
||||
if err := model.Scan(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row == nil || strings.TrimSpace(row.OrderSn) == "" {
|
||||
continue
|
||||
}
|
||||
orderCode := strings.TrimSpace(row.OrderSn)
|
||||
if _, exists := result[orderCode]; !exists {
|
||||
result[orderCode] = row
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) refundOrders(
|
||||
ctx context.Context,
|
||||
tx gdb.TX,
|
||||
task *unsub.Task,
|
||||
orderCounts map[string]int,
|
||||
bindsByIccid map[string]*bindPkgQuery,
|
||||
orders map[string]*orderQuery,
|
||||
orderItems map[string]*orderItemQuery,
|
||||
activeCounts map[string]int,
|
||||
cutoffTime, transactionTime *gtime.Time,
|
||||
) (map[string]*unsub.OrderRefund, error) {
|
||||
if cutoffTime == nil {
|
||||
cutoffTime = transactionTime
|
||||
}
|
||||
if cutoffTime == nil {
|
||||
return nil, gerror.New("unsub refund cutoff time is empty")
|
||||
}
|
||||
result := make(map[string]*unsub.OrderRefund, len(orderCounts))
|
||||
orderBinds := make(map[string][]*bindPkgQuery, len(orderCounts))
|
||||
for _, detail := range task.Details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
bind := bindsByIccid[detail.ICCID]
|
||||
if bind == nil {
|
||||
continue
|
||||
}
|
||||
orderCode := strings.TrimSpace(bind.OrderCode)
|
||||
if orderCode != "" {
|
||||
orderBinds[orderCode] = append(orderBinds[orderCode], bind)
|
||||
}
|
||||
}
|
||||
|
||||
for _, orderCode := range sortedOrderCodes(orderCounts) {
|
||||
refund := &unsub.OrderRefund{Status: refundStatusNoRefund}
|
||||
result[orderCode] = refund
|
||||
order := orders[orderCode]
|
||||
if order == nil {
|
||||
refund.Status = refundStatusSkipped
|
||||
refund.Reason = "order not found"
|
||||
g.Log().Warningf(ctx, "unsub balance refund skipped, order not found, batchNo=%s orderNo=%s", task.Batch.BatchNo, orderCode)
|
||||
continue
|
||||
}
|
||||
if order.PayType != consts.PayTypeBalance {
|
||||
refund.Status = refundStatusSkipped
|
||||
refund.Reason = "order is not paid by balance"
|
||||
g.Log().Warningf(ctx, "unsub balance refund skipped, order is not paid by balance, batchNo=%s orderNo=%s payType=%d", task.Batch.BatchNo, orderCode, order.PayType)
|
||||
continue
|
||||
}
|
||||
if order.PayStatus != 0 && order.PayStatus != consts.PayStatusPaid {
|
||||
refund.Status = refundStatusSkipped
|
||||
refund.Reason = "order is not paid"
|
||||
g.Log().Warningf(ctx, "unsub balance refund skipped, order is not paid, batchNo=%s orderNo=%s payStatus=%d", task.Batch.BatchNo, orderCode, order.PayStatus)
|
||||
continue
|
||||
}
|
||||
|
||||
binds := orderBinds[orderCode]
|
||||
if len(binds) == 0 {
|
||||
continue
|
||||
}
|
||||
item := orderItems[orderCode]
|
||||
totalQuantity := resolveOrderQuantity(order, item, activeCounts[orderCode])
|
||||
if totalQuantity <= 0 {
|
||||
return nil, gerror.Newf("order %s lacks a valid subscription quantity for refund", orderCode)
|
||||
}
|
||||
paidAmount := orderPaidAmount(order, item, totalQuantity)
|
||||
if paidAmount <= 0 {
|
||||
refund.Reason = "order paid amount is zero"
|
||||
continue
|
||||
}
|
||||
unitPaidAmount := paidAmount / float64(totalQuantity)
|
||||
refundAmount := 0.0
|
||||
for _, bind := range binds {
|
||||
if bind == nil {
|
||||
continue
|
||||
}
|
||||
if bind.EffectiveTime == nil || bind.DurationMonths <= 0 {
|
||||
return nil, gerror.Newf("order %s has an invalid paid subscription period", orderCode)
|
||||
}
|
||||
refundAmount += unitPaidAmount * calculateRefundRatio(bind.EffectiveTime.Time, cutoffTime.Time, bind.DurationMonths)
|
||||
}
|
||||
refund.Amount = roundUnsubAmount(refundAmount)
|
||||
if refund.Amount <= 0 {
|
||||
refund.Reason = "paid subscription period has been fully used"
|
||||
continue
|
||||
}
|
||||
|
||||
refund.SerialNo = unsubscribeRefundSerialNo(task.Batch.BatchNo, orderCode)
|
||||
existing, err := r.lockRefundDetail(ctx, tx, task.Batch.TenantID, refund.SerialNo)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "query existing unsub refund detail")
|
||||
}
|
||||
if existing.Id != 0 {
|
||||
if existing.IncomeExpenseType != financeIncomeExpenseIncome || existing.TransactionStatus != financeTransactionStatusPaid {
|
||||
return nil, gerror.Newf("existing unsub refund detail is invalid, order %s", orderCode)
|
||||
}
|
||||
refund.Amount = roundUnsubAmount(existing.Amount)
|
||||
refund.Status = refundStatusRefunded
|
||||
refund.Reason = "refund detail already exists"
|
||||
continue
|
||||
}
|
||||
|
||||
payerDeptID := refundPayerDeptID(order, binds)
|
||||
if payerDeptID <= 0 {
|
||||
return nil, gerror.Newf("order %s lacks a balance payer department", orderCode)
|
||||
}
|
||||
tenantID := strings.TrimSpace(order.TenantId)
|
||||
if tenantID == "" {
|
||||
tenantID = strings.TrimSpace(task.Batch.TenantID)
|
||||
}
|
||||
if tenantID == "" {
|
||||
return nil, gerror.Newf("order %s lacks a tenant id for refund", orderCode)
|
||||
}
|
||||
var account balanceAccountQuery
|
||||
accountModel := r.applyLock(tx.Ctx(ctx).Model(agentBalanceTable)).
|
||||
Where("dept_id", payerDeptID).
|
||||
Where("tenant_id", tenantID).
|
||||
Fields("id", "dept_id", "balance", "status", "tenant_id")
|
||||
if err := accountModel.Scan(&account); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, gerror.Wrap(err, "query balance account for unsub refund")
|
||||
}
|
||||
if account.Id == 0 {
|
||||
return nil, gerror.Newf("balance account for order %s does not exist", orderCode)
|
||||
}
|
||||
if account.Status != balanceAccountStatusNormal {
|
||||
return nil, gerror.Newf("balance account for order %s is not available", orderCode)
|
||||
}
|
||||
before := roundUnsubAmount(account.Balance)
|
||||
after := roundUnsubAmount(before + refund.Amount)
|
||||
accountUpdate, err := tx.Ctx(ctx).Model(agentBalanceTable).
|
||||
Where("id", account.Id).
|
||||
Data(g.Map{
|
||||
"balance": after,
|
||||
"updated_by": task.Batch.CreatedBy,
|
||||
"updated_at": transactionTime,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "update balance account for unsub refund")
|
||||
}
|
||||
if affected, err := accountUpdate.RowsAffected(); err != nil {
|
||||
return nil, gerror.Wrap(err, "read balance account update result")
|
||||
} else if affected != 1 {
|
||||
return nil, gerror.Newf("balance account for order %s changed during refund", orderCode)
|
||||
}
|
||||
|
||||
createdDept := order.CreatedDept
|
||||
if createdDept <= 0 {
|
||||
createdDept = payerDeptID
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(balanceDetailTable).Data(g.Map{
|
||||
"dept_id": payerDeptID,
|
||||
"serial_no": refund.SerialNo,
|
||||
"transaction_time": transactionTime,
|
||||
"income_expense_type": financeIncomeExpenseIncome,
|
||||
"transaction_type": financeTransactionTypeConsume,
|
||||
"transaction_channel": financeTransactionChannelBalance,
|
||||
"transaction_status": financeTransactionStatusPaid,
|
||||
"amount": refund.Amount,
|
||||
"before_balance": before,
|
||||
"balance_after": after,
|
||||
"order_sn": orderCode,
|
||||
"remark": fmt.Sprintf("SIM card unsubscribe balance refund, batchNo=%s, orderNo=%s", task.Batch.BatchNo, orderCode),
|
||||
"tenant_id": tenantID,
|
||||
"created_dept": createdDept,
|
||||
"created_by": task.Batch.CreatedBy,
|
||||
"created_at": transactionTime,
|
||||
}).Insert(); err != nil {
|
||||
return nil, gerror.Wrap(err, "insert unsub refund detail")
|
||||
}
|
||||
refund.Status = refundStatusRefunded
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) lockRefundDetail(ctx context.Context, tx gdb.TX, tenantID, serialNo string) (*balanceDetailQuery, error) {
|
||||
var row balanceDetailQuery
|
||||
model := r.applyLock(tx.Ctx(ctx).Model(balanceDetailTable)).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("serial_no", serialNo).
|
||||
Fields("id", "income_expense_type", "transaction_status", "amount")
|
||||
if err := model.Scan(&row); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func sortedOrderCodes(orderCounts map[string]int) []string {
|
||||
orderCodes := make([]string, 0, len(orderCounts))
|
||||
for orderCode := range orderCounts {
|
||||
if strings.TrimSpace(orderCode) != "" {
|
||||
orderCodes = append(orderCodes, orderCode)
|
||||
}
|
||||
}
|
||||
sort.Strings(orderCodes)
|
||||
return orderCodes
|
||||
}
|
||||
|
||||
func resolveOrderQuantity(order *orderQuery, item *orderItemQuery, activeCount int) int {
|
||||
if item != nil && item.Quantity > 0 {
|
||||
return item.Quantity
|
||||
}
|
||||
paidAmount := orderPaidAmount(order, nil, 0)
|
||||
if item != nil && item.Price > 0 && paidAmount > 0 {
|
||||
quantity := int(math.Round(paidAmount / item.Price))
|
||||
if quantity > 0 {
|
||||
return quantity
|
||||
}
|
||||
}
|
||||
return activeCount
|
||||
}
|
||||
|
||||
func orderPaidAmount(order *orderQuery, item *orderItemQuery, quantity int) float64 {
|
||||
if order != nil {
|
||||
if order.Balance > 0 {
|
||||
return order.Balance
|
||||
}
|
||||
if order.PaidAmount > 0 {
|
||||
return order.PaidAmount
|
||||
}
|
||||
if order.TotalAmount > 0 {
|
||||
return order.TotalAmount
|
||||
}
|
||||
}
|
||||
if item != nil && item.Price > 0 && quantity > 0 {
|
||||
return item.Price * float64(quantity)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func refundPayerDeptID(order *orderQuery, binds []*bindPkgQuery) int64 {
|
||||
if order != nil && order.CustomerAgentId > 0 {
|
||||
return order.CustomerAgentId
|
||||
}
|
||||
for _, bind := range binds {
|
||||
if bind == nil {
|
||||
continue
|
||||
}
|
||||
if deptID, err := strconv.ParseInt(strings.TrimSpace(bind.DeptId), 10, 64); err == nil && deptID > 0 {
|
||||
return deptID
|
||||
}
|
||||
}
|
||||
if order != nil && order.CreatedDept > 0 {
|
||||
return order.CreatedDept
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func unsubscribeRefundSerialNo(batchNo, orderCode string) string {
|
||||
return fmt.Sprintf("UNSUBREFUND-%s-%s", strings.TrimSpace(batchNo), strings.TrimSpace(orderCode))
|
||||
}
|
||||
|
||||
func roundUnsubAmount(value float64) float64 {
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
func calculateRefundRatio(effectiveAt, cutoffAt time.Time, durationMonths int) float64 {
|
||||
if effectiveAt.IsZero() || cutoffAt.IsZero() || durationMonths <= 0 {
|
||||
return 0
|
||||
}
|
||||
paidEndAt := addRefundMonths(effectiveAt, durationMonths)
|
||||
if !cutoffAt.After(effectiveAt) {
|
||||
return 1
|
||||
}
|
||||
if !cutoffAt.Before(paidEndAt) {
|
||||
return 0
|
||||
}
|
||||
totalSeconds := paidEndAt.Sub(effectiveAt).Seconds()
|
||||
remainingSeconds := paidEndAt.Sub(cutoffAt).Seconds()
|
||||
if totalSeconds <= 0 || remainingSeconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
ratio := remainingSeconds / totalSeconds
|
||||
if ratio < 0 {
|
||||
return 0
|
||||
}
|
||||
if ratio > 1 {
|
||||
return 1
|
||||
}
|
||||
return ratio
|
||||
}
|
||||
|
||||
func addRefundMonths(value time.Time, months int) time.Time {
|
||||
if months <= 0 {
|
||||
return value
|
||||
}
|
||||
targetMonth := time.Date(value.Year(), value.Month()+time.Month(months), 1, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location())
|
||||
day := value.Day()
|
||||
lastDay := time.Date(targetMonth.Year(), targetMonth.Month()+1, 0, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location()).Day()
|
||||
if day > lastDay {
|
||||
day = lastDay
|
||||
}
|
||||
return time.Date(targetMonth.Year(), targetMonth.Month(), day, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location())
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package unsub
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCalculateRefundRatio(t *testing.T) {
|
||||
effective := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
cutoff time.Time
|
||||
want float64
|
||||
}{
|
||||
{name: "before effective refunds all", cutoff: effective.Add(-time.Hour), want: 1},
|
||||
{name: "half paid period", cutoff: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), want: 184.0 / 365.0},
|
||||
{name: "during gift period refunds none", cutoff: time.Date(2027, 2, 1, 0, 0, 0, 0, time.UTC), want: 0},
|
||||
{name: "after paid period refunds none", cutoff: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), want: 0},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := calculateRefundRatio(effective, testCase.cutoff, 12)
|
||||
if math.Abs(got-testCase.want) > 0.000001 {
|
||||
t.Fatalf("calculateRefundRatio() = %v, want %v", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRefundMonthsClampsMonthEnd(t *testing.T) {
|
||||
got := addRefundMonths(time.Date(2026, 1, 31, 12, 0, 0, 0, time.UTC), 1)
|
||||
want := time.Date(2026, 2, 28, 12, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("addRefundMonths() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,790 @@
|
||||
package unsub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
batchdomain "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/batch"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
unsub "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/service/unsub"
|
||||
)
|
||||
|
||||
const (
|
||||
testTenantID = "000000"
|
||||
testDeptID = int64(100)
|
||||
testPackage = "PKG-A"
|
||||
testSource = "OUT-1"
|
||||
testInbound = "IN-1"
|
||||
)
|
||||
|
||||
func newTestRepository(t *testing.T) *Repository {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
db, err := gdb.New(gdb.ConfigNode{
|
||||
Type: "sqlite",
|
||||
Link: "sqlite::@file(:memory:)",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlite database: %v", err)
|
||||
}
|
||||
statements := []string{
|
||||
`CREATE TABLE batch_main (
|
||||
source_channel TEXT NOT NULL DEFAULT 'legacy',
|
||||
execution_mode INTEGER NOT NULL DEFAULT 0,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_no TEXT NOT NULL,
|
||||
batch_type INTEGER NOT NULL,
|
||||
batch_async_status INTEGER NOT NULL,
|
||||
run_result INTEGER NOT NULL DEFAULT 0,
|
||||
fail_reason TEXT,
|
||||
start_time DATETIME,
|
||||
end_time DATETIME,
|
||||
dept_id INTEGER NOT NULL DEFAULT 0,
|
||||
source_batch_no TEXT,
|
||||
created_by INTEGER NOT NULL DEFAULT 0,
|
||||
created_dept INTEGER NOT NULL DEFAULT 0,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
batch_info TEXT
|
||||
)`,
|
||||
`CREATE TABLE batch_detail (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_no TEXT NOT NULL,
|
||||
batch_type INTEGER NOT NULL,
|
||||
detail_status INTEGER NOT NULL,
|
||||
error_reason TEXT,
|
||||
snapshot TEXT,
|
||||
item_id TEXT,
|
||||
row_no INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE t_sim_card_info (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
iccid TEXT NOT NULL,
|
||||
msisdn TEXT,
|
||||
imsi TEXT,
|
||||
imei TEXT,
|
||||
chan_key TEXT,
|
||||
dept_id INTEGER NOT NULL DEFAULT 0,
|
||||
created_dept INTEGER NOT NULL DEFAULT 0,
|
||||
current_package_code TEXT,
|
||||
package_status INTEGER NOT NULL DEFAULT 0,
|
||||
inbound_batch_no TEXT,
|
||||
outbound_batch_no TEXT,
|
||||
tenant_id TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`CREATE TABLE t_sim_card_bind_pkg (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subscription_no TEXT,
|
||||
batch_no TEXT,
|
||||
iccid TEXT,
|
||||
msisdn TEXT,
|
||||
dept_id TEXT,
|
||||
package_type INTEGER NOT NULL DEFAULT 0,
|
||||
package_code TEXT,
|
||||
previous_package_code TEXT,
|
||||
business_type INTEGER NOT NULL DEFAULT 0,
|
||||
effective_status INTEGER NOT NULL DEFAULT 0,
|
||||
handle_time DATETIME,
|
||||
effective_time DATETIME,
|
||||
expected_expire_time DATETIME,
|
||||
actual_expire_time DATETIME,
|
||||
duration_months INTEGER NOT NULL DEFAULT 0,
|
||||
gift_duration_months INTEGER NOT NULL DEFAULT 0,
|
||||
order_code TEXT,
|
||||
remark TEXT,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
created_dept INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE t_sim_bind_package_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subscription_no TEXT,
|
||||
batch_no TEXT,
|
||||
iccid TEXT,
|
||||
msisdn TEXT,
|
||||
dept_id TEXT,
|
||||
package_type INTEGER,
|
||||
package_code TEXT,
|
||||
previous_package_code TEXT,
|
||||
business_type INTEGER,
|
||||
effective_status INTEGER,
|
||||
handle_time DATETIME,
|
||||
effective_time DATETIME,
|
||||
expected_expire_time DATETIME,
|
||||
actual_expire_time DATETIME,
|
||||
duration_months INTEGER,
|
||||
gift_duration_months INTEGER,
|
||||
order_code TEXT,
|
||||
remark TEXT,
|
||||
tenant_id TEXT,
|
||||
created_dept INTEGER,
|
||||
created_by INTEGER,
|
||||
updated_by INTEGER
|
||||
)`,
|
||||
`CREATE TABLE t_sim_card_stock (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_no TEXT,
|
||||
iccid TEXT,
|
||||
msisdn TEXT,
|
||||
imsi TEXT,
|
||||
imei TEXT,
|
||||
chan_key TEXT,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
created_dept INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER NOT NULL DEFAULT 0,
|
||||
updated_by INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE t_order (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_sn TEXT,
|
||||
customer_agent_id INTEGER NOT NULL DEFAULT 0,
|
||||
pay_type INTEGER NOT NULL DEFAULT 0,
|
||||
pay_status INTEGER NOT NULL DEFAULT 0,
|
||||
pay_order_sn TEXT,
|
||||
total_amount REAL NOT NULL DEFAULT 0,
|
||||
paid_amount REAL NOT NULL DEFAULT 0,
|
||||
balance REAL NOT NULL DEFAULT 0,
|
||||
unrefund_amount REAL NOT NULL DEFAULT 0,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
created_dept INTEGER NOT NULL DEFAULT 0,
|
||||
return_status INTEGER NOT NULL DEFAULT 0,
|
||||
updated_by INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE t_order_item (
|
||||
item_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER,
|
||||
order_sn TEXT,
|
||||
price REAL NOT NULL DEFAULT 0,
|
||||
quantity INTEGER NOT NULL DEFAULT 0,
|
||||
service_begin_time DATETIME,
|
||||
service_end_time DATETIME,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
extra_sku_data TEXT,
|
||||
updated_by INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE t_agent_balance (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dept_id INTEGER NOT NULL DEFAULT 0,
|
||||
balance REAL NOT NULL DEFAULT 0,
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
tenant_id TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`CREATE TABLE t_balance_detail (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dept_id INTEGER NOT NULL DEFAULT 0,
|
||||
serial_no TEXT NOT NULL,
|
||||
transaction_time DATETIME,
|
||||
income_expense_type INTEGER NOT NULL DEFAULT 0,
|
||||
transaction_type INTEGER NOT NULL DEFAULT 0,
|
||||
transaction_channel INTEGER NOT NULL DEFAULT 0,
|
||||
transaction_status INTEGER NOT NULL DEFAULT 0,
|
||||
amount REAL NOT NULL DEFAULT 0,
|
||||
before_balance REAL NOT NULL DEFAULT 0,
|
||||
balance_after REAL NOT NULL DEFAULT 0,
|
||||
order_sn TEXT,
|
||||
remark TEXT,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
created_dept INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME
|
||||
)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err = db.Exec(ctx, statement); err != nil {
|
||||
t.Fatalf("create test schema: %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close(ctx) })
|
||||
return &Repository{db: db, lockEnabled: false}
|
||||
}
|
||||
|
||||
func seedProcessingBatch(t *testing.T, ctx context.Context, db gdb.DB, batchNo, sourceBatchNo string) {
|
||||
t.Helper()
|
||||
info := gjson.New(map[string]any{
|
||||
"sourceBatchNo": sourceBatchNo,
|
||||
})
|
||||
if _, err := db.Model(dao.BatchMain.Table()).Ctx(ctx).Data(do.BatchMain{
|
||||
BatchNo: batchNo,
|
||||
BatchType: consts.BatchTypeSimCardUnsub,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
|
||||
RunResult: batchdomain.RunResultRunning,
|
||||
DeptId: testDeptID,
|
||||
SourceBatchNo: sourceBatchNo,
|
||||
CreatedBy: 1,
|
||||
CreatedDept: 1,
|
||||
TenantId: testTenantID,
|
||||
BatchInfo: info,
|
||||
}).Insert(); err != nil {
|
||||
t.Fatalf("insert batch main: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedRunningDetail(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, rowNo int, iccid, msisdn, imsi, sourceBatchNo, reason string) int64 {
|
||||
t.Helper()
|
||||
snap := gjson.New(map[string]any{
|
||||
"ICCID": iccid,
|
||||
"MSISDN": msisdn,
|
||||
"IMSI": imsi,
|
||||
"authorized": true,
|
||||
"sourceBatchNo": sourceBatchNo,
|
||||
})
|
||||
id, err := db.Model(dao.BatchDetail.Table()).Ctx(ctx).Data(do.BatchDetail{
|
||||
BatchNo: batchNo,
|
||||
BatchType: consts.BatchTypeSimCardUnsub,
|
||||
DetailStatus: consts.BatchDetailStatusRunning,
|
||||
RowNo: rowNo,
|
||||
ItemId: iccid,
|
||||
Snapshot: snap,
|
||||
ErrorReason: reason,
|
||||
}).InsertAndGetId()
|
||||
if err != nil {
|
||||
t.Fatalf("insert batch detail: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func seedCard(t *testing.T, ctx context.Context, db gdb.DB, iccid, msisdn, imsi, packageCode string, deptID int64) {
|
||||
t.Helper()
|
||||
_, err := db.Model(cardInfoTable).Ctx(ctx).Data(do.TSimCardInfo{
|
||||
Iccid: iccid,
|
||||
Msisdn: msisdn,
|
||||
Imsi: imsi,
|
||||
DeptId: deptID,
|
||||
CreatedDept: deptID,
|
||||
CurrentPackageCode: packageCode,
|
||||
PackageStatus: pkgStatusEffective,
|
||||
InboundBatchNo: testInbound,
|
||||
OutboundBatchNo: testSource,
|
||||
TenantId: testTenantID,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
t.Fatalf("insert sim card: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedActiveBind(t *testing.T, ctx context.Context, db gdb.DB, iccid, packageCode, orderCode, sourceBatchNo string) {
|
||||
t.Helper()
|
||||
_, err := db.Model(cardBindPkgTable).Ctx(ctx).Data(map[string]any{
|
||||
"subscription_no": "SUB-" + iccid,
|
||||
"batch_no": sourceBatchNo,
|
||||
"iccid": iccid,
|
||||
"msisdn": "13" + iccid[len(iccid)-8:],
|
||||
"dept_id": "100",
|
||||
"package_type": 1,
|
||||
"package_code": packageCode,
|
||||
"business_type": 0,
|
||||
"effective_status": bindStatusEffective,
|
||||
"order_code": orderCode,
|
||||
"remark": "",
|
||||
"tenant_id": testTenantID,
|
||||
"created_dept": 1,
|
||||
"created_by": 1,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
t.Fatalf("insert bind: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedStock(t *testing.T, ctx context.Context, db gdb.DB, iccid string) {
|
||||
t.Helper()
|
||||
_, err := db.Model(stockTable).Ctx(ctx).Data(map[string]any{
|
||||
"iccid": iccid,
|
||||
"tenant_id": testTenantID,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
t.Fatalf("insert stock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedOrder(t *testing.T, ctx context.Context, db gdb.DB, orderCode string) {
|
||||
t.Helper()
|
||||
_, err := db.Model(orderTable).Ctx(ctx).Data(map[string]any{
|
||||
"order_sn": orderCode,
|
||||
"return_status": 0,
|
||||
"tenant_id": testTenantID,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
t.Fatalf("insert order: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedOrderItem(t *testing.T, ctx context.Context, db gdb.DB, orderCode, extra string) {
|
||||
t.Helper()
|
||||
_, err := db.Model(orderItemTable).Ctx(ctx).Data(map[string]any{
|
||||
"order_sn": orderCode,
|
||||
"tenant_id": testTenantID,
|
||||
"extra_sku_data": extra,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
t.Fatalf("insert order item: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTableCount(t *testing.T, ctx context.Context, db gdb.DB, table string, expected int, where string, args ...any) {
|
||||
t.Helper()
|
||||
model := db.Model(table).Ctx(ctx).Where(where, args...)
|
||||
count, err := model.Count()
|
||||
if err != nil {
|
||||
t.Fatalf("count %s: %v", table, err)
|
||||
}
|
||||
if count != expected {
|
||||
t.Fatalf("%s count = %d, want %d", table, count, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func assertBatchMainStatus(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, wantAsync, wantRun int) {
|
||||
t.Helper()
|
||||
var row struct {
|
||||
Async int `orm:"batch_async_status"`
|
||||
Run int `orm:"run_result"`
|
||||
}
|
||||
if err := db.Model(dao.BatchMain.Table()).Ctx(ctx).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Fields("batch_async_status", "run_result").
|
||||
Scan(&row); err != nil {
|
||||
t.Fatalf("query batch main: %v", err)
|
||||
}
|
||||
if row.Async != wantAsync || row.Run != wantRun {
|
||||
t.Fatalf("batch %s status = (async=%d, run=%d), want (async=%d, run=%d)", batchNo, row.Async, row.Run, wantAsync, wantRun)
|
||||
}
|
||||
}
|
||||
|
||||
func assertBatchDetailStatus(t *testing.T, ctx context.Context, db gdb.DB, batchNo string, want int) {
|
||||
t.Helper()
|
||||
var row struct {
|
||||
Status int `orm:"detail_status"`
|
||||
}
|
||||
if err := db.Model(dao.BatchDetail.Table()).Ctx(ctx).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Fields("detail_status").
|
||||
Scan(&row); err != nil {
|
||||
t.Fatalf("query batch detail: %v", err)
|
||||
}
|
||||
if row.Status != want {
|
||||
t.Fatalf("batch %s detail status = %d, want %d", batchNo, row.Status, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReadsTaskAndDetails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestRepository(t)
|
||||
batchNo := "UNS-1"
|
||||
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", testSource, "")
|
||||
|
||||
task, err := repo.Load(ctx, batchNo)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if task.Batch.BatchNo != batchNo {
|
||||
t.Fatalf("batch no = %q, want %q", task.Batch.BatchNo, batchNo)
|
||||
}
|
||||
if task.Batch.SourceBatchNo != testSource {
|
||||
t.Fatalf("source batch = %q, want %q", task.Batch.SourceBatchNo, testSource)
|
||||
}
|
||||
if len(task.Details) != 2 {
|
||||
t.Fatalf("details = %d, want 2", len(task.Details))
|
||||
}
|
||||
if task.Details[0].ICCID != "898600000000001" {
|
||||
t.Fatalf("first detail ICCID = %q", task.Details[0].ICCID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTreatsSelfBatchNoAsEmptySourceBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestRepository(t)
|
||||
batchNo := "UNS-1"
|
||||
seedProcessingBatch(t, ctx, repo.db, batchNo, batchNo)
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", "", "")
|
||||
|
||||
task, err := repo.Load(ctx, batchNo)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if task.Batch.SourceBatchNo != "" {
|
||||
t.Fatalf("source batch = %q, want empty when equals batchNo", task.Batch.SourceBatchNo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveValidationFailureMarksAllDetailsAndMainFailed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestRepository(t)
|
||||
batchNo := "UNS-1"
|
||||
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "snapshot has reason")
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", testSource, "")
|
||||
|
||||
task, err := repo.Load(ctx, batchNo)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if err = repo.SaveValidationFailure(ctx, task); err != nil {
|
||||
t.Fatalf("SaveValidationFailure() error = %v", err)
|
||||
}
|
||||
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed)
|
||||
assertBatchDetailStatus(t, ctx, repo.db, batchNo, consts.BatchDetailStatusFailed)
|
||||
assertTableCount(t, ctx, repo.db, cardInfoTable, 0, "iccid IN (?, ?)", "898600000000001", "898600000000002")
|
||||
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 0, "iccid IN (?, ?)", "898600000000001", "898600000000002")
|
||||
assertTableCount(t, ctx, repo.db, stockTable, 0, "1=1")
|
||||
}
|
||||
|
||||
func TestCommitBusinessHappyPath(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestRepository(t)
|
||||
batchNo := "UNS-1"
|
||||
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", testSource, "")
|
||||
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, testDeptID)
|
||||
seedCard(t, ctx, repo.db, "898600000000002", "13800000002", "460001000000002", testPackage, testDeptID)
|
||||
seedActiveBind(t, ctx, repo.db, "898600000000001", testPackage, "ORD-1", testSource)
|
||||
seedActiveBind(t, ctx, repo.db, "898600000000002", testPackage, "ORD-1", testSource)
|
||||
seedOrder(t, ctx, repo.db, "ORD-1")
|
||||
seedOrderItem(t, ctx, repo.db, "ORD-1", `{}`)
|
||||
|
||||
task, err := repo.Load(ctx, batchNo)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if err = repo.CommitBusiness(ctx, task); err != nil {
|
||||
t.Fatalf("CommitBusiness() error = %v", err)
|
||||
}
|
||||
|
||||
assertTableCount(t, ctx, repo.db, cardInfoTable, 0, "iccid IN (?, ?)", "898600000000001", "898600000000002")
|
||||
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 0, "iccid IN (?, ?)", "898600000000001", "898600000000002")
|
||||
assertTableCount(t, ctx, repo.db, bindPackageLogsTable, 2, "iccid IN (?, ?)", "898600000000001", "898600000000002")
|
||||
assertTableCount(t, ctx, repo.db, stockTable, 2, "iccid IN (?, ?)", "898600000000001", "898600000000002")
|
||||
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusSuccess, consts.BatchRunResultSuccess)
|
||||
assertBatchDetailStatus(t, ctx, repo.db, batchNo, consts.BatchDetailStatusSuccess)
|
||||
assertOrderReturnStatus(t, ctx, repo.db, "ORD-1", orderReturnStatusSuccess)
|
||||
assertOrderItemExtra(t, ctx, repo.db, "ORD-1", batchNo, 2)
|
||||
}
|
||||
|
||||
func TestCommitBusinessRefundsBalanceByPaidPeriodOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestRepository(t)
|
||||
batchNo := "UNS-REFUND-1"
|
||||
orderCode := "ORD-REFUND-1"
|
||||
effectiveAt := gtime.New("2026-01-01 00:00:00")
|
||||
cutoffAt := gtime.New("2026-07-01 00:00:00")
|
||||
|
||||
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
|
||||
if _, err := repo.db.Model(dao.BatchMain.Table()).Ctx(ctx).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Data(map[string]any{"start_time": cutoffAt}).Update(); err != nil {
|
||||
t.Fatalf("update batch start time: %v", err)
|
||||
}
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
|
||||
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, testDeptID)
|
||||
seedActiveBind(t, ctx, repo.db, "898600000000001", testPackage, orderCode, testSource)
|
||||
if _, err := repo.db.Model(cardBindPkgTable).Ctx(ctx).
|
||||
Where("order_code", orderCode).
|
||||
Data(map[string]any{
|
||||
"effective_time": effectiveAt,
|
||||
"expected_expire_time": gtime.New("2027-04-01 23:59:59"),
|
||||
"duration_months": 12,
|
||||
"gift_duration_months": 3,
|
||||
}).Update(); err != nil {
|
||||
t.Fatalf("update bind period: %v", err)
|
||||
}
|
||||
if _, err := repo.db.Model(orderTable).Ctx(ctx).Data(map[string]any{
|
||||
"order_sn": orderCode,
|
||||
"customer_agent_id": testDeptID,
|
||||
"pay_type": consts.PayTypeBalance,
|
||||
"pay_status": consts.PayStatusPaid,
|
||||
"pay_order_sn": "RENPAY-REFUND-1",
|
||||
"total_amount": 120,
|
||||
"paid_amount": 120,
|
||||
"balance": 120,
|
||||
"unrefund_amount": 120,
|
||||
"tenant_id": testTenantID,
|
||||
"created_dept": testDeptID,
|
||||
}).Insert(); err != nil {
|
||||
t.Fatalf("insert refund order: %v", err)
|
||||
}
|
||||
if _, err := repo.db.Model(orderItemTable).Ctx(ctx).Data(map[string]any{
|
||||
"order_sn": orderCode,
|
||||
"price": 120,
|
||||
"quantity": 1,
|
||||
"service_begin_time": effectiveAt,
|
||||
"service_end_time": gtime.New("2027-04-01 23:59:59"),
|
||||
"tenant_id": testTenantID,
|
||||
}).Insert(); err != nil {
|
||||
t.Fatalf("insert refund order item: %v", err)
|
||||
}
|
||||
if _, err := repo.db.Model(agentBalanceTable).Ctx(ctx).Data(map[string]any{
|
||||
"dept_id": testDeptID,
|
||||
"balance": 10,
|
||||
"status": 0,
|
||||
"tenant_id": testTenantID,
|
||||
}).Insert(); err != nil {
|
||||
t.Fatalf("insert refund account: %v", err)
|
||||
}
|
||||
|
||||
task, err := repo.Load(ctx, batchNo)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if err = repo.CommitBusiness(ctx, task); err != nil {
|
||||
t.Fatalf("CommitBusiness() error = %v", err)
|
||||
}
|
||||
|
||||
var account struct {
|
||||
Balance float64 `orm:"balance"`
|
||||
}
|
||||
if err = repo.db.Model(agentBalanceTable).Ctx(ctx).Where("dept_id", testDeptID).Scan(&account); err != nil {
|
||||
t.Fatalf("query refund account: %v", err)
|
||||
}
|
||||
if want := 70.49; math.Abs(account.Balance-want) > 0.01 {
|
||||
t.Fatalf("refund balance = %.2f, want %.2f", account.Balance, want)
|
||||
}
|
||||
assertTableCount(t, ctx, repo.db, balanceDetailTable, 1, "serial_no = ?", unsubscribeRefundSerialNo(batchNo, orderCode))
|
||||
assertOrderReturnStatus(t, ctx, repo.db, orderCode, orderReturnStatusSuccess)
|
||||
}
|
||||
|
||||
func TestCommitBusinessFailsOnNoActiveBind(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestRepository(t)
|
||||
batchNo := "UNS-1"
|
||||
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
|
||||
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, testDeptID)
|
||||
|
||||
task, err := repo.Load(ctx, batchNo)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if err = repo.CommitBusiness(ctx, task); err != nil {
|
||||
t.Fatalf("CommitBusiness() returned error = %v, want nil (validation failure handled internally)", err)
|
||||
}
|
||||
assertTableCount(t, ctx, repo.db, cardInfoTable, 1, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 0, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, bindPackageLogsTable, 0, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, stockTable, 0, "iccid = ?", "898600000000001")
|
||||
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed)
|
||||
assertBatchDetailStatus(t, ctx, repo.db, batchNo, consts.BatchDetailStatusFailed)
|
||||
}
|
||||
|
||||
func TestCommitBusinessFailsOnPackageMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestRepository(t)
|
||||
batchNo := "UNS-1"
|
||||
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
|
||||
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, testDeptID)
|
||||
seedActiveBind(t, ctx, repo.db, "898600000000001", "PKG-OTHER", "ORD-1", testSource)
|
||||
|
||||
task, err := repo.Load(ctx, batchNo)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if err = repo.CommitBusiness(ctx, task); err != nil {
|
||||
t.Fatalf("CommitBusiness() returned error = %v, want nil", err)
|
||||
}
|
||||
assertTableCount(t, ctx, repo.db, cardInfoTable, 1, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 1, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, bindPackageLogsTable, 0, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, stockTable, 0, "iccid = ?", "898600000000001")
|
||||
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed)
|
||||
}
|
||||
|
||||
func TestCommitBusinessFailsOnDeptMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestRepository(t)
|
||||
batchNo := "UNS-1"
|
||||
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
|
||||
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, 999)
|
||||
seedActiveBind(t, ctx, repo.db, "898600000000001", testPackage, "ORD-1", testSource)
|
||||
|
||||
task, err := repo.Load(ctx, batchNo)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if err = repo.CommitBusiness(ctx, task); err != nil {
|
||||
t.Fatalf("CommitBusiness() returned error = %v, want nil", err)
|
||||
}
|
||||
assertTableCount(t, ctx, repo.db, cardInfoTable, 1, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 1, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, bindPackageLogsTable, 0, "iccid = ?", "898600000000001")
|
||||
}
|
||||
|
||||
func TestCommitBusinessFailsOnSourceBatchMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestRepository(t)
|
||||
batchNo := "UNS-1"
|
||||
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
|
||||
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, testDeptID)
|
||||
seedActiveBind(t, ctx, repo.db, "898600000000001", testPackage, "ORD-1", "OUT-OTHER")
|
||||
|
||||
task, err := repo.Load(ctx, batchNo)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if err = repo.CommitBusiness(ctx, task); err != nil {
|
||||
t.Fatalf("CommitBusiness() returned error = %v, want nil", err)
|
||||
}
|
||||
assertTableCount(t, ctx, repo.db, cardInfoTable, 1, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 1, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, stockTable, 0, "iccid = ?", "898600000000001")
|
||||
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed)
|
||||
}
|
||||
|
||||
func TestCommitBusinessFailsOnExistingStock(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestRepository(t)
|
||||
batchNo := "UNS-1"
|
||||
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
|
||||
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, testDeptID)
|
||||
seedActiveBind(t, ctx, repo.db, "898600000000001", testPackage, "ORD-1", testSource)
|
||||
seedStock(t, ctx, repo.db, "898600000000001")
|
||||
|
||||
task, err := repo.Load(ctx, batchNo)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if err = repo.CommitBusiness(ctx, task); err != nil {
|
||||
t.Fatalf("CommitBusiness() returned error = %v, want nil", err)
|
||||
}
|
||||
assertTableCount(t, ctx, repo.db, cardInfoTable, 1, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 1, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, stockTable, 1, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, bindPackageLogsTable, 0, "iccid = ?", "898600000000001")
|
||||
}
|
||||
|
||||
func TestCommitBusinessFailsOnMultiplePackagesInBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestRepository(t)
|
||||
batchNo := "UNS-1"
|
||||
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 2, "898600000000002", "13800000002", "460001000000002", testSource, "")
|
||||
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", "PKG-A", testDeptID)
|
||||
seedCard(t, ctx, repo.db, "898600000000002", "13800000002", "460001000000002", "PKG-B", testDeptID)
|
||||
seedActiveBind(t, ctx, repo.db, "898600000000001", "PKG-A", "ORD-1", testSource)
|
||||
seedActiveBind(t, ctx, repo.db, "898600000000002", "PKG-B", "ORD-2", testSource)
|
||||
|
||||
task, err := repo.Load(ctx, batchNo)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if err = repo.CommitBusiness(ctx, task); err != nil {
|
||||
t.Fatalf("CommitBusiness() returned error = %v, want nil", err)
|
||||
}
|
||||
assertTableCount(t, ctx, repo.db, cardInfoTable, 2, "iccid IN (?, ?)", "898600000000001", "898600000000002")
|
||||
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 2, "iccid IN (?, ?)", "898600000000001", "898600000000002")
|
||||
assertTableCount(t, ctx, repo.db, bindPackageLogsTable, 0, "1=1")
|
||||
assertTableCount(t, ctx, repo.db, stockTable, 0, "1=1")
|
||||
assertBatchMainStatus(t, ctx, repo.db, batchNo, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed)
|
||||
}
|
||||
|
||||
func TestCommitBusinessRollsBackOnOrderUpdateFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestRepository(t)
|
||||
batchNo := "UNS-1"
|
||||
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
|
||||
seedCard(t, ctx, repo.db, "898600000000001", "13800000001", "460001000000001", testPackage, testDeptID)
|
||||
seedActiveBind(t, ctx, repo.db, "898600000000001", testPackage, "ORD-1", testSource)
|
||||
seedOrder(t, ctx, repo.db, "ORD-1")
|
||||
seedOrderItem(t, ctx, repo.db, "ORD-1", `{}`)
|
||||
|
||||
if _, err := repo.db.Exec(ctx, "DROP TABLE t_order"); err != nil {
|
||||
t.Fatalf("drop order table: %v", err)
|
||||
}
|
||||
|
||||
task, err := repo.Load(ctx, batchNo)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if err = repo.CommitBusiness(ctx, task); err == nil {
|
||||
t.Fatal("expected CommitBusiness to fail when order table is missing")
|
||||
}
|
||||
assertTableCount(t, ctx, repo.db, cardInfoTable, 1, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, cardBindPkgTable, 1, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, bindPackageLogsTable, 0, "iccid = ?", "898600000000001")
|
||||
assertTableCount(t, ctx, repo.db, stockTable, 0, "iccid = ?", "898600000000001")
|
||||
assertBatchMainStatus(t, ctx, repo.db, batchNo, batchdomain.AsyncStatusProcessing, batchdomain.RunResultRunning)
|
||||
}
|
||||
|
||||
func TestSaveResultFileFailureOnlyMarksBatchInfo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newTestRepository(t)
|
||||
batchNo := "UNS-1"
|
||||
seedProcessingBatch(t, ctx, repo.db, batchNo, testSource)
|
||||
seedRunningDetail(t, ctx, repo.db, batchNo, 1, "898600000000001", "13800000001", "460001000000001", testSource, "")
|
||||
|
||||
task, err := repo.Load(ctx, batchNo)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if err = repo.updateResultFileStatus(ctx, task, "failed", 0, 0, "disk full"); err != nil {
|
||||
t.Fatalf("updateResultFileStatus() error = %v", err)
|
||||
}
|
||||
var row struct {
|
||||
Info string `orm:"batch_info"`
|
||||
}
|
||||
if err = repo.db.Model(dao.BatchMain.Table()).Ctx(ctx).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Fields("batch_info").
|
||||
Scan(&row); err != nil {
|
||||
t.Fatalf("query batch main: %v", err)
|
||||
}
|
||||
info := gjson.New([]byte(row.Info))
|
||||
if info.Get("resultFile.status").String() != "failed" {
|
||||
t.Fatalf("resultFile.status = %s, want failed", info.Get("resultFile.status").String())
|
||||
}
|
||||
if info.Get("resultFile.error").String() != "disk full" {
|
||||
t.Fatalf("resultFile.error = %s, want disk full", info.Get("resultFile.error").String())
|
||||
}
|
||||
assertBatchMainStatus(t, ctx, repo.db, batchNo, batchdomain.AsyncStatusProcessing, batchdomain.RunResultRunning)
|
||||
}
|
||||
|
||||
func assertOrderReturnStatus(t *testing.T, ctx context.Context, db gdb.DB, orderCode string, want int) {
|
||||
t.Helper()
|
||||
var row struct {
|
||||
ReturnStatus int `orm:"return_status"`
|
||||
}
|
||||
if err := db.Model(orderTable).Ctx(ctx).
|
||||
Where("order_sn", orderCode).
|
||||
Fields("return_status").
|
||||
Scan(&row); err != nil {
|
||||
t.Fatalf("query order return status: %v", err)
|
||||
}
|
||||
if row.ReturnStatus != want {
|
||||
t.Fatalf("order %s return_status = %d, want %d", orderCode, row.ReturnStatus, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOrderItemExtra(t *testing.T, ctx context.Context, db gdb.DB, orderCode, batchNo string, wantQuantity int) {
|
||||
t.Helper()
|
||||
var row struct {
|
||||
Extra string `orm:"extra_sku_data"`
|
||||
}
|
||||
if err := db.Model(orderItemTable).Ctx(ctx).
|
||||
Where("order_sn", orderCode).
|
||||
Fields("extra_sku_data").
|
||||
Scan(&row); err != nil {
|
||||
t.Fatalf("query order item extra: %v", err)
|
||||
}
|
||||
info := gjson.New([]byte(row.Extra))
|
||||
if info.Get("lastUnsubscribeBatchNo").String() != batchNo {
|
||||
t.Fatalf("lastUnsubscribeBatchNo = %s, want %s", info.Get("lastUnsubscribeBatchNo").String(), batchNo)
|
||||
}
|
||||
if int(info.Get("unsubscribedQuantity").Int()) != wantQuantity {
|
||||
t.Fatalf("unsubscribedQuantity = %d, want %d", info.Get("unsubscribedQuantity").Int(), wantQuantity)
|
||||
}
|
||||
}
|
||||
|
||||
var _ unsub.Repository = (*Repository)(nil)
|
||||
Reference in New Issue
Block a user