package app import ( "context" "errors" _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" _ "github.com/gogf/gf/contrib/nosql/redis/v2" "github.com/gogf/gf/v2/container/gvar" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/database/gredis" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "reflect" "regexp" "strings" "testing" "git.5iot.com/iotlcm-public/iotlcm-app/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-app/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-app/internal/library/allocaterequest" "git.5iot.com/iotlcm-public/iotlcm-app/internal/library/batchimport" "git.5iot.com/iotlcm-public/iotlcm-app/internal/model" "git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do" "git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity" ) type allocateSequenceAdapter struct { gredis.Adapter sequence int } func (adapter *allocateSequenceAdapter) Do(ctx context.Context, command string, args ...any) (*gvar.Var, error) { if command != "EVAL" { return nil, errors.New("unexpected Redis command") } adapter.sequence++ return gvar.New(adapter.sequence), nil } func TestAppAllocateQueuePersistsWorkerTaskWithoutChangingOwnership(t *testing.T) { ctx := context.Background() gredis.SetConfig(&gredis.Config{Address: "127.0.0.1:1"}) redis := g.Redis() original := redis.GetAdapter() redis.SetAdapter(&allocateSequenceAdapter{}) t.Cleanup(func() { redis.SetAdapter(original) }) previous := gdb.GetConfig("default") if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}); err != nil { t.Fatal(err) } database := g.DB() t.Cleanup(func() { if err := database.GetCore().ClearCacheAll(ctx); err != nil { t.Error(err) } if err := database.Close(ctx); err != nil { t.Error(err) } if err := gdb.SetConfigGroup("default", previous); err != nil { t.Error(err) } }) if err := database.GetCore().ClearCacheAll(ctx); err != nil { t.Fatal(err) } for name, value := range map[string]any{"batch_main": entity.BatchMain{}, "batch_detail": entity.BatchDetail{}, "t_sim_card_info": entity.TSimCardInfo{}} { typ := reflect.TypeOf(value) columns := make([]string, 0, typ.NumField()) for index := 0; index < typ.NumField(); index++ { field := typ.Field(index) column := field.Tag.Get("orm") sqlType := "TEXT" if field.Type.Kind() >= reflect.Int && field.Type.Kind() <= reflect.Uint64 { sqlType = "INTEGER" } if column == "id" { sqlType = "INTEGER PRIMARY KEY" } if column == "batch_no" && name == "batch_main" { sqlType += " UNIQUE" } columns = append(columns, column+" "+sqlType) } if _, err := g.DB().Exec(ctx, "CREATE TABLE "+name+" ("+strings.Join(columns, ",")+")"); err != nil { t.Fatal(err) } } if _, err := g.DB().Exec(ctx, "CREATE TABLE sys_dept(dept_id INTEGER,tenant_id TEXT,dept_key TEXT,dept_name TEXT,deleted_at TEXT)"); err != nil { t.Fatal(err) } if _, err := g.DB().Exec(ctx, "INSERT INTO sys_dept VALUES(1,'A','source','划拨单位',NULL),(2,'A','target','接收单位',NULL)"); err != nil { t.Fatal(err) } user := &entity.SysUser{UserId: 7, DeptId: 1, TenantId: "A"} prepare := &entity.BatchMain{BatchInfo: gjson.New(map[string]any{"requestId": "request", "requestHash": "hash"}), Id: 1, BatchNo: "ALC20261012000001", ExecutionMode: 2} if _, err := dao.BatchMain.Ctx(ctx).Data(do.BatchMain{Id: 1, BatchNo: prepare.BatchNo, BatchType: 4, TenantId: "A", CreatedBy: 7, StartTime: gtime.Now(), EndTime: gtime.Now(), TotalCount: 1, ResultOssId: 99}).Insert(); err != nil { t.Fatal(err) } if _, err := dao.BatchDetail.Ctx(ctx).Data(do.BatchDetail{BatchNo: prepare.BatchNo, BatchType: 4, ItemId: "CARD", TenantId: "A", CreatedBy: 7}).Insert(); err != nil { t.Fatal(err) } if _, err := dao.TSimCardInfo.Ctx(ctx).Data(do.TSimCardInfo{Id: 1, Iccid: "CARD", DeptId: 1, TenantId: "A", CreatedDept: 1}).Insert(); err != nil { t.Fatal(err) } batch := &appAllocateBatchContext{Target: &entity.SysDept{DeptId: 2, DeptName: "目标客户"}, Cards: []*appAllocateCardContext{{Card: &appCardQueryRow{TSimCardInfo: entity.TSimCardInfo{Id: 1, Iccid: "CARD", DeptId: 1}}}}} if err := dao.BatchMain.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { return NewAppCard().queueAppAllocateBatch(ctx, tx, user, prepare, batch, "") }); err != nil { t.Fatal(err) } var parent *entity.BatchMain if err := dao.BatchMain.Ctx(ctx).Where("batch_no", prepare.BatchNo).Scan(&parent); err != nil { t.Fatal(err) } if parent.BatchType != consts.BatchTypeAllocate || parent.SourceBatchNo != "" || parent.BatchAsyncStatus != consts.BatchAsyncStatusPending || parent.ExecutionMode != 2 || parent.SourceChannel != "mini_program" { t.Fatal("invalid parent", parent) } if parent.BatchInfo.Get("requestHash").String() != "hash" || parent.BatchInfo.Get("requestId").String() != "request" { t.Fatal("request identity lost") } if parent.ResultOssId != 0 { t.Fatal("precheck result file leaked into execution") } if parent.StartTime != nil && !parent.StartTime.IsZero() || parent.EndTime != nil && !parent.EndTime.IsZero() { t.Fatal("prepare times leaked into execution", parent) } var children []*entity.BatchMain if err := dao.BatchMain.Ctx(ctx).Where("source_batch_no", prepare.BatchNo).Scan(&children); err != nil { t.Fatal(err) } if len(children) != 1 || !regexp.MustCompile(`^ALC[0-9]{17}$`).MatchString(children[0].BatchNo) || children[0].BatchInfo.Get("jobRole").String() != "allocate_chunk" { t.Fatal("invalid child", children) } var details []*entity.BatchDetail if err := dao.BatchDetail.Ctx(ctx).Scan(&details); err != nil { t.Fatal(err) } if len(details) != 1 || details[0].BatchNo != children[0].BatchNo || details[0].Snapshot.Get("targetDeptId").Int64() != 2 { t.Fatal("invalid execution details", details) } for _, snapshot := range []*gjson.Json{parent.BatchInfo, children[0].BatchInfo, details[0].Snapshot} { if snapshot.Get("sourceDeptName").String() != "划拨单位" || snapshot.Get("targetDeptName").String() != "接收单位" || snapshot.Get("sourceDeptId").Int64() != 1 || snapshot.Get("targetDeptId").Int64() != 2 { t.Fatal("父子批次和明细单位快照不一致", snapshot) } } var card *entity.TSimCardInfo if err := dao.TSimCardInfo.Ctx(ctx).Where("id", 1).Scan(&card); err != nil { t.Fatal(err) } if card.DeptId != 1 || card.CreatedDept != 1 || card.AllocationBatchNo != "" { t.Fatal("queue changed ownership", card) } for _, test := range []struct { value string allocate bool count int }{ {" AR ", true, 1}, {"AR", false, 0}, {"CARD", true, 1}, {"missing", true, 0}, } { query := NewAppCard().applyCardFilters(dao.TSimCardInfo.Ctx(ctx).Where("tenant_id", "A"), &model.AppCardListParam{CardValue: test.value, AllocateOnly: test.allocate}) if count, err := query.Count(); err != nil || count != test.count { t.Fatalf("search %q allocate=%v count=%d error=%v", test.value, test.allocate, count, err) } } parentNo, err := batchimport.NextBatchNoContext(ctx, consts.BatchNoPrefixAllocate) if err != nil { t.Fatal(err) } pending, err := NewAppCard().persistAppAllocatePending(ctx, user, parentNo, 2, batch, "直接异步") if err != nil { t.Fatal(err) } if pending.BatchType != 3 || pending.BatchAsyncStatus != 1 || pending.ValidCount != 0 || !regexp.MustCompile(`^ALC[0-9]{17}$`).MatchString(pending.BatchNo) { t.Fatal("direct submission did not create pending task", pending) } var directParent *entity.BatchMain if err := dao.BatchMain.Ctx(ctx).Where("batch_no", parentNo).Scan(&directParent); err != nil || directParent.SourceBatchNo != "" || directParent.BatchInfo.Get("jobRole").String() != "allocate_parent" || !directParent.BatchInfo.Get("splitCompleted").Bool() { t.Fatal("invalid direct parent protocol", directParent, err) } var directChild *entity.BatchMain if err := dao.BatchMain.Ctx(ctx).Where("source_batch_no", parentNo).Scan(&directChild); err != nil || directChild.BatchInfo.Get("jobRole").String() != "allocate_chunk" || directChild.BatchAsyncStatus != 1 { t.Fatal("invalid direct child protocol", directChild, err) } if err := dao.TSimCardInfo.Ctx(ctx).Where("id", 1).Scan(&card); err != nil || card.DeptId != 1 || card.AllocationBatchNo != "" { t.Fatal("direct submission changed card ownership", card, err) } if _, err := dao.BatchMain.DB().Exec(ctx, `CREATE TRIGGER reject_allocate_child BEFORE INSERT ON batch_main WHEN NEW.source_batch_no = 'ROLLBACK_PARENT' BEGIN SELECT RAISE(ABORT, 'injected child failure'); END`); err != nil { t.Fatal(err) } if _, err := NewAppCard().persistAppAllocatePending(ctx, user, "ROLLBACK_PARENT", 2, batch, ""); err == nil { t.Fatal("child creation failure was ignored") } if count, err := dao.BatchMain.Ctx(ctx).Where("batch_no", "ROLLBACK_PARENT").Count(); err != nil || count != 0 { t.Fatal("direct submission left orphan parent", count, err) } if _, err := dao.BatchMain.DB().Exec(ctx, `DROP TRIGGER reject_allocate_child`); err != nil { t.Fatal(err) } second := &entity.BatchMain{Id: 10, BatchNo: "ALC20261012000002", ExecutionMode: 2} if _, err := dao.BatchMain.Ctx(ctx).Data(do.BatchMain{Id: 10, BatchNo: second.BatchNo, BatchType: 4, TenantId: "A", CreatedBy: 7}).Insert(); err != nil { t.Fatal(err) } if _, err := dao.BatchDetail.Ctx(ctx).Data(do.BatchDetail{BatchNo: second.BatchNo, BatchType: 4, ItemId: "CARD", TenantId: "A", CreatedBy: 7}).Insert(); err != nil { t.Fatal(err) } injected := errors.New("rollback test") err = dao.BatchMain.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { if err := NewAppCard().queueAppAllocateBatch(ctx, tx, user, second, batch, ""); err != nil { return err } return injected }) if !errors.Is(err, injected) { t.Fatal("rollback did not return injected failure", err) } request := &model.AppCardAllocateBatchSubmitParam{RequestId: "alloc_test_0123456789", CardValues: []string{"CARD"}, TargetDeptId: 2, ExecutionMode: 2} requestKey, err := allocaterequest.Key(user.TenantId, user.UserId, request.RequestId) requestNo := "ALC" + requestKey[:60] if err != nil { t.Fatal(err) } hash := allocaterequest.Fingerprint(request.CardValues, 1, 2, 2, "") if _, err := dao.BatchMain.Ctx(ctx).Data(do.BatchMain{Id: 20, BatchNo: requestNo, BatchType: 3, SourceBatchNo: "", BatchStatus: 3, BatchAsyncStatus: 3, TenantId: "A", CreatedBy: 7, CreatedDept: 1, TotalCount: 1, TargetDeptId: 2, BatchInfo: gjson.New(map[string]any{"jobRole": "allocate_parent", "requestHash": hash})}).Insert(); err != nil { t.Fatal(err) } if _, err := dao.TSimCardInfo.Ctx(ctx).Where("id", 1).Data(do.TSimCardInfo{DeptId: 2}).Update(); err != nil { t.Fatal(err) } for index := 0; index < 2; index++ { response, err := NewAppCard().submitAppAllocateRequest(ctx, user, request) if err != nil || response.BatchNo != requestNo || response.Status != "success" { t.Fatal("accepted request was revalidated or repeated", response, err) } } request.RequestId = "alloc_missing_0123456789" if _, err := NewAppCard().submitAppAllocateRequest(ctx, user, request); err == nil { t.Fatal("missing legacy request created new batch") } request.RequestId = "alloc_test_0123456789" request.TargetDeptId = 3 if _, err := NewAppCard().submitAppAllocateRequest(ctx, user, request); err == nil { t.Fatal("changed request accepted") } if count, err := dao.BatchMain.Ctx(ctx).Where("batch_no", requestNo).Count(); err != nil || count != 1 { t.Fatal("duplicate request rows", count, err) } count, err := dao.BatchMain.Ctx(ctx).Where("source_batch_no", second.BatchNo).Count() if err != nil || count != 0 { t.Fatal("rollback left child", count, err) } var retained *entity.BatchMain if err := dao.BatchMain.Ctx(ctx).Where("id", second.Id).Scan(&retained); err != nil || retained.BatchType != 4 { t.Fatal("rollback changed parent", retained, err) } count, err = dao.BatchDetail.Ctx(ctx).Where("batch_no", second.BatchNo).Count() if err != nil || count != 1 { t.Fatal("rollback lost original detail", count, err) } }