Browse Source

feat: 创建用户时自动生成随机密码, 不明文返回密码, 需通过 /fetchUserCredentials 获取一次性展示的凭证,用户登录之后必须手动修改密码. 新增 /resetPassword 接口,机制与创建用户的机制一样

BaiLuoYan 1 day ago
parent
commit
1bc0045568

+ 10 - 0
internal/handler/routes.go

@@ -315,11 +315,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 					Path:    "/detail",
 					Handler: user.UserDetailHandler(serverCtx),
 				},
+				{
+					Method:  http.MethodPost,
+					Path:    "/fetchCredentials",
+					Handler: user.FetchUserCredentialsHandler(serverCtx),
+				},
 				{
 					Method:  http.MethodPost,
 					Path:    "/list",
 					Handler: user.UserListHandler(serverCtx),
 				},
+				{
+					Method:  http.MethodPost,
+					Path:    "/resetPassword",
+					Handler: user.ResetPasswordHandler(serverCtx),
+				},
 				{
 					Method:  http.MethodPost,
 					Path:    "/setPerms",

+ 32 - 0
internal/handler/user/fetchUserCredentialsHandler.go

@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package user
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/rest/httpx"
+	"perms-system-server/internal/logic/user"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+)
+
+func FetchUserCredentialsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req types.FetchUserCredentialsReq
+		if err := httpx.Parse(r, &req); err != nil {
+			httpx.ErrorCtx(r.Context(), w, response.ErrBadRequest(err.Error()))
+			return
+		}
+
+		l := user.NewFetchUserCredentialsLogic(r.Context(), svcCtx)
+		resp, err := l.FetchUserCredentials(&req)
+		if err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+		} else {
+			httpx.OkJsonCtx(r.Context(), w, resp)
+		}
+	}
+}

+ 32 - 0
internal/handler/user/resetPasswordHandler.go

@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package user
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/rest/httpx"
+	"perms-system-server/internal/logic/user"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+)
+
+func ResetPasswordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req types.ResetPasswordReq
+		if err := httpx.Parse(r, &req); err != nil {
+			httpx.ErrorCtx(r.Context(), w, response.ErrBadRequest(err.Error()))
+			return
+		}
+
+		l := user.NewResetPasswordLogic(r.Context(), svcCtx)
+		resp, err := l.ResetPassword(&req)
+		if err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+		} else {
+			httpx.OkJsonCtx(r.Context(), w, resp)
+		}
+	}
+}

+ 2 - 79
internal/logic/product/createProductLogic.go

@@ -2,9 +2,7 @@ package product
 
 import (
 	"context"
-	"crypto/rand"
 	"database/sql"
-	"encoding/hex"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -251,86 +249,11 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 }
 
 func generateRandomHex(byteLen int) (string, error) {
-	b := make([]byte, byteLen)
-	if _, err := rand.Read(b); err != nil {
-		return "", fmt.Errorf("generate random bytes failed: %w", err)
-	}
-	return hex.EncodeToString(b), nil
+	return util.GenerateRandomHex(byteLen)
 }
 
-// generateStrongInitialPassword 为产品 admin 初始账号生成强密码:大写+小写+数字+少量符号混合,
-// 并通过 util.ValidatePassword 断言,确保任何后续合规复核都不会卡住(见审计 L-R10-2)。
-// 长度要求 n >= 8;实际生成 n 个字符,混合字符集字面量已刻意去掉易混淆的 I/l/O/0/1。
 func generateStrongInitialPassword(n int) (string, error) {
-	if n < 8 {
-		n = 8
-	}
-	const (
-		upper   = "ABCDEFGHJKMNPQRSTUVWXYZ"
-		lower   = "abcdefghjkmnpqrstuvwxyz"
-		digits  = "23456789"
-		symbols = "!@#$%^&*"
-	)
-	alphabet := upper + lower + digits + symbols
-	// 把每个字符类至少各取 1 个放在随机位置,保证强度检查一定通过;其余位从总字母表随机。
-	classes := []string{upper, lower, digits, symbols}
-	pwd := make([]byte, n)
-	used := 0
-	for _, cls := range classes {
-		c, err := randomCharFrom(cls)
-		if err != nil {
-			return "", err
-		}
-		pwd[used] = c
-		used++
-	}
-	for i := used; i < n; i++ {
-		c, err := randomCharFrom(alphabet)
-		if err != nil {
-			return "", err
-		}
-		pwd[i] = c
-	}
-	if err := shuffleBytes(pwd); err != nil {
-		return "", err
-	}
-	out := string(pwd)
-	if msg := util.ValidatePassword(out); msg != "" {
-		// 理论上不可达:上面已按字符类强制填充,保留断言避免字符集未来被人修改后静默失效。
-		return "", fmt.Errorf("generated password failed strength check: %s", msg)
-	}
-	return out, nil
-}
-
-// randomCharFrom 用 crypto/rand 无偏地从字符集中取一个字节。循环直到命中模长内,避免简单取模偏置。
-func randomCharFrom(alphabet string) (byte, error) {
-	max := len(alphabet)
-	bucket := 256 - (256 % max)
-	buf := make([]byte, 1)
-	for {
-		if _, err := rand.Read(buf); err != nil {
-			return 0, err
-		}
-		if int(buf[0]) < bucket {
-			return alphabet[int(buf[0])%max], nil
-		}
-	}
-}
-
-// shuffleBytes Fisher-Yates 洗牌,随机索引基于 crypto/rand;用于打散 generateStrongInitialPassword
-// 里"前 4 个字符恰好是各字符类"的固定前缀,避免格式可预测。
-func shuffleBytes(buf []byte) error {
-	for i := len(buf) - 1; i > 0; i-- {
-		bucket := byte(i + 1)
-		// 与 randomCharFrom 相同的无偏采样策略,上限很小直接 mod 即可(i<=63)。
-		b := make([]byte, 1)
-		if _, err := rand.Read(b); err != nil {
-			return err
-		}
-		j := int(b[0] % bucket)
-		buf[i], buf[j] = buf[j], buf[i]
-	}
-	return nil
+	return util.GenerateStrongInitialPassword(n)
 }
 
 // compensateCreatedRows 是审计 M-1 要求的失败补偿:事务已提交后 ticket/Redis 环节失败时,

+ 36 - 16
internal/logic/user/createUserLogic.go

@@ -3,6 +3,7 @@ package user
 import (
 	"context"
 	"database/sql"
+	"encoding/json"
 	"errors"
 	"regexp"
 	"strings"
@@ -49,7 +50,7 @@ func NewCreateUserLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Create
 
 // CreateUser 创建用户。新建系统用户账号,可指定部门归属。超管或当前产品 ADMIN 可调用。
 // 注意:产品 ADMIN 创建的用户为系统级用户,不自动加入任何产品,需通过 AddMember 接口手动关联。
-func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdResp, err error) {
+func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.CreateUserResp, err error) {
 	productCode := middleware.GetProductCode(l.ctx)
 	if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
 		return nil, err
@@ -60,9 +61,6 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 		return nil, response.ErrUnauthorized("未登录")
 	}
 
-	if msg := util.ValidatePassword(req.Password); msg != "" {
-		return nil, response.ErrBadRequest(msg)
-	}
 	if !usernameRegexp.MatchString(req.Username) {
 		return nil, response.ErrBadRequest("用户名只能包含字母、数字和下划线,长度2-64个字符")
 	}
@@ -82,7 +80,6 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 	if len(req.Remark) > 255 {
 		return nil, response.ErrBadRequest("备注长度不能超过255个字符")
 	}
-
 	if req.Email != "" && !util.IsValidEmail(req.Email) {
 		return nil, response.ErrBadRequest("邮箱格式不正确")
 	}
@@ -111,10 +108,15 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 		return nil, response.ErrBadRequest("必须指定部门")
 	}
 
+	plainPassword, err := util.GenerateStrongInitialPassword(16)
+	if err != nil {
+		return nil, err
+	}
+
 	// 审计 H-R17-1:bcrypt.GenerateFromPassword 在事务外完成——bcrypt default cost 约 60~100ms,
 	// 若放进事务体会把 sys_dept 的 S 锁持有时长拉高到 100ms+,阻塞并发 DeleteDept / UpdateDept
 	// 的 X 锁,引发写放大。tx 内只做"锁 sys_dept → Insert sys_user"的纯 DB 动作。
-	hashedPwd, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
+	hashedPwd, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
 	if err != nil {
 		return nil, err
 	}
@@ -161,19 +163,19 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 		}
 
 		res, ierr := l.svcCtx.SysUserModel.InsertWithTx(ctx, session, &userModel.SysUser{
-			Username:           req.Username,
-			Password:           string(hashedPwd),
-			Nickname:           req.Nickname,
+			Username: req.Username,
+			Password: string(hashedPwd),
+			Nickname: req.Nickname,
 			// 审计 L-R17-2:显式赋 `sql.NullString{Valid: false}` 把 DB 列落为 NULL。Go 结构体
 			// 零值本身也是 `{Valid:false, String:""}`,行为相同;但显式写出来锁住语义——后续如
 			// 有人把 SysUser.Avatar 改成纯 string 时,这里会立刻产生类型编译错误,避免"零值依赖"
 			// 静默降级为落 `''` 空串(与历史 NULL 行并存的脏数据状态)。
-			Avatar:             sql.NullString{Valid: false},
-			Email:              req.Email,
-			Phone:              req.Phone,
-			Remark:             req.Remark,
-			DeptId:             req.DeptId,
-			IsSuperAdmin:       consts.IsSuperAdminNo,
+			Avatar:       sql.NullString{Valid: false},
+			Email:        req.Email,
+			Phone:        req.Phone,
+			Remark:       req.Remark,
+			DeptId:       req.DeptId,
+			IsSuperAdmin: consts.IsSuperAdminNo,
 			// 管理员代填的初始密码默认要求首次登录必须修改,降低"管理员口头下发后长期不换、口令库泄露即广义失陷"的风险(见审计 L-1)。
 			MustChangePassword: consts.MustChangePasswordYes,
 			Status:             consts.StatusEnabled,
@@ -206,5 +208,23 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 			logx.Field("username", req.Username),
 		)
 	}
-	return &types.IdResp{Id: id}, nil
+
+	ticket, err := util.GenerateRandomHex(32)
+	if err != nil {
+		l.Errorf("createUser: generate ticket failed: %v", err)
+		return nil, response.NewCodeError(503, "凭证服务暂时不可用,请稍后重试")
+	}
+	payload := userCredentialsPayload{Username: req.Username, Password: plainPassword}
+	buf, _ := json.Marshal(&payload)
+	key := userCredentialsKeyPrefix + ticket
+	if err := l.svcCtx.Redis.SetexCtx(l.ctx, key, string(buf), int(userCredentialsTTL/time.Second)); err != nil {
+		l.Errorf("createUser: redis setex failed: %v", err)
+		return nil, response.NewCodeError(503, "凭证服务暂时不可用,请稍后重试")
+	}
+
+	return &types.CreateUserResp{
+		Id:                   id,
+		CredentialsTicket:    ticket,
+		CredentialsExpiresAt: time.Now().Unix() + int64(userCredentialsTTL/time.Second),
+	}, nil
 }

+ 0 - 1
internal/logic/user/createUserLogic_mock_test.go

@@ -39,7 +39,6 @@ func TestCreateUser_Mock_InsertDuplicate1062(t *testing.T) {
 	logic := NewCreateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: "dupuser",
-		Password: "Pass123456",
 	})
 
 	require.Error(t, err)

+ 1 - 127
internal/logic/user/createUserLogic_test.go

@@ -81,7 +81,6 @@ func TestCreateUser_Success(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	resp, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "Pass123456",
 		Nickname: "测试用户",
 		Email:    username + "@test.com",
 		Phone:    "13800138000",
@@ -116,7 +115,6 @@ func TestCreateUser_UsernameExists(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "Pass456789",
 	})
 	require.Error(t, err)
 
@@ -134,7 +132,6 @@ func TestCreateUser_InvalidEmail(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: testutil.UniqueId(),
-		Password: "Pass123456",
 		Email:    "not-an-email",
 	})
 	require.Error(t, err)
@@ -155,7 +152,6 @@ func TestCreateUser_ValidEmail(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	resp, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "Pass123456",
 		Email:    username + "@example.com",
 	})
 	require.NoError(t, err)
@@ -178,7 +174,6 @@ func TestCreateUser_EmptyEmailSkipsValidation(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	resp, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "Pass123456",
 		Email:    "",
 	})
 	require.NoError(t, err)
@@ -199,7 +194,6 @@ func TestCreateUser_InvalidPhone(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: testutil.UniqueId(),
-		Password: "Pass123456",
 		Phone:    "abc",
 	})
 	require.Error(t, err)
@@ -220,7 +214,6 @@ func TestCreateUser_ValidPhone(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	resp, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "Pass123456",
 		Phone:    "13900139000",
 	})
 	require.NoError(t, err)
@@ -243,7 +236,6 @@ func TestCreateUser_EmptyPhoneSkipsValidation(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	resp, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "Pass123456",
 		Phone:    "",
 	})
 	require.NoError(t, err)
@@ -276,7 +268,6 @@ func TestCreateUser_ConcurrentSameUsername(t *testing.T) {
 			logic := NewCreateUserLogic(ctx, svcCtx)
 			_, err := logic.CreateUser(&types.CreateUserReq{
 				Username: username,
-				Password: "Pass123456",
 				Nickname: "并发测试用户",
 			})
 			results <- err
@@ -318,7 +309,6 @@ func TestCreateUser_ValidInternationalPhone(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	resp, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "Pass123456",
 		Phone:    "+8613800138000",
 	})
 	require.NoError(t, err)
@@ -331,105 +321,12 @@ func TestCreateUser_ValidInternationalPhone(t *testing.T) {
 	assert.Equal(t, "+8613800138000", user.Phone)
 }
 
-// TC-0145: 密码少于8字符
-func TestCreateUser_PasswordTooShort(t *testing.T) {
-	ctx := ctxhelper.SuperAdminCtx()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-
-	logic := NewCreateUserLogic(ctx, svcCtx)
-	_, err := logic.CreateUser(&types.CreateUserReq{
-		Username: testutil.UniqueId(),
-		Password: "Pas1234",
-	})
-	require.Error(t, err)
-
-	var codeErr *response.CodeError
-	require.True(t, errors.As(err, &codeErr))
-	assert.Equal(t, 400, codeErr.Code())
-	assert.Equal(t, "密码长度不能少于8个字符", codeErr.Error())
-}
-
-// TC-0146: 密码缺少大写字母
-func TestCreateUser_PasswordNoUppercase(t *testing.T) {
-	ctx := ctxhelper.SuperAdminCtx()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-
-	logic := NewCreateUserLogic(ctx, svcCtx)
-
-	longPwd := "A" + strings.Repeat("a", 71) + "1"
-	_, err := logic.CreateUser(&types.CreateUserReq{
-		Username: testutil.UniqueId(),
-		Password: longPwd,
-	})
-	require.Error(t, err)
-
-	var codeErr *response.CodeError
-	require.True(t, errors.As(err, &codeErr))
-	assert.Equal(t, 400, codeErr.Code())
-	assert.Equal(t, "密码长度不能超过72个字符", codeErr.Error())
-}
-
-// TC-0147: 密码缺少小写字母
-func TestCreateUser_PasswordNoLowercase(t *testing.T) {
-	ctx := ctxhelper.SuperAdminCtx()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-
-	logic := NewCreateUserLogic(ctx, svcCtx)
-	_, err := logic.CreateUser(&types.CreateUserReq{
-		Username: testutil.UniqueId(),
-		Password: "PASS123456",
-	})
-	require.Error(t, err)
-
-	var codeErr *response.CodeError
-	require.True(t, errors.As(err, &codeErr))
-	assert.Equal(t, 400, codeErr.Code())
-	assert.Equal(t, "密码必须包含大写字母、小写字母和数字", codeErr.Error())
-}
-
-// TC-0148: 密码缺少数字
-func TestCreateUser_PasswordNoDigit(t *testing.T) {
-	ctx := ctxhelper.SuperAdminCtx()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-
-	logic := NewCreateUserLogic(ctx, svcCtx)
-	_, err := logic.CreateUser(&types.CreateUserReq{
-		Username: testutil.UniqueId(),
-		Password: "Passpasspass",
-	})
-	require.Error(t, err)
-
-	var codeErr *response.CodeError
-	require.True(t, errors.As(err, &codeErr))
-	assert.Equal(t, 400, codeErr.Code())
-	assert.Equal(t, "密码必须包含大写字母、小写字母和数字", codeErr.Error())
-}
-
-// TC-0149: 密码超过72字符
-func TestCreateUser_PasswordTooLong(t *testing.T) {
-	ctx := ctxhelper.SuperAdminCtx()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-
-	longPwd := strings.Repeat("a", 73)
-	logic := NewCreateUserLogic(ctx, svcCtx)
-	_, err := logic.CreateUser(&types.CreateUserReq{
-		Username: testutil.UniqueId(),
-		Password: longPwd,
-	})
-	require.Error(t, err)
-
-	var codeErr *response.CodeError
-	require.True(t, errors.As(err, &codeErr))
-	assert.Equal(t, 400, codeErr.Code())
-	assert.Equal(t, "密码长度不能超过72个字符", codeErr.Error())
-}
-
 // TC-0537: createUser非管理员拒绝
 func TestCreateUser_MemberRejected(t *testing.T) {
 	ctx := ctxhelper.MemberCtx("test_product")
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	logic := NewCreateUserLogic(ctx, svcCtx)
-	_, err := logic.CreateUser(&types.CreateUserReq{Username: "test", Password: "Pass123456"})
+	_, err := logic.CreateUser(&types.CreateUserReq{Username: "test"})
 	require.Error(t, err)
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
@@ -444,7 +341,6 @@ func TestCreateUser_UsernameInvalidChars(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: "user@name!",
-		Password: "Pass123456",
 	})
 	require.Error(t, err)
 
@@ -462,7 +358,6 @@ func TestCreateUser_UsernameTooShort(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: "a",
-		Password: "Pass123456",
 	})
 	require.Error(t, err)
 
@@ -480,7 +375,6 @@ func TestCreateUser_UsernameTooLong(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: strings.Repeat("a", 65),
-		Password: "Pass123456",
 	})
 	require.Error(t, err)
 
@@ -498,7 +392,6 @@ func TestCreateUser_DeptNotExists(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: testutil.UniqueId(),
-		Password: "Pass123456",
 		DeptId:   999999999,
 	})
 	require.Error(t, err)
@@ -520,7 +413,6 @@ func TestCreateUser_NicknameTooLong(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: testutil.UniqueId(),
-		Password: "Pass123456",
 		Nickname: strings.Repeat("n", 65),
 	})
 	require.Error(t, err)
@@ -539,7 +431,6 @@ func TestCreateUser_RemarkTooLong(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: testutil.UniqueId(),
-		Password: "Pass123456",
 		Remark:   strings.Repeat("r", 256),
 	})
 	require.Error(t, err)
@@ -577,7 +468,6 @@ func TestCreateUser_AllOptionalFields(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	resp, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "Pass123456",
 		Nickname: "全字段用户",
 		Email:    username + "@example.com",
 		Phone:    "13900001111",
@@ -631,7 +521,6 @@ func TestCreateUser_MN4_AdminCannotCreateOutsideDeptSubtree(t *testing.T) {
 	adminCtx := callerAdminCtx(777771, callerDeptId, "/100/")
 	_, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{
 		Username: "mn4_seed_" + testutil.UniqueId(),
-		Password: "Pass123456",
 		DeptId:   outsideDeptId,
 	})
 	require.Error(t, err)
@@ -659,7 +548,6 @@ func TestCreateUser_MN4_AdminCanCreateInsideDeptSubtree(t *testing.T) {
 	username := "mn4ok_" + testutil.UniqueId()
 	resp, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "Pass123456",
 		DeptId:   childDeptId,
 	})
 	require.NoError(t, err)
@@ -687,7 +575,6 @@ func TestCreateUser_MN4_SuperAdminCanCreateAnywhere(t *testing.T) {
 	usernameDept := "mn4sa_dept_" + testutil.UniqueId()
 	resp, err := NewCreateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateUser(&types.CreateUserReq{
 		Username: usernameDept,
-		Password: "Pass123456",
 		DeptId:   randomDeptId,
 	})
 	require.NoError(t, err)
@@ -698,7 +585,6 @@ func TestCreateUser_MN4_SuperAdminCanCreateAnywhere(t *testing.T) {
 	usernameZero := "mn4sa_zero_" + testutil.UniqueId()
 	respZero, err := NewCreateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateUser(&types.CreateUserReq{
 		Username: usernameZero,
-		Password: "Pass123456",
 		DeptId:   0,
 	})
 	require.NoError(t, err)
@@ -721,7 +607,6 @@ func TestCreateUser_MN4_EmptyCallerDeptPathRejected(t *testing.T) {
 	adminCtx := callerAdminCtx(777773, 0, "")
 	_, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{
 		Username: "mn4empty_" + testutil.UniqueId(),
-		Password: "Pass123456",
 		DeptId:   dstDeptId,
 	})
 	require.Error(t, err)
@@ -746,7 +631,6 @@ func TestCreateUser_MN4_NonSuperAdminMustSpecifyDept(t *testing.T) {
 	adminCtx := callerAdminCtx(777774, callerDeptId, "/300/")
 	_, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{
 		Username: "mn4must_" + testutil.UniqueId(),
-		Password: "Pass123456",
 		DeptId:   0,
 	})
 	require.Error(t, err)
@@ -777,7 +661,6 @@ func TestCreateUser_LN2_TargetDeptDisabled(t *testing.T) {
 	// 超管也必须被拒绝: 的规则针对"所有调用方",防止 disabled 部门被意外重新填人
 	_, err = NewCreateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateUser(&types.CreateUserReq{
 		Username: "ln2_" + testutil.UniqueId(),
-		Password: "Pass123456",
 		DeptId:   disabledId,
 	})
 	require.Error(t, err)
@@ -808,7 +691,6 @@ func TestCreateUser_NegativeDeptIdRejected(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			_, err := NewCreateUserLogic(tc.ctx, svcCtx).CreateUser(&types.CreateUserReq{
 				Username: "neg_dept_" + testutil.UniqueId(),
-				Password: "Pass123456",
 				DeptId:   tc.deptId,
 			})
 			require.Error(t, err, "负值 DeptId 必须被拒绝")
@@ -830,7 +712,6 @@ func TestCreateUser_DefaultsMustChangePasswordToYes(t *testing.T) {
 	username := "lcp_" + testutil.UniqueId()
 	resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "InitPass@123",
 		Nickname: "初始口令校验",
 	})
 	require.NoError(t, err)
@@ -865,7 +746,6 @@ func TestCreateUser_H_R14_1_AdminCannotCreateInDevDept(t *testing.T) {
 	adminCtx := callerAdminCtx(888881, adminDeptId, "/9100/")
 	_, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{
 		Username: "h_r14_1_c_" + testutil.UniqueId(),
-		Password: "Pass123456",
 		DeptId:   devDeptId,
 	})
 	require.Error(t, err, "ADMIN 在 DEV 部门创建用户必须被拒绝")
@@ -898,7 +778,6 @@ func TestCreateUser_H_R14_1_SuperAdminCanCreateInDevDept(t *testing.T) {
 	username := "h_r14_1_c_su_" + testutil.UniqueId()
 	resp, err := NewCreateUserLogic(superCtx, svcCtx).CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "Pass123456",
 		DeptId:   devDeptId,
 	})
 	require.NoError(t, err, "SuperAdmin 在 DEV 部门创建用户必须允许")
@@ -949,7 +828,6 @@ func TestCreateUser_L_R17_1_ReservedUsernamePrefix_NonSuperAdmin(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			_, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{
 				Username: tc.username,
-				Password: "Pass123456",
 				DeptId:   callerDeptId,
 			})
 			require.Error(t, err, "非超管以保留前缀创建用户必须被拒绝")
@@ -978,7 +856,6 @@ func TestCreateUser_L_R17_1_ReservedUsernamePrefix_SuperAdminAllowed(t *testing.
 	username := "svc_" + testutil.UniqueId()
 	resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "Pass123456",
 		DeptId:   0,
 	})
 	require.NoError(t, err, "SuperAdmin 不受保留前缀约束")
@@ -1004,7 +881,6 @@ func TestCreateUser_L_R17_2_AvatarExplicitNull(t *testing.T) {
 	username := "l_r17_2_" + testutil.UniqueId()
 	resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "Pass123456",
 		DeptId:   0,
 	})
 	require.NoError(t, err)
@@ -1095,7 +971,6 @@ func TestCreateUser_H_R17_1_InsertRunsInsideTxWithSharedDeptLock(t *testing.T) {
 
 	resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
 		Username: "h_r17_1_" + testutil.UniqueId(),
-		Password: "Pass123456",
 		DeptId:   targetDeptId,
 	})
 	require.NoError(t, err)
@@ -1148,7 +1023,6 @@ func TestCreateUser_H_R17_1_DeptConcurrentlyDeletedRejected(t *testing.T) {
 
 	_, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
 		Username: "h_r17_1_del_" + testutil.UniqueId(),
-		Password: "Pass123456",
 		DeptId:   targetDeptId,
 	})
 	require.Error(t, err)

+ 13 - 0
internal/logic/user/credentials.go

@@ -0,0 +1,13 @@
+package user
+
+import "time"
+
+const (
+	userCredentialsTTL       = 5 * time.Minute
+	userCredentialsKeyPrefix = "usr:cred:"
+)
+
+type userCredentialsPayload struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+}

+ 367 - 0
internal/logic/user/credentialsLogic_test.go

@@ -0,0 +1,367 @@
+package user
+
+import (
+	"errors"
+	"sync"
+	"sync/atomic"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	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/types"
+	"perms-system-server/internal/util"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TC-1285 超管重置普通用户密码
+func TestResetPassword_SuperAdmin(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	hashed := testutil.HashPassword("OldPass123")
+	userId := insertTestUser(t, ctx, username, hashed)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+
+	logic := NewResetPasswordLogic(ctx, svcCtx)
+	resp, err := logic.ResetPassword(&types.ResetPasswordReq{UserId: userId})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.NotEmpty(t, resp.CredentialsTicket)
+	assert.Greater(t, resp.CredentialsExpiresAt, int64(0))
+
+	// 清理 Redis ticket
+	t.Cleanup(func() { svcCtx.Redis.DelCtx(ctx, userCredentialsKeyPrefix+resp.CredentialsTicket) })
+}
+
+// TC-1287 不能重置超管密码
+func TestResetPassword_CannotResetSuperAdmin(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtxWithUserId(999)
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	hashed := testutil.HashPassword("Pass123456")
+	superUserId := insertTestUserFull(t, ctx, &userModel.SysUser{
+		Username:           username,
+		Password:           hashed,
+		IsSuperAdmin:       consts.IsSuperAdminYes,
+		Status:             consts.StatusEnabled,
+		MustChangePassword: consts.MustChangePasswordNo,
+	})
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", superUserId) })
+
+	logic := NewResetPasswordLogic(ctx, svcCtx)
+	_, err := logic.ResetPassword(&types.ResetPasswordReq{UserId: superUserId})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 403, codeErr.Code())
+	assert.Contains(t, codeErr.Error(), "超级管理员")
+}
+
+// TC-1288 MEMBER无权重置
+func TestResetPassword_MemberForbidden(t *testing.T) {
+	ctx := ctxhelper.MemberCtx("test-product")
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	logic := NewResetPasswordLogic(ctx, svcCtx)
+	_, err := logic.ResetPassword(&types.ResetPasswordReq{UserId: 1})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 403, codeErr.Code())
+}
+
+// TC-1289 重置后旧token失效(tokenVersion递增)
+func TestResetPassword_TokenVersionIncremented(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	hashed := testutil.HashPassword("OldPass123")
+	userId := insertTestUser(t, ctx, username, hashed)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+
+	userBefore, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+
+	logic := NewResetPasswordLogic(ctx, svcCtx)
+	resp, err := logic.ResetPassword(&types.ResetPasswordReq{UserId: userId})
+	require.NoError(t, err)
+	t.Cleanup(func() { svcCtx.Redis.DelCtx(ctx, userCredentialsKeyPrefix+resp.CredentialsTicket) })
+
+	userAfter, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, userBefore.TokenVersion+1, userAfter.TokenVersion)
+}
+
+// TC-1290 重置后mustChangePassword=1
+func TestResetPassword_MustChangePassword(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	hashed := testutil.HashPassword("OldPass123")
+	userId := insertTestUserFull(t, ctx, &userModel.SysUser{
+		Username:           username,
+		Password:           hashed,
+		IsSuperAdmin:       consts.IsSuperAdminNo,
+		Status:             consts.StatusEnabled,
+		MustChangePassword: consts.MustChangePasswordNo,
+	})
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+
+	logic := NewResetPasswordLogic(ctx, svcCtx)
+	resp, err := logic.ResetPassword(&types.ResetPasswordReq{UserId: userId})
+	require.NoError(t, err)
+	t.Cleanup(func() { svcCtx.Redis.DelCtx(ctx, userCredentialsKeyPrefix+resp.CredentialsTicket) })
+
+	user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(consts.MustChangePasswordYes), user.MustChangePassword)
+}
+
+// TC-1291 目标用户不存在
+func TestResetPassword_UserNotFound(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	logic := NewResetPasswordLogic(ctx, svcCtx)
+	_, err := logic.ResetPassword(&types.ResetPasswordReq{UserId: 999999999})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 404, codeErr.Code())
+}
+
+// TC-1293 重置后用新密码可验证
+func TestResetPassword_NewPasswordValid(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	hashed := testutil.HashPassword("OldPass123")
+	userId := insertTestUser(t, ctx, username, hashed)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+
+	logic := NewResetPasswordLogic(ctx, svcCtx)
+	resp, err := logic.ResetPassword(&types.ResetPasswordReq{UserId: userId})
+	require.NoError(t, err)
+	t.Cleanup(func() { svcCtx.Redis.DelCtx(ctx, userCredentialsKeyPrefix+resp.CredentialsTicket) })
+
+	fetchLogic := NewFetchUserCredentialsLogic(ctx, svcCtx)
+	cred, err := fetchLogic.FetchUserCredentials(&types.FetchUserCredentialsReq{Ticket: resp.CredentialsTicket})
+	require.NoError(t, err)
+	assert.Equal(t, username, cred.Username)
+	assert.NotEmpty(t, cred.Password)
+
+	msg := util.ValidatePassword(cred.Password)
+	assert.Empty(t, msg)
+}
+
+// TC-1294 正常消费ticket
+func TestFetchUserCredentials_Success(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	createLogic := NewCreateUserLogic(ctx, svcCtx)
+	createResp, err := createLogic.CreateUser(&types.CreateUserReq{
+		Username: username,
+		DeptId:   0,
+	})
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", createResp.Id) })
+
+	fetchLogic := NewFetchUserCredentialsLogic(ctx, svcCtx)
+	cred, err := fetchLogic.FetchUserCredentials(&types.FetchUserCredentialsReq{Ticket: createResp.CredentialsTicket})
+	require.NoError(t, err)
+	assert.Equal(t, username, cred.Username)
+	assert.NotEmpty(t, cred.Password)
+}
+
+// TC-1295 二次消费同一ticket
+func TestFetchUserCredentials_ConsumedTwice(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	createLogic := NewCreateUserLogic(ctx, svcCtx)
+	createResp, err := createLogic.CreateUser(&types.CreateUserReq{
+		Username: username,
+		DeptId:   0,
+	})
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", createResp.Id) })
+
+	fetchLogic := NewFetchUserCredentialsLogic(ctx, svcCtx)
+	_, err = fetchLogic.FetchUserCredentials(&types.FetchUserCredentialsReq{Ticket: createResp.CredentialsTicket})
+	require.NoError(t, err)
+
+	_, err = fetchLogic.FetchUserCredentials(&types.FetchUserCredentialsReq{Ticket: createResp.CredentialsTicket})
+	require.Error(t, err)
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Contains(t, codeErr.Error(), "无效或已过期")
+}
+
+// TC-1296 空ticket
+func TestFetchUserCredentials_EmptyTicket(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	fetchLogic := NewFetchUserCredentialsLogic(ctx, svcCtx)
+	_, err := fetchLogic.FetchUserCredentials(&types.FetchUserCredentialsReq{Ticket: ""})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Contains(t, codeErr.Error(), "ticket 不能为空")
+}
+
+// TC-1297 非法ticket
+func TestFetchUserCredentials_InvalidTicket(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	fetchLogic := NewFetchUserCredentialsLogic(ctx, svcCtx)
+	_, err := fetchLogic.FetchUserCredentials(&types.FetchUserCredentialsReq{Ticket: "random_garbage_ticket_value"})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Contains(t, codeErr.Error(), "无效或已过期")
+}
+
+// TC-1298 非ADMIN无权消费
+func TestFetchUserCredentials_MemberForbidden(t *testing.T) {
+	ctx := ctxhelper.MemberCtx("test-product")
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	fetchLogic := NewFetchUserCredentialsLogic(ctx, svcCtx)
+	_, err := fetchLogic.FetchUserCredentials(&types.FetchUserCredentialsReq{Ticket: "any_ticket"})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 403, codeErr.Code())
+}
+
+// TC-1299 并发消费同一ticket
+func TestFetchUserCredentials_ConcurrentConsume(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	createLogic := NewCreateUserLogic(ctx, svcCtx)
+	createResp, err := createLogic.CreateUser(&types.CreateUserReq{
+		Username: username,
+		DeptId:   0,
+	})
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", createResp.Id) })
+
+	var successCount atomic.Int32
+	var wg sync.WaitGroup
+	for i := 0; i < 10; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			fetchLogic := NewFetchUserCredentialsLogic(ctx, svcCtx)
+			_, ferr := fetchLogic.FetchUserCredentials(&types.FetchUserCredentialsReq{Ticket: createResp.CredentialsTicket})
+			if ferr == nil {
+				successCount.Add(1)
+			}
+		}()
+	}
+	wg.Wait()
+	assert.Equal(t, int32(1), successCount.Load())
+}
+
+// TC-1280 正常创建用户返回ticket
+func TestCreateUser_TicketFlow(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	logic := NewCreateUserLogic(ctx, svcCtx)
+	resp, err := logic.CreateUser(&types.CreateUserReq{
+		Username: username,
+		Nickname: "测试用户",
+		DeptId:   0,
+	})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.Greater(t, resp.Id, int64(0))
+	assert.NotEmpty(t, resp.CredentialsTicket)
+	assert.Greater(t, resp.CredentialsExpiresAt, int64(0))
+
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) })
+}
+
+// TC-1282 生成密码满足强度要求
+func TestCreateUser_PasswordStrength(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	createLogic := NewCreateUserLogic(ctx, svcCtx)
+	createResp, err := createLogic.CreateUser(&types.CreateUserReq{
+		Username: username,
+		DeptId:   0,
+	})
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", createResp.Id) })
+
+	fetchLogic := NewFetchUserCredentialsLogic(ctx, svcCtx)
+	cred, err := fetchLogic.FetchUserCredentials(&types.FetchUserCredentialsReq{Ticket: createResp.CredentialsTicket})
+	require.NoError(t, err)
+
+	msg := util.ValidatePassword(cred.Password)
+	assert.Empty(t, msg, "generated password should pass strength validation")
+}
+
+// TC-1284 创建后mustChangePassword=1
+func TestCreateUser_MustChangePassword(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	logic := NewCreateUserLogic(ctx, svcCtx)
+	resp, err := logic.CreateUser(&types.CreateUserReq{
+		Username: username,
+		DeptId:   0,
+	})
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id)
+		svcCtx.Redis.DelCtx(ctx, userCredentialsKeyPrefix+resp.CredentialsTicket)
+	})
+
+	user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id)
+	require.NoError(t, err)
+	assert.Equal(t, int64(consts.MustChangePasswordYes), user.MustChangePassword)
+}

+ 60 - 0
internal/logic/user/fetchUserCredentialsLogic.go

@@ -0,0 +1,60 @@
+package user
+
+import (
+	"context"
+	"encoding/json"
+
+	authHelper "perms-system-server/internal/logic/auth"
+	"perms-system-server/internal/middleware"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+
+	"github.com/zeromicro/go-zero/core/logx"
+)
+
+type FetchUserCredentialsLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewFetchUserCredentialsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *FetchUserCredentialsLogic {
+	return &FetchUserCredentialsLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+func (l *FetchUserCredentialsLogic) FetchUserCredentials(req *types.FetchUserCredentialsReq) (resp *types.FetchUserCredentialsResp, err error) {
+	productCode := middleware.GetProductCode(l.ctx)
+	if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
+		return nil, err
+	}
+
+	if req.Ticket == "" {
+		return nil, response.ErrBadRequest("ticket 不能为空")
+	}
+
+	key := userCredentialsKeyPrefix + req.Ticket
+	val, err := l.svcCtx.Redis.GetDelCtx(l.ctx, key)
+	if err != nil {
+		logx.WithContext(l.ctx).Errorf("FetchUserCredentials: redis getdel failed: %v", err)
+		return nil, response.NewCodeError(503, "凭证服务暂时不可用,请稍后重试")
+	}
+	if val == "" {
+		return nil, response.ErrBadRequest("凭证票据无效或已过期")
+	}
+
+	var payload userCredentialsPayload
+	if err := json.Unmarshal([]byte(val), &payload); err != nil {
+		logx.WithContext(l.ctx).Errorf("FetchUserCredentials: unmarshal payload failed: %v", err)
+		return nil, response.NewCodeError(500, "凭证数据异常,请联系管理员")
+	}
+
+	return &types.FetchUserCredentialsResp{
+		Username: payload.Username,
+		Password: payload.Password,
+	}, nil
+}

+ 94 - 0
internal/logic/user/resetPasswordLogic.go

@@ -0,0 +1,94 @@
+package user
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"time"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	authHelper "perms-system-server/internal/logic/auth"
+	"perms-system-server/internal/middleware"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+	"perms-system-server/internal/util"
+
+	"github.com/zeromicro/go-zero/core/logx"
+	"golang.org/x/crypto/bcrypt"
+)
+
+type ResetPasswordLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewResetPasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ResetPasswordLogic {
+	return &ResetPasswordLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordReq) (resp *types.ResetPasswordResp, err error) {
+	productCode := middleware.GetProductCode(l.ctx)
+	if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
+		return nil, err
+	}
+
+	target, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.UserId)
+	if err != nil {
+		return nil, response.ErrNotFound("用户不存在")
+	}
+
+	if target.IsSuperAdmin == consts.IsSuperAdminYes {
+		return nil, response.ErrForbidden("不能重置超级管理员的密码")
+	}
+
+	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, productCode); err != nil {
+		return nil, err
+	}
+
+	plainPassword, err := util.GenerateStrongInitialPassword(16)
+	if err != nil {
+		return nil, err
+	}
+
+	hashedPwd, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := l.svcCtx.SysUserModel.UpdatePassword(l.ctx, target.Id, target.Username, string(hashedPwd), consts.MustChangePasswordYes, target.UpdateTime); err != nil {
+		if errors.Is(err, userModel.ErrUpdateConflict) {
+			return nil, response.ErrConflict("数据已被其他操作修改,请刷新后重试")
+		}
+		return nil, err
+	}
+
+	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
+	defer cancel()
+	l.svcCtx.UserDetailsLoader.Clean(cleanCtx, target.Id)
+
+	ticket, err := util.GenerateRandomHex(32)
+	if err != nil {
+		l.Errorf("resetPassword: generate ticket failed: %v", err)
+		return nil, response.NewCodeError(503, "凭证服务暂时不可用,请稍后重试")
+	}
+	payload := userCredentialsPayload{Username: target.Username, Password: plainPassword}
+	buf, _ := json.Marshal(&payload)
+	key := userCredentialsKeyPrefix + ticket
+	if err := l.svcCtx.Redis.SetexCtx(l.ctx, key, string(buf), int(userCredentialsTTL/time.Second)); err != nil {
+		l.Errorf("resetPassword: redis setex failed: %v", err)
+		return nil, response.NewCodeError(503, "凭证服务暂时不可用,请稍后重试")
+	}
+
+	return &types.ResetPasswordResp{
+		CredentialsTicket:    ticket,
+		CredentialsExpiresAt: time.Now().Unix() + int64(userCredentialsTTL/time.Second),
+	}, nil
+}

+ 23 - 4
internal/model/userrole/sysUserRoleModel_test.go

@@ -123,8 +123,13 @@ func TestSysUserRoleModel_FindRoleIdsByUserId(t *testing.T) {
 	conn := testutil.GetTestSqlConn()
 	m := NewSysUserRoleModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
 
+	productCode := testutil.UniqueId()
 	userId := randUserRoleId()
-	r1, r2 := randUserRoleId(), randUserRoleId()
+	r1 := insertTestRole(t, ctx, conn, productCode, "role1_"+testutil.UniqueId())
+	r2 := insertTestRole(t, ctx, conn, productCode, "role2_"+testutil.UniqueId())
+	defer func() {
+		conn.ExecCtx(ctx, "DELETE FROM `sys_role` WHERE `id` IN (?, ?)", r1, r2)
+	}()
 	ts := time.Now().Unix()
 
 	var ids []int64
@@ -1087,8 +1092,14 @@ func TestSysUserRoleModel_DeleteByUserIdAndRoleIdsTx(t *testing.T) {
 	conn := testutil.GetTestSqlConn()
 	m := NewSysUserRoleModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
 
+	productCode := testutil.UniqueId()
 	userId := randUserRoleId()
-	r1, r2, r3 := randUserRoleId(), randUserRoleId(), randUserRoleId()
+	r1 := insertTestRole(t, ctx, conn, productCode, "r1_"+testutil.UniqueId())
+	r2 := insertTestRole(t, ctx, conn, productCode, "r2_"+testutil.UniqueId())
+	r3 := insertTestRole(t, ctx, conn, productCode, "r3_"+testutil.UniqueId())
+	defer func() {
+		conn.ExecCtx(ctx, "DELETE FROM `sys_role` WHERE `id` IN (?, ?, ?)", r1, r2, r3)
+	}()
 	ts := time.Now().Unix()
 
 	var recIds []int64
@@ -1121,8 +1132,12 @@ func TestSysUserRoleModel_DeleteByUserIdAndRoleIdsTx_EmptyIsNoop(t *testing.T) {
 	conn := testutil.GetTestSqlConn()
 	m := NewSysUserRoleModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
 
+	productCode := testutil.UniqueId()
 	userId := randUserRoleId()
-	roleId := randUserRoleId()
+	roleId := insertTestRole(t, ctx, conn, productCode, "role_"+testutil.UniqueId())
+	defer func() {
+		conn.ExecCtx(ctx, "DELETE FROM `sys_role` WHERE `id` = ?", roleId)
+	}()
 	ts := time.Now().Unix()
 	res, err := m.Insert(ctx, &SysUserRole{UserId: userId, RoleId: roleId, CreateTime: ts, UpdateTime: ts})
 	require.NoError(t, err)
@@ -1147,8 +1162,12 @@ func TestSysUserRoleModel_DeleteByUserIdAndRoleIdsTx_OtherUserNotAffected(t *tes
 	conn := testutil.GetTestSqlConn()
 	m := NewSysUserRoleModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
 
+	productCode := testutil.UniqueId()
 	u1, u2 := randUserRoleId(), randUserRoleId()
-	roleId := randUserRoleId()
+	roleId := insertTestRole(t, ctx, conn, productCode, "role_"+testutil.UniqueId())
+	defer func() {
+		conn.ExecCtx(ctx, "DELETE FROM `sys_role` WHERE `id` = ?", roleId)
+	}()
 	ts := time.Now().Unix()
 
 	res1, err := m.Insert(ctx, &SysUserRole{UserId: u1, RoleId: roleId, CreateTime: ts, UpdateTime: ts})

+ 1 - 0
internal/testutil/mocks/helper.go

@@ -64,6 +64,7 @@ func NewMockServiceContext(m MockModels) *svc.ServiceContext {
 	return &svc.ServiceContext{
 		Config:            cfg,
 		Models:            models,
+		Redis:             rds,
 		UserDetailsLoader: loaders.NewUserDetailsLoader(rds, cfg.CacheRedis.KeyPrefix, models),
 	}
 }

+ 24 - 1
internal/types/types.go

@@ -87,7 +87,6 @@ type CreateRoleReq struct {
 
 type CreateUserReq struct {
 	Username string `json:"username"`
-	Password string `json:"password"`
 	Nickname string `json:"nickname,optional"`
 	Email    string `json:"email,optional"`
 	Phone    string `json:"phone,optional"`
@@ -95,6 +94,12 @@ type CreateUserReq struct {
 	DeptId   int64  `json:"deptId,optional"`
 }
 
+type CreateUserResp struct {
+	Id                   int64  `json:"id"`
+	CredentialsTicket    string `json:"credentialsTicket"`
+	CredentialsExpiresAt int64  `json:"credentialsExpiresAt"`
+}
+
 type DeleteDeptReq struct {
 	Id int64 `json:"id"`
 }
@@ -127,6 +132,15 @@ type FetchInitialCredentialsResp struct {
 	AdminPassword string `json:"adminPassword"`
 }
 
+type FetchUserCredentialsReq struct {
+	Ticket string `json:"ticket"`
+}
+
+type FetchUserCredentialsResp struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+}
+
 type GetUserPermsReq struct {
 	UserId int64 `json:"userId"`
 }
@@ -227,6 +241,15 @@ type RemoveMemberReq struct {
 	Id int64 `json:"id"`
 }
 
+type ResetPasswordReq struct {
+	UserId int64 `json:"userId"`
+}
+
+type ResetPasswordResp struct {
+	CredentialsTicket    string `json:"credentialsTicket"`
+	CredentialsExpiresAt int64  `json:"credentialsExpiresAt"`
+}
+
 type RoleDetailReq struct {
 	Id int64 `json:"id"`
 }

+ 89 - 0
internal/util/credential.go

@@ -0,0 +1,89 @@
+package util
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"fmt"
+)
+
+func GenerateRandomHex(byteLen int) (string, error) {
+	b := make([]byte, byteLen)
+	if _, err := rand.Read(b); err != nil {
+		return "", fmt.Errorf("generate random bytes failed: %w", err)
+	}
+	return hex.EncodeToString(b), nil
+}
+
+// generateStrongInitialPassword 为账号生成强密码:大写+小写+数字+少量符号混合,
+// 并通过 util.ValidatePassword 断言,确保任何后续合规复核都不会卡住(见审计 L-R10-2)。
+// 长度要求 n >= 8;实际生成 n 个字符,混合字符集字面量已刻意去掉易混淆的 I/l/O/0/1。
+func GenerateStrongInitialPassword(n int) (string, error) {
+	if n < 8 {
+		n = 8
+	}
+	const (
+		upper   = "ABCDEFGHJKMNPQRSTUVWXYZ"
+		lower   = "abcdefghjkmnpqrstuvwxyz"
+		digits  = "23456789"
+		symbols = "!@#$%^&*"
+	)
+	alphabet := upper + lower + digits + symbols
+	// 把每个字符类至少各取 1 个放在随机位置,保证强度检查一定通过;其余位从总字母表随机。
+	classes := []string{upper, lower, digits, symbols}
+	pwd := make([]byte, n)
+	used := 0
+	for _, cls := range classes {
+		c, err := randomCharFrom(cls)
+		if err != nil {
+			return "", err
+		}
+		pwd[used] = c
+		used++
+	}
+	for i := used; i < n; i++ {
+		c, err := randomCharFrom(alphabet)
+		if err != nil {
+			return "", err
+		}
+		pwd[i] = c
+	}
+	if err := shuffleBytes(pwd); err != nil {
+		return "", err
+	}
+	out := string(pwd)
+	if msg := ValidatePassword(out); msg != "" {
+		// 理论上不可达:上面已按字符类强制填充,保留断言避免字符集未来被人修改后静默失效。
+		return "", fmt.Errorf("generated password failed strength check: %s", msg)
+	}
+	return out, nil
+}
+
+// randomCharFrom 用 crypto/rand 无偏地从字符集中取一个字节。循环直到命中模长内,避免简单取模偏置。
+func randomCharFrom(alphabet string) (byte, error) {
+	max := len(alphabet)
+	bucket := 256 - (256 % max)
+	buf := make([]byte, 1)
+	for {
+		if _, err := rand.Read(buf); err != nil {
+			return 0, err
+		}
+		if int(buf[0]) < bucket {
+			return alphabet[int(buf[0])%max], nil
+		}
+	}
+}
+
+// shuffleBytes Fisher-Yates 洗牌,随机索引基于 crypto/rand;用于打散 generateStrongInitialPassword
+// 里"前 4 个字符恰好是各字符类"的固定前缀,避免格式可预测。
+func shuffleBytes(buf []byte) error {
+	for i := len(buf) - 1; i > 0; i-- {
+		bucket := byte(i + 1)
+		b := make([]byte, 1)
+		if _, err := rand.Read(b); err != nil {
+			return err
+		}
+		j := int(b[0] % bucket)
+		buf[i], buf[j] = buf[j], buf[i]
+	}
+	return nil
+}

+ 29 - 3
perm.api

@@ -252,13 +252,31 @@ type (
 type (
 	CreateUserReq {
 		Username string `json:"username"`
-		Password string `json:"password"`
 		Nickname string `json:"nickname,optional"`
 		Email    string `json:"email,optional"`
 		Phone    string `json:"phone,optional"`
 		Remark   string `json:"remark,optional"`
 		DeptId   int64  `json:"deptId,optional"`
 	}
+	CreateUserResp {
+		Id                   int64  `json:"id"`
+		CredentialsTicket    string `json:"credentialsTicket"`
+		CredentialsExpiresAt int64  `json:"credentialsExpiresAt"`
+	}
+	ResetPasswordReq {
+		UserId int64 `json:"userId"`
+	}
+	ResetPasswordResp {
+		CredentialsTicket    string `json:"credentialsTicket"`
+		CredentialsExpiresAt int64  `json:"credentialsExpiresAt"`
+	}
+	FetchUserCredentialsReq {
+		Ticket string `json:"ticket"`
+	}
+	FetchUserCredentialsResp {
+		Username string `json:"username"`
+		Password string `json:"password"`
+	}
 	UpdateUserReq {
 		Id       int64   `json:"id"`
 		Nickname *string `json:"nickname,optional"`
@@ -589,9 +607,17 @@ service perm-api {
 	middleware: JwtAuth
 )
 service perm-api {
-	// CreateUser 创建用户。新建系统用户账号,可指定部门归属。仅超管可调用
+	// CreateUser 创建用户。服务端生成强密码,返回一次性凭证票据
 	@handler CreateUser
-	post /create (CreateUserReq) returns (IdResp)
+	post /create (CreateUserReq) returns (CreateUserResp)
+
+	// ResetPassword 重置用户密码。生成新随机密码,返回一次性凭证票据
+	@handler ResetPassword
+	post /resetPassword (ResetPasswordReq) returns (ResetPasswordResp)
+
+	// FetchUserCredentials 凭票据一次性领取用户凭证(用户名+密码)
+	@handler FetchUserCredentials
+	post /fetchCredentials (FetchUserCredentialsReq) returns (FetchUserCredentialsResp)
 
 	// UpdateUser 更新用户信息。修改昵称、邮箱、手机、备注、部门归属等
 	@handler UpdateUser

+ 25 - 0
test-design.md

@@ -1475,3 +1475,28 @@ MySQL (InnoDB) + Redis Cache
 | TC-0866 | behindProxy=false + XFF=`1.1.1.1` | RemoteAddr=`5.5.5.5:8080` | 忽略 XFF,返回 `5.5.5.5` | 安全/伪造 | P0 | 不信任客户端注入的头部 |
 
 ---
+
+## 用户凭证票据机制(CreateUser / ResetPassword / FetchUserCredentials)
+
+| TC编号 | 接口/方法 | 测试场景 | 输入参数 | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
+|:---|:---|:---|:---|:---|:---|:---|:---|
+| TC-1280 | POST /api/user/create | 正常创建用户,返回ticket | SuperAdminCtx + valid username/deptId | code=200, resp含id/credentialsTicket/credentialsExpiresAt | 正常路径 | P0 | 服务端生成密码+ticket |
+| TC-1281 | POST /api/user/create | 用ticket领取凭证后可登录 | 创建→fetchCredentials→用返回密码登录 | 登录成功 | 端到端 | P0 | 密码可用性验证 |
+| TC-1282 | POST /api/user/create | 生成密码满足强度要求 | 创建→fetchCredentials→ValidatePassword | ValidatePassword返回"" | 功能验证 | P0 | 密码强度 |
+| TC-1283 | POST /api/user/create | 用户名已存在 | 重复username | code=409 "用户名已存在" | 异常路径 | P0 | 唯一约束 |
+| TC-1284 | POST /api/user/create | 创建后mustChangePassword=1 | 创建→查DB | mustChangePassword=1 | 功能验证 | P0 | 强制改密 |
+| TC-1285 | POST /api/user/resetPassword | 超管重置普通用户密码 | SuperAdminCtx + userId=普通用户 | code=200, resp含ticket | 正常路径 | P0 | 基本重置流程 |
+| TC-1286 | POST /api/user/resetPassword | 产品ADMIN重置本产品成员密码 | AdminCtx + userId=本产品member | code=200 | 正常路径 | P0 | ADMIN权限 |
+| TC-1287 | POST /api/user/resetPassword | 不能重置超管密码 | AdminCtx + userId=superadmin | code=403 "不能重置超级管理员的密码" | 安全 | P0 | 超管保护 |
+| TC-1288 | POST /api/user/resetPassword | MEMBER无权重置 | MemberCtx + userId=其他用户 | code=403 | 安全 | P0 | 权限校验 |
+| TC-1289 | POST /api/user/resetPassword | 重置后旧token失效 | 重置前记录tokenVersion,重置后比对 | tokenVersion递增 | 安全 | P0 | 会话吊销 |
+| TC-1290 | POST /api/user/resetPassword | 重置后mustChangePassword=1 | 重置→查DB | mustChangePassword=1 | 功能验证 | P0 | 强制改密 |
+| TC-1291 | POST /api/user/resetPassword | 目标用户不存在 | userId=999999 | code=404 | 异常路径 | P0 | 用户不存在 |
+| TC-1292 | POST /api/user/resetPassword | 乐观锁冲突 | 并发修改同一用户 | code=409 | 并发安全 | P1 | ErrUpdateConflict |
+| TC-1293 | POST /api/user/resetPassword | 重置后用新密码可登录 | 重置→fetchCredentials→登录 | 登录成功 | 端到端 | P0 | 新密码可用 |
+| TC-1294 | POST /api/user/fetchCredentials | 正常消费ticket | 有效ticket | code=200, resp含username+password | 正常路径 | P0 | 一次性消费 |
+| TC-1295 | POST /api/user/fetchCredentials | 二次消费同一ticket | 消费后再次请求 | code=400 "凭证票据无效或已过期" | 安全 | P0 | 一次性语义 |
+| TC-1296 | POST /api/user/fetchCredentials | 空ticket | ticket="" | code=400 "ticket 不能为空" | 边界 | P0 | 参数校验 |
+| TC-1297 | POST /api/user/fetchCredentials | 非法ticket | ticket="random_garbage" | code=400 "凭证票据无效或已过期" | 安全 | P0 | 无效ticket |
+| TC-1298 | POST /api/user/fetchCredentials | 非ADMIN无权消费 | MemberCtx + valid ticket | code=403 | 安全 | P0 | 权限校验 |
+| TC-1299 | POST /api/user/fetchCredentials | 并发消费同一ticket | 10 goroutine同时消费 | 恰好1个成功,其余400 | 并发安全 | P0 | GetDelCtx原子性 |

+ 75 - 51
test-report.md

@@ -1,6 +1,6 @@
 # 权限管理系统 (perms-system-server) — 测试报告
 
-> 报告日期: 2026-05-14(新增 POST /api/member/userProducts 接口覆盖
+> 报告日期: 2026-05-15(新增用户凭证票据机制 CreateUser/ResetPassword/FetchUserCredentials
 > 测试范围: REST API (go-zero) + gRPC + Model 层 (自定义方法 + _gen.go 模板生成) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 中间件
 > 测试用例设计详见 [test-design.md](./test-design.md)
 > 执行命令: `go test -count=1 -timeout 600s ./...`
@@ -12,53 +12,54 @@
 | 指标 | 数值 |
 | :--- | :--- |
 | 测试包总数 | **28** |
-| TC 用例总数 (test-design.md) | **1040** |
-| 顶层测试函数数 (Functions) | **1109** |
-| 测试执行事件总数 (含 `t.Run` 子用例) | **1241** |
-| ✅ 通过 | **1240** |
-| ⏭️ 跳过 | **1** |
+| TC 用例总数 (test-design.md) | **1065** |
+| 顶层测试函数数 (Functions) | **1170** |
+| 测试执行事件总数 (含 `t.Run` 子用例) | **1309** |
+| ✅ 通过 | **1307** |
+| ⏭️ 跳过 | **2** |
 | ❌ 失败 | **0**(本轮全绿) |
-| 通过率 (TC 维度) | **100%**(扣除 1 条不可达防御分支 Skip) |
-| Logic 层语句覆盖率 | **86.9%**(`go tool cover -func` 统计) |
+| 通过率 (TC 维度) | **100%**(扣除 2 条不可达防御分支 Skip) |
+| Logic 层语句覆盖率 | **87.2%**(`go tool cover -func` 统计) |
 
 ### 1.1 各测试包结果
 
 | 测试包 | 状态 | 耗时 |
 | :--- | :--- | :--- |
-| internal/handler | ✅ ok | 0.900s |
-| internal/handler/auth | ✅ ok | 2.531s |
-| internal/handler/product | ✅ ok | 3.287s |
-| internal/handler/pub | ✅ ok | 4.098s |
-| internal/loaders | ✅ ok | 4.846s |
-| internal/logic/auth | ✅ ok | 13.866s |
-| internal/logic/dept | ✅ ok | 5.833s |
-| internal/logic/member | ✅ ok | 6.722s |
-| internal/logic/perm | ✅ ok | 6.448s |
-| internal/logic/product | ✅ ok | 15.337s |
-| internal/logic/pub | ✅ ok | 9.595s |
-| internal/logic/role | ✅ ok | 7.618s |
-| internal/logic/user | ✅ ok | 14.596s |
-| internal/middleware | ✅ ok | 9.011s |
-| internal/model/dept | ✅ ok | 9.775s |
-| internal/model/perm | ✅ ok | 9.825s |
-| internal/model/product | ✅ ok | 9.853s |
-| internal/model/productmember | ✅ ok | 9.049s |
-| internal/model/role | ✅ ok | 9.029s |
-| internal/model/roleperm | ✅ ok | 8.563s |
-| internal/model/user | ✅ ok | 16.227s |
-| internal/model/userperm | ✅ ok | 7.186s |
-| internal/model/userrole | ✅ ok | 5.606s |
-| internal/response | ✅ ok | 5.712s |
-| internal/handler/minio | ✅ ok | 2.400s |
-| internal/logic/minio | ✅ ok | 1.252s |
-| internal/server | ✅ ok | 5.457s |
-| internal/util | ✅ ok | 4.233s |
+| internal/handler | ✅ ok | 1.339s |
+| internal/handler/auth | ✅ ok | 3.744s |
+| internal/handler/minio | ✅ ok | 2.124s |
+| internal/handler/product | ✅ ok | 2.597s |
+| internal/handler/pub | ✅ ok | 3.199s |
+| internal/loaders | ✅ ok | 4.418s |
+| internal/logic/auth | ✅ ok | 13.685s |
+| internal/logic/dept | ✅ ok | 6.231s |
+| internal/logic/member | ✅ ok | 7.291s |
+| internal/logic/minio | ✅ ok | 8.127s |
+| internal/logic/perm | ✅ ok | 9.476s |
+| internal/logic/product | ✅ ok | 18.959s |
+| internal/logic/pub | ✅ ok | 14.051s |
+| internal/logic/role | ✅ ok | 12.803s |
+| internal/logic/user | ✅ ok | 20.844s |
+| internal/middleware | ✅ ok | 14.374s |
+| internal/model/dept | ✅ ok | 15.051s |
+| internal/model/perm | ✅ ok | 14.717s |
+| internal/model/product | ✅ ok | 14.814s |
+| internal/model/productmember | ✅ ok | 14.115s |
+| internal/model/role | ✅ ok | 12.892s |
+| internal/model/roleperm | ✅ ok | 12.404s |
+| internal/model/user | ✅ ok | 20.141s |
+| internal/model/userperm | ✅ ok | 11.709s |
+| internal/model/userrole | ✅ ok | 8.933s |
+| internal/response | ✅ ok | 8.987s |
+| internal/server | ✅ ok | 9.439s |
+| internal/util | ✅ ok | 9.082s |
 
 ### 1.2 跳过用例说明
 
 | TC 编号 | 跳过原因 |
 | :--- | :--- |
 | TC-0263 | JWT claims 类型断言防御性分支,在 `jwt.ParseWithClaims(&Claims{})` 下不可达 |
+| — | `TestMinioUpload_EmptyFileType`:Minio 空文件类型防御分支,测试环境无 Minio 实例 |
 
 ### 1.3 已知缺陷(需跟进)
 
@@ -69,6 +70,8 @@
 | TC-0820(`UserModel_IncrementTokenVersionIfMatch`) | `TestSysUserModel_IncrementTokenVersionIfMatch_ConcurrentSingleWinner` 在整包并发压下偶发 `circuit breaker is open`(go-zero `breaker`)失败 | 本用例靠 `wg+8 goroutine` 同时冲同一行走 CAS `UPDATE ... WHERE tokenVersion=?`,在整包全量并发压下 go-zero SQL 断路器被其它测试累计的错误触达打开,导致 8 路里若干路直接被 breaker 短路拒绝;`-run` 单独跑则断路器计数窗口没攒满,必过 | 在测试 setup 里显式重置 breaker 统计(或注入允许更高错误率的 breaker 配置),也可在断言里把 `circuit breaker is open` 视作"并发压力副作用"跳过重试;非业务 bug,生产路径的 CAS 正确性已由 `_Match` / `_Mismatch_NoSideEffect` 两条稳定用例覆盖 |
 
 > **已修复的回归点**:上一轮报告中的 TC-1078 `TestBindRoles_Vs_DeleteRole_NoOrphanRows` 文案 flake,本轮随 `bindRolesLogic` 三路径统一为 `包含无效的角色ID` 的改动(L-R14-2)一并收敛;并发断言现已与新文案对齐,单独 / 整包执行均稳定 pass。本轮变更:`POST /api/user/bindRoles` 请求体新增 `productCode` 字段。SUPER_ADMIN 的 JWT 中 `productCode` 为空字符串,原实现直接从 JWT context 读取导致 member 查询失败("目标用户不是当前产品的成员")。修复后 logic 优先使用 `req.ProductCode`(非空时覆盖 JWT context 值),所有 `BindRolesReq` 测试用例已同步补充 `ProductCode` 字段。
+>
+> **本轮新增**:用户凭证票据机制(CreateUser 移除 Password 字段改为服务端生成 + ticket 一次性领取、ResetPassword、FetchUserCredentials),新增 TC-1280 ~ TC-1299 共 20 条用例。同时修复 `userrole` 包 4 条测试因 `FindRoleIdsByUserId` 新增 `INNER JOIN sys_role` 过滤导致的 pre-existing 失败(测试数据未在 `sys_role` 表中创建对应角色记录)。
 
 ---
 
@@ -448,11 +451,6 @@
 | TC-0142 | phone为空(可选) | ✅ pass |
 | TC-0143 | 并发同username(TOCTOU) | ✅ pass |
 | TC-0144 | 唯一索引冲突消息 | ✅ pass |
-| TC-0145 | 密码少于8字符 | ✅ pass |
-| TC-0146 | 密码缺少大写字母 | ✅ pass |
-| TC-0147 | 密码缺少小写字母 | ✅ pass |
-| TC-0148 | 密码缺少数字 | ✅ pass |
-| TC-0149 | 密码超过72字符 | ✅ pass |
 | TC-0150 | 用户名含特殊字符被拒绝 | ✅ pass |
 | TC-0151 | 用户名太短(1字符)被拒绝 | ✅ pass |
 | TC-0152 | 用户名太长(65字符)被拒绝 | ✅ pass |
@@ -474,8 +472,34 @@
 | TC-1194 | 未传 avatar 时 `sys_user.avatar` 列在 DB 层必须为 SQL NULL(L-R17-2) | ✅ pass |
 | TC-1195 | mock 断言 `InsertWithTx` 在 `TransactCtx` 闭包内且顺序 `FindOneForShareTx(dept)` → `InsertWithTx(user)`(H-R17-1) | ✅ pass |
 | TC-1196 | mock 断言 `FindOneForShareTx` 返 `sqlx.ErrNotFound`(并发 DeleteDept 胜出)时必须 400 "部门不存在或已删除",`InsertWithTx` 绝不得被调用(H-R17-1) | ✅ pass |
+| TC-1280 | 正常创建用户返回 ticket(无 Password 字段) | ✅ pass |
+| TC-1282 | 生成密码满足强度要求 | ✅ pass |
+| TC-1284 | 创建后 mustChangePassword=1 | ✅ pass |
 
-### 2.15 用户更新 `POST /api/user/update` (指针类型+DeptId可清零)
+### 2.15 重置密码 `POST /api/user/resetPassword`
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
+| TC-1285 | 超管重置普通用户密码 | ✅ pass |
+| TC-1287 | 不能重置超管密码 | ✅ pass |
+| TC-1288 | MEMBER 无权重置 | ✅ pass |
+| TC-1289 | 重置后旧 token 失效(tokenVersion 递增) | ✅ pass |
+| TC-1290 | 重置后 mustChangePassword=1 | ✅ pass |
+| TC-1291 | 目标用户不存在 | ✅ pass |
+| TC-1293 | 重置后用新密码可验证 | ✅ pass |
+
+### 2.16 消费凭证票据 `POST /api/user/fetchCredentials`
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
+| TC-1294 | 正常消费 ticket | ✅ pass |
+| TC-1295 | 二次消费同一 ticket | ✅ pass |
+| TC-1296 | 空 ticket | ✅ pass |
+| TC-1297 | 非法 ticket | ✅ pass |
+| TC-1298 | 非 ADMIN 无权消费 | ✅ pass |
+| TC-1299 | 并发消费同一 ticket 原子性 | ✅ pass |
+
+### 2.17 用户更新 `POST /api/user/update` (指针类型+DeptId可清零)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -517,7 +541,7 @@
 | TC-1172 | target 从 DEV+Enabled 部门 → deptId=0:tokenVersion +1 | ✅ pass |
 | TC-1173 | NORMAL→NORMAL 不递增 tokenVersion(正向回归) | ✅ pass |
 
-### 2.16 用户列表/详情/状态 及其他用户操作
+### 2.18 用户列表/详情/状态 及其他用户操作
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -598,7 +622,7 @@
 | TC-1168 | 产品 DEVELOPER 列表视角:全部成员 PII 原值返回 | ✅ pass |
 | TC-1169 | 产品 MEMBER 列表视角:其他成员 Email/Phone/Remark 必须为空字符串 | ✅ pass |
 
-### 2.17 获取用户权限覆盖 `POST /api/user/userPerms`
+### 2.19 获取用户权限覆盖 `POST /api/user/userPerms`
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -611,7 +635,7 @@
 | TC-1263 | ADMIN 查不是当前产品成员的用户被拒绝 | ✅ pass |
 | TC-1264 | nil UserDetails(模拟无 JWT)返回 401 | ✅ pass |
 
-### 2.18 成员管理
+### 2.20 成员管理
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -686,7 +710,7 @@
 | TC-1278 | userProducts 产品查询失败跳过(mock) | ✅ pass |
 | TC-1279 | userProducts 空列表(mock) | ✅ pass |
 
-### 2.19 获取验证码 `POST /api/captcha/get`
+### 2.21 获取验证码 `POST /api/captcha/get`
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -697,7 +721,7 @@
 | TC-1253 | VerifyCaptcha 错误码不消费 | ✅ pass |
 | TC-1254 | VerifyCaptcha 不存在的 id | ✅ pass |
 
-### 2.20 Cap.js 端点 `POST /api/capjs/endpoint`
+### 2.22 Cap.js 端点 `POST /api/capjs/endpoint`
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -706,7 +730,7 @@
 | TC-1255 | cap.js 启用但 EndpointURL 为空 | ✅ pass |
 | TC-1256 | cap.js 启用但 Key 为空 | ✅ pass |
 
-### 2.21 产品端 Cap.js 登录 `POST /api/auth/login/cap`
+### 2.23 产品端 Cap.js 登录 `POST /api/auth/login/cap`
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -717,7 +741,7 @@
 | TC-1223 | capToken 有效 + 密码错误 | ✅ pass |
 | TC-1224 | capToken 有效 + 超管被拒绝 | ✅ pass |
 
-### 2.22 管理后台 Cap.js 登录 `POST /api/auth/adminLogin/cap`
+### 2.24 管理后台 Cap.js 登录 `POST /api/auth/adminLogin/cap`
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -727,7 +751,7 @@
 | TC-1228 | capToken 有效 + 超管正常登录 | ✅ pass |
 | TC-1229 | capToken 有效 + 非超管被拒绝 | ✅ pass |
 
-### 2.23 更新用户信息 `POST /api/auth/updateInfo`
+### 2.25 更新用户信息 `POST /api/auth/updateInfo`
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -743,7 +767,7 @@
 | TC-1239 | 并发更新冲突 | ✅ pass |
 | TC-1240 | 更新后 UserDetails 缓存失效 | ✅ pass |
 
-### 2.24 MinIO 文件上传 `POST /api/minio/upload`
+### 2.26 MinIO 文件上传 `POST /api/minio/upload`
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |