package product import ( "context" "database/sql" "encoding/json" "errors" "github.com/go-sql-driver/mysql" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/stores/redis" "github.com/zeromicro/go-zero/core/stores/sqlx" "go.uber.org/mock/gomock" "golang.org/x/crypto/bcrypt" deptModel "perms-system-server/internal/model/dept" productModel "perms-system-server/internal/model/product" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "perms-system-server/internal/testutil/ctxhelper" "perms-system-server/internal/testutil/mocks" "perms-system-server/internal/types" "sync" "testing" "time" ) func TestCreateProduct_Success(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() code := testutil.UniqueId() logic := NewCreateProductLogic(ctx, svcCtx) resp, err := logic.CreateProduct(&types.CreateProductReq{ Code: code, Name: "测试产品", Remark: "集成测试", AdminDeptId: seedAdminDept(t, ctx, svcCtx), }) require.NoError(t, err) require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code) testutil.CleanTable(ctx, conn, "`sys_product`", resp.Id) }) assert.True(t, resp.Id > 0) assert.Equal(t, code, resp.Code) assert.NotEmpty(t, resp.AppKey) assert.Equal(t, "admin_"+code, resp.AdminUser) // 响应体必须不再明文携带 appSecret / adminPassword, // 改为发放一次性 credentialsTicket + 过期时间;调用方需凭 ticket 走 // /api/product/fetchInitialCredentials 领取敏感凭证。 assert.NotEmpty(t, resp.CredentialsTicket, "必须返回一次性凭证票据") assert.True(t, resp.CredentialsExpiresAt > 0, "必须返回过期时间戳") // 契约性校验:CreateProductResp 的 JSON 序列化里不应再出现 appSecret / adminPassword 字段。 buf, err := json.Marshal(resp) require.NoError(t, err) var asMap map[string]interface{} require.NoError(t, json.Unmarshal(buf, &asMap)) _, hasSecret := asMap["appSecret"] _, hasPwd := asMap["adminPassword"] assert.False(t, hasSecret, "CreateProductResp JSON 不得包含 appSecret 字段(避免日志落盘)") assert.False(t, hasPwd, "CreateProductResp JSON 不得包含 adminPassword 字段(避免日志落盘)") } // TC-0064: 正常创建 func TestCreateProduct_VerifyDB(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() code := testutil.UniqueId() logic := NewCreateProductLogic(ctx, svcCtx) resp, err := logic.CreateProduct(&types.CreateProductReq{ Code: code, Name: "DB验证产品", Remark: "验证数据库记录", AdminDeptId: seedAdminDept(t, ctx, svcCtx), }) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code) testutil.CleanTable(ctx, conn, "`sys_product`", resp.Id) }) product, err := svcCtx.SysProductModel.FindOne(ctx, resp.Id) require.NoError(t, err) assert.Equal(t, code, product.Code) assert.Equal(t, "DB验证产品", product.Name) assert.Equal(t, resp.AppKey, product.AppKey) // CreateProduct 响应不再明文吐 appSecret;appSecret 经 ticket 领取后再核对。 // 这里改为用 FetchInitialCredentialsLogic 把明文 appSecret 取出来,与 DB 中的 bcrypt hash 比对, // 既验证"DB 存的是 hash 而不是明文",也验证 ticket 流程正确交还了原始 appSecret。 fetch := NewFetchInitialCredentialsLogic(ctx, svcCtx) cred, err := fetch.FetchInitialCredentials(&types.FetchInitialCredentialsReq{Ticket: resp.CredentialsTicket}) require.NoError(t, err, "使用 ticket 必须能领取到初始 appSecret / adminPassword") require.NotEmpty(t, cred.AppSecret) require.NotEmpty(t, cred.AdminPassword) assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(product.AppSecret), []byte(cred.AppSecret)), "DB should store bcrypt hash of appSecret, verifiable with plaintext from ticket payload") assert.Equal(t, int64(1), product.Status) var userCount int64 err = conn.QueryRowCtx(ctx, &userCount, "SELECT COUNT(*) FROM `sys_user` WHERE `username` = ?", "admin_"+code) require.NoError(t, err) assert.Equal(t, int64(1), userCount) var memberCount int64 err = conn.QueryRowCtx(ctx, &memberCount, "SELECT COUNT(*) FROM `sys_product_member` WHERE `productCode` = ? AND `memberType` = 'ADMIN'", code) require.NoError(t, err) assert.Equal(t, int64(1), memberCount) } // TC-0067: 编码已存在 func TestCreateProduct_DuplicateCode(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() code := testutil.UniqueId() deptId := seedAdminDept(t, ctx, svcCtx) logic := NewCreateProductLogic(ctx, svcCtx) resp, err := logic.CreateProduct(&types.CreateProductReq{ Code: code, Name: "第一个产品", AdminDeptId: deptId, }) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code) testutil.CleanTable(ctx, conn, "`sys_product`", resp.Id) }) logic2 := NewCreateProductLogic(ctx, svcCtx) _, err = logic2.CreateProduct(&types.CreateProductReq{ Code: code, Name: "重复产品", AdminDeptId: deptId, }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 409, codeErr.Code()) assert.Equal(t, "产品编码已存在", codeErr.Error()) } // TC-0068: 并发创建同编码 func TestCreateProduct_ConcurrentSameCode(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() code := testutil.UniqueId() t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code) testutil.CleanTableByField(ctx, conn, "`sys_product`", "code", code) }) deptId := seedAdminDept(t, ctx, svcCtx) var wg sync.WaitGroup results := make(chan error, 2) for i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done() logic := NewCreateProductLogic(ctx, svcCtx) _, err := logic.CreateProduct(&types.CreateProductReq{ Code: code, Name: "并发测试产品", AdminDeptId: deptId, }) results <- err }() } wg.Wait() close(results) var errs []error for err := range results { errs = append(errs, err) } require.Len(t, errs, 2) successCount := 0 failCount := 0 for _, err := range errs { if err == nil { successCount++ } else { failCount++ } } assert.Equal(t, 1, successCount, "exactly one goroutine should succeed") assert.Equal(t, 1, failCount, "exactly one goroutine should fail (409 or DB duplicate)") } // TC-0535: createProduct非超管拒绝 func TestCreateProduct_NonSuperAdminRejected(t *testing.T) { ctx := ctxhelper.AdminCtx("test_product") svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateProductLogic(ctx, svcCtx) _, err := logic.CreateProduct(&types.CreateProductReq{Code: "test", Name: "test"}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) } // TC-0069~0593: createProduct 编码格式校验( 修复验证) func TestCreateProduct_InvalidCodeFormat(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateProductLogic(ctx, svcCtx) cases := []struct { name string code string }{ {"空", ""}, {"数字开头", "1abc"}, {"下划线开头", "_abc"}, {"中划线开头", "-abc"}, {"包含中文", "产品A"}, {"单字母(过短)", "a"}, {"包含空格", "ab c"}, {"包含特殊字符!", "ab!c"}, {"包含斜杠", "ab/c"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { _, err := logic.CreateProduct(&types.CreateProductReq{Code: c.code, Name: "x"}) require.Error(t, err, "code=%q 应被拒绝", c.code) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) }) } } // TC-0074: createProduct 编码长度>64 被拒绝 func TestCreateProduct_CodeTooLong(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateProductLogic(ctx, svcCtx) long := "a" for i := 0; i < 64; i++ { long += "b" } _, err := logic.CreateProduct(&types.CreateProductReq{Code: long, Name: "x"}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) } // TC-0075: createProduct 合法编码(包含下划线、中划线、数字) func TestCreateProduct_ValidCodeWithSymbols(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() code := "a_1-" + testutil.UniqueId() logic := NewCreateProductLogic(ctx, svcCtx) resp, err := logic.CreateProduct(&types.CreateProductReq{ Code: code, Name: "x", AdminDeptId: seedAdminDept(t, ctx, svcCtx), }) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code) testutil.CleanTable(ctx, conn, "`sys_product`", resp.Id) }) assert.Equal(t, code, resp.Code) } // suppress unused import var _ = (*productModel.SysProduct)(nil) func newBrokenSvcCtxForM1(t *testing.T) *svc.ServiceContext { t.Helper() cfg := testutil.GetTestConfig() svcCtx := svc.NewServiceContext(cfg) // 把 svcCtx.Redis 换掉;注意 MustNewRedis 不立即拨号,只有真正调 Setex 才会爆。 // NonBlock=true 避免构造期强制 ping;PingTimeout 只在 NonBlock=false 时生效,这里保留是为 // 防御未来默认值漂移。真正的失败发生在运行 SetexCtx 时,连不通 127.0.0.1:1 会立刻返 err。 broken := redis.MustNewRedis(redis.RedisConf{ Host: "127.0.0.1:1", Type: "node", NonBlock: true, PingTimeout: 200 * time.Millisecond, }) svcCtx.Redis = broken return svcCtx } // assertNoOrphanRowsLeft 在补偿完成后,按 (productCode, adminUsername) 反查 DB 是否还有脏行。 // 三张表都必须干净 —— 这是补偿契约的硬不变式。 func assertNoOrphanRowsLeft(t *testing.T, ctx context.Context, conn sqlx.SqlConn, productCode, adminUsername string) { t.Helper() var productId int64 err := conn.QueryRowCtx(ctx, &productId, "SELECT `id` FROM `sys_product` WHERE `code` = ? LIMIT 1", productCode) assert.ErrorIs(t, err, sql.ErrNoRows, "补偿后 sys_product 不得留下 code=%s 的行", productCode) var userId int64 err = conn.QueryRowCtx(ctx, &userId, "SELECT `id` FROM `sys_user` WHERE `username` = ? LIMIT 1", adminUsername) assert.ErrorIs(t, err, sql.ErrNoRows, "补偿后 sys_user 不得留下 username=%s 的行", adminUsername) var memberId int64 err = conn.QueryRowCtx(ctx, &memberId, "SELECT `id` FROM `sys_product_member` WHERE `productCode` = ? LIMIT 1", productCode) assert.ErrorIs(t, err, sql.ErrNoRows, "补偿后 sys_product_member 不得留下 productCode=%s 的行", productCode) } // TC-0976: Redis SetexCtx 失败时走补偿 —— DB 三张表必须回到"从未创建"。 func TestCreateProduct_RedisSetexFail_CompensatesAllRows(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := newBrokenSvcCtxForM1(t) conn := testutil.GetTestSqlConn() code := "m1_cpf_" + testutil.UniqueId() adminUsername := "admin_" + code // 兜底清理,防止断言失败后把孤儿行留下来污染下一次运行。 t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", adminUsername) testutil.CleanTableByField(ctx, conn, "`sys_product`", "code", code) }) // 补偿路径必须在"入库 → SetexCtx 失败 → 补偿"整条链路生效。 要求传 AdminDeptId: // 这里必须用真实 DB(svcCtx.SysDeptModel 走 testutil 底层 conn),所以 seed 一条真实 dept。 deptId := seedAdminDept(t, ctx, svcCtx) logic := NewCreateProductLogic(ctx, svcCtx) resp, err := logic.CreateProduct(&types.CreateProductReq{ Code: code, Name: "m1压测产品", Remark: "补偿验证", AdminDeptId: deptId, }) // 要求:返回 503 "暂存初始凭证失败,请稍后重试";响应体不得携带 ticket / adminPassword。 require.Error(t, err, "Redis 挂了时必须返回错误而不是静默吞掉") require.Nil(t, resp, "失败路径下不应把半成品 CreateProductResp 塞回给客户端") // 核心断言:补偿必须把产品 / admin 用户 / product_member 三行全部抹除。 assertNoOrphanRowsLeft(t, ctx, conn, code, adminUsername) } // TC-0977: 同一 product code 在补偿后可以再次创建成功(幂等性)。 // 没有这条断言的话,"补偿把行删干净"还可能与"索引未释放"组合成 ErrConflict,运维的修复动作会 // 在二次尝试时被"产品编码已存在"挡回,补偿只是半成品。 func TestCreateProduct_RedisSetexFail_AfterCompensation_CanRecreate(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() brokenCtx := newBrokenSvcCtxForM1(t) conn := testutil.GetTestSqlConn() code := "m1_recreate_" + testutil.UniqueId() adminUsername := "admin_" + code t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", adminUsername) testutil.CleanTableByField(ctx, conn, "`sys_product`", "code", code) }) // 两次 CreateProduct 复用同一个有效 dept,seedAdminDept 在 brokenCtx 下创建 // (brokenCtx 的 Models 共享底层 conn,所以 goodCtx 二次查询也能读到)。 deptId := seedAdminDept(t, ctx, brokenCtx) // 第一次:Redis 坏 → 补偿 → 503 _, err := NewCreateProductLogic(ctx, brokenCtx).CreateProduct(&types.CreateProductReq{ Code: code, Name: "first_attempt", Remark: "redis_down", AdminDeptId: deptId, }) require.Error(t, err) assertNoOrphanRowsLeft(t, ctx, conn, code, adminUsername) // 第二次:Redis 好 → 必须成功;若第一次补偿不彻底会在 FindOneByCode/FindOneByUsername 里被拦。 goodCtx := svc.NewServiceContext(testutil.GetTestConfig()) resp2, err := NewCreateProductLogic(ctx, goodCtx).CreateProduct(&types.CreateProductReq{ Code: code, Name: "second_attempt", Remark: "redis_ok", AdminDeptId: deptId, }) require.NoError(t, err, "补偿后二次创建必须成功;若失败说明有行没被清干净") require.NotNil(t, resp2) assert.Equal(t, code, resp2.Code) assert.NotEmpty(t, resp2.CredentialsTicket) } // TC-0978: 补偿的三张表删除顺序必须是"子 → 父"(product_member → user → product), // 保证即使外键/缓存/唯一索引尚未释放也不会互相拦截。 // // 验证手法:在第一次补偿完成后,直接用数据库元数据反查三张表里均无与 productCode 相关的残留; // 如果删除顺序错误(例如先 product 后 member),product 会被外键/级联规则阻塞,member 行会留下。 // // 注:sys_product 与 sys_product_member 之间没有强制外键(见 perm.sql 确认),所以 MySQL 不会在 // 引擎层阻止错序 DELETE。但错序在事务里依然会导致: // - 如果先删 product 再删 member,member 仍有引用的 productCode 已经没有对应 product, // 再之后的 "ON DUPLICATE KEY UPDATE" 或外部查询会看到脏引用; // - 即便 DB 不拦,测试也要在更高一层用"三张表均为 0 行"来钉死补偿是否真正覆盖 3 行。 func TestCreateProduct_RedisSetexFail_CompensatesInChildFirstOrder(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := newBrokenSvcCtxForM1(t) conn := testutil.GetTestSqlConn() code := "m1_order_" + testutil.UniqueId() adminUsername := "admin_" + code t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", adminUsername) testutil.CleanTableByField(ctx, conn, "`sys_product`", "code", code) }) _, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{ Code: code, Name: "m1 order", Remark: "delete order", AdminDeptId: seedAdminDept(t, ctx, svcCtx), }) require.Error(t, err) // 交叉验证:三张表按独立 SELECT COUNT 查询,每张都必须为 0。 for _, tc := range []struct { sql string arg interface{} table string }{ {"SELECT COUNT(*) FROM `sys_product_member` WHERE `productCode` = ?", code, "sys_product_member"}, {"SELECT COUNT(*) FROM `sys_user` WHERE `username` = ?", adminUsername, "sys_user"}, {"SELECT COUNT(*) FROM `sys_product` WHERE `code` = ?", code, "sys_product"}, } { var n int64 require.NoError(t, conn.QueryRowCtx(ctx, &n, tc.sql, tc.arg)) assert.Equal(t, int64(0), n, "补偿完成后 %s 应 0 行(命名 code=%s / admin=%s)", tc.table, code, adminUsername) } } func TestCreateProduct_DuplicateEntry_UnknownIndexName_MapsTo409(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) // 关键:索引名选一个完全不含 "uk_code" 的,让旧 strings.Contains 分支必然 miss。 dupErr := &mysql.MySQLError{ Number: 1062, Message: "Duplicate entry 'abc' for key 'sys_product_PRIMARY'", } mockProduct := mocks.NewMockSysProductModel(ctrl) mockProduct.EXPECT().FindOneByCode(gomock.Any(), "m5_code"). Return(nil, productModel.ErrNotFound) mockProduct.EXPECT().TransactCtx(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error { return fn(ctx, nil) }) // 直接让 InsertWithTx 冒出 1062 mockProduct.EXPECT().InsertWithTx(gomock.Any(), nil, gomock.Any()). Return(nil, dupErr) mockUser := mocks.NewMockSysUserModel(ctrl) mockUser.EXPECT().FindOneByUsername(gomock.Any(), "admin_m5_code"). Return(nil, userModel.ErrNotFound) // CreateProduct 现在必填 AdminDeptId:外层 FindOne 做启用状态 pre-check, // tx 内部再用 FindOneForShareTx 取 S 锁闭合 pre-check→insert 之间的并发删除窗口(H-R17-1)。 mockDept := mocks.NewMockSysDeptModel(ctrl) mockDept.EXPECT().FindOne(gomock.Any(), int64(77)). Return(&deptModel.SysDept{Id: 77, Path: "/77/", Status: 1}, nil) mockDept.EXPECT().FindOneForShareTx(gomock.Any(), gomock.Any(), int64(77)). Return(&deptModel.SysDept{Id: 77, Path: "/77/", Status: 1}, nil) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{ Product: mockProduct, User: mockUser, Dept: mockDept, }) resp, err := NewCreateProductLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateProduct(&types.CreateProductReq{ Code: "m5_code", Name: "M5 Product", AdminDeptId: 77, }) assert.Nil(t, resp) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce), "必须是结构化 CodeError") assert.Equal(t, 409, ce.Code(), "任何 1062 都应统一返回 409;修复前不含 uk_code 的索引名会被吞成 500") assert.Contains(t, ce.Error(), "数据冲突", "错误消息应当是通用的'数据冲突,请稍后重试',不再尝试解析索引名文案") } // TC-1202: L-R17-2 —— CreateProduct 落盘的 admin_ 账号 Avatar 必须是 SQL NULL, // 不得被零值 `sql.NullString{""}` 悄悄写成空串。与 CreateUser 对齐,保证未来 SELECT JOIN // 判空语义稳定("空串"与"NULL"在 ORM / SDK 里不等价,会让业务分支误差)。 func TestCreateProduct_L_R17_2_AdminUserAvatarIsExplicitNull(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() code := testutil.UniqueId() resp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{ Code: code, Name: "avatar_null_" + code, AdminDeptId: seedAdminDept(t, ctx, svcCtx), }) require.NoError(t, err) require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code) testutil.CleanTable(ctx, conn, "`sys_product`", resp.Id) }) var avatarIsNull int64 require.NoError(t, conn.QueryRowCtx(ctx, &avatarIsNull, "SELECT CASE WHEN `avatar` IS NULL THEN 1 ELSE 0 END FROM `sys_user` WHERE `username`=?", "admin_"+code)) assert.Equal(t, int64(1), avatarIsNull, "L-R17-2:admin_.avatar 必须落 NULL(而非空串),与 CreateUser 口径对齐") } // TC-1203: H-R17-1 —— CreateProduct tx 内部必须对 adminDeptId 取 S 锁后再 Insert; // 若 FindOneForShareTx 返回 ErrNotFound(并发 DeleteDept 已提交),必须 400 "管理员部门不存在或已删除" // 且三张表(product/user/product_member)InsertWithTx 都不得被调用。 func TestCreateProduct_H_R17_1_DeptConcurrentlyDeletedRejected(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) const pc = "h_r17_1_prod" const deptId = int64(777) mockProduct := mocks.NewMockSysProductModel(ctrl) mockProduct.EXPECT().FindOneByCode(gomock.Any(), pc). Return(nil, productModel.ErrNotFound) mockProduct.EXPECT().TransactCtx(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error { return fn(ctx, nil) }) // Critical: InsertWithTx 全系列都不得被调用——S 锁拿不到时应立刻 400 退出 tx。 // 若未来 FindOneForShareTx 的返回被误忽略,gomock 会在未声明 EXPECT 的 Insert* 上立刻报错。 mockUser := mocks.NewMockSysUserModel(ctrl) mockUser.EXPECT().FindOneByUsername(gomock.Any(), "admin_"+pc). Return(nil, userModel.ErrNotFound) mockDept := mocks.NewMockSysDeptModel(ctrl) // 外层 pre-check 此刻还能读到启用状态的 dept(快照时间点早于 DeleteDept 提交)。 mockDept.EXPECT().FindOne(gomock.Any(), deptId). Return(&deptModel.SysDept{Id: deptId, Path: "/777/", Status: 1}, nil) // tx 内的 FOR SHARE 读 —— DeleteDept 已先于本 tx 提交,回 ErrNotFound。 mockDept.EXPECT().FindOneForShareTx(gomock.Any(), gomock.Any(), deptId). Return(nil, sqlx.ErrNotFound) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{ Product: mockProduct, User: mockUser, Dept: mockDept, }) resp, err := NewCreateProductLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateProduct(&types.CreateProductReq{ Code: pc, Name: "H-R17-1", AdminDeptId: deptId, }) assert.Nil(t, resp) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code(), "H-R17-1:tx 内 FindOneForShareTx 命中 ErrNotFound 必须立刻 400,不能继续 Insert "+ "导致 orphan admin 挂在 deptId=777 上") assert.Contains(t, ce.Error(), "管理员部门不存在或已删除", "文案必须点出'已删除'情形,帮助调用方立刻识别'并发 DeleteDept 胜出'场景") }