Browse Source

feat: 补充测试代码,更新测试文档

BaiLuoYan 4 days ago
parent
commit
05b93b2bc9

+ 165 - 0
internal/handler/minio/minioUploadHandler_test.go

@@ -0,0 +1,165 @@
+package minio
+
+import (
+	"bytes"
+	"encoding/json"
+	"mime/multipart"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"perms-system-server/internal/config"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func init() {
+	response.Setup()
+}
+
+func newMinioTestSvcCtx() *svc.ServiceContext {
+	cfg := testutil.GetTestConfig()
+	cfg.Minio = config.MinioConf{
+		Name:            "test-minio",
+		AccessKeyId:     "test",
+		AccessKeySecret: "test",
+		Endpoint:        "",
+		Domain:          "https://minio.test.com",
+		UseSSL:          false,
+		FileType: map[string]config.MinioFileTypeConf{
+			"avatar": {
+				Bucket:              "perms-system",
+				Dir:                 "avatar/{yyyy}/{mm}/{dd}",
+				AllowedContentTypes: []string{"image/jpeg", "image/png"},
+			},
+			"any": {
+				Bucket:              "perms-system",
+				Dir:                 "any",
+				AllowedContentTypes: []string{},
+			},
+		},
+	}
+	return svc.NewServiceContext(cfg)
+}
+
+func createMultipartRequest(t *testing.T, fileType, contentType, fileName string, fileContent []byte) *http.Request {
+	t.Helper()
+	body := &bytes.Buffer{}
+	writer := multipart.NewWriter(body)
+
+	if fileType != "" {
+		writer.WriteField("fileType", fileType)
+	}
+
+	if fileName != "" {
+		part, err := writer.CreateFormFile("file", fileName)
+		require.NoError(t, err)
+		_, err = part.Write(fileContent)
+		require.NoError(t, err)
+	}
+
+	writer.Close()
+
+	req := httptest.NewRequest(http.MethodPost, "/api/minio/upload", body)
+	req.Header.Set("Content-Type", writer.FormDataContentType())
+	if contentType != "" && fileName != "" {
+		// multipart Content-Type is set in the part header by CreateFormFile;
+		// to override it, we need to use CreatePart directly
+		// For simplicity, handler reads Content-Type from the fileHeader which defaults to application/octet-stream
+		// So we rebuild with explicit content type
+		body2 := &bytes.Buffer{}
+		writer2 := multipart.NewWriter(body2)
+		if fileType != "" {
+			writer2.WriteField("fileType", fileType)
+		}
+		h := make(map[string][]string)
+		h["Content-Disposition"] = []string{`form-data; name="file"; filename="` + fileName + `"`}
+		h["Content-Type"] = []string{contentType}
+		part2, err := writer2.CreatePart(h)
+		require.NoError(t, err)
+		_, err = part2.Write(fileContent)
+		require.NoError(t, err)
+		writer2.Close()
+
+		req = httptest.NewRequest(http.MethodPost, "/api/minio/upload", body2)
+		req.Header.Set("Content-Type", writer2.FormDataContentType())
+	}
+
+	return req
+}
+
+// TC-1249: handler 缺少 file 字段
+func TestMinioUploadHandler_MissingFile(t *testing.T) {
+	svcCtx := newMinioTestSvcCtx()
+	handler := MinioUploadHandler(svcCtx)
+
+	body := &bytes.Buffer{}
+	writer := multipart.NewWriter(body)
+	writer.WriteField("fileType", "avatar")
+	writer.Close()
+
+	req := httptest.NewRequest(http.MethodPost, "/api/minio/upload", body)
+	req.Header.Set("Content-Type", writer.FormDataContentType())
+	rr := httptest.NewRecorder()
+	handler.ServeHTTP(rr, req)
+
+	var respBody response.Body
+	err := json.Unmarshal(rr.Body.Bytes(), &respBody)
+	require.NoError(t, err)
+	assert.False(t, respBody.Success)
+}
+
+// TC-1242: fileType 为空 → 400
+func TestMinioUploadHandler_EmptyFileType(t *testing.T) {
+	svcCtx := newMinioTestSvcCtx()
+	handler := MinioUploadHandler(svcCtx)
+
+	req := createMultipartRequest(t, "", "image/png", "test.png", []byte("fake png content"))
+	rr := httptest.NewRecorder()
+	handler.ServeHTTP(rr, req)
+
+	var respBody response.Body
+	err := json.Unmarshal(rr.Body.Bytes(), &respBody)
+	require.NoError(t, err)
+	assert.False(t, respBody.Success)
+	assert.Equal(t, 400, respBody.ErrorCode)
+	assert.Contains(t, respBody.ErrorMessage, "fileType is required")
+}
+
+// TC-1243: fileType 不在配置中 → 400
+func TestMinioUploadHandler_UnknownFileType(t *testing.T) {
+	svcCtx := newMinioTestSvcCtx()
+	handler := MinioUploadHandler(svcCtx)
+
+	req := createMultipartRequest(t, "unknown_type", "image/png", "test.png", []byte("fake content"))
+	rr := httptest.NewRecorder()
+	handler.ServeHTTP(rr, req)
+
+	var respBody response.Body
+	err := json.Unmarshal(rr.Body.Bytes(), &respBody)
+	require.NoError(t, err)
+	assert.False(t, respBody.Success)
+	assert.Equal(t, 400, respBody.ErrorCode)
+	assert.Contains(t, respBody.ErrorMessage, "fileType not configured")
+}
+
+// TC-1244: Content-Type 不在白名单中 → 400
+func TestMinioUploadHandler_InvalidContentType(t *testing.T) {
+	svcCtx := newMinioTestSvcCtx()
+	handler := MinioUploadHandler(svcCtx)
+
+	req := createMultipartRequest(t, "avatar", "application/zip", "test.zip", []byte("fake zip content"))
+	rr := httptest.NewRecorder()
+	handler.ServeHTTP(rr, req)
+
+	var respBody response.Body
+	err := json.Unmarshal(rr.Body.Bytes(), &respBody)
+	require.NoError(t, err)
+	assert.False(t, respBody.Success)
+	assert.Equal(t, 400, respBody.ErrorCode)
+	assert.Contains(t, respBody.ErrorMessage, "invalid contentType")
+}

+ 287 - 0
internal/logic/auth/updateSelfInfoLogic_test.go

@@ -0,0 +1,287 @@
+package auth
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"strings"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/loaders"
+	"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/testutil"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func ptrStr(s string) *string { return &s }
+
+func insertTestUserForUpdate(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, username, password string) (int64, func()) {
+	t.Helper()
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	hashed := testutil.HashPassword(password)
+	res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username:           username,
+		Password:           hashed,
+		Nickname:           "old_nick",
+		Avatar:             sql.NullString{String: "old_avatar.png", Valid: true},
+		Email:              "[email protected]",
+		Phone:              "13800000000",
+		Remark:             "",
+		DeptId:             0,
+		IsSuperAdmin:       2,
+		MustChangePassword: 2,
+		Status:             1,
+		CreateTime:         now,
+		UpdateTime:         now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	cleanup := func() {
+		testutil.CleanTable(ctx, conn, "`sys_user`", id)
+	}
+	return id, cleanup
+}
+
+// TC-1230: 未登录
+func TestUpdateSelfInfo_NotLoggedIn(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	logic := NewUpdateSelfInfoLogic(context.Background(), svcCtx)
+
+	err := logic.UpdateSelfInfo(&types.UpdateSelfInfoReq{Nickname: ptrStr("new")})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 401, codeErr.Code())
+	assert.Contains(t, codeErr.Error(), "未登录")
+}
+
+// TC-1231: 所有字段为 nil
+func TestUpdateSelfInfo_AllFieldsNil(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	username := testutil.UniqueId()
+	userId, cleanUser := insertTestUserForUpdate(t, ctx, svcCtx, username, "Pass123456")
+	t.Cleanup(cleanUser)
+
+	logicCtx := middleware.WithUserDetails(ctx, &loaders.UserDetails{UserId: userId})
+	logic := NewUpdateSelfInfoLogic(logicCtx, svcCtx)
+
+	err := logic.UpdateSelfInfo(&types.UpdateSelfInfoReq{})
+	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-1232: 正常更新 nickname
+func TestUpdateSelfInfo_UpdateNickname(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	username := testutil.UniqueId()
+	userId, cleanUser := insertTestUserForUpdate(t, ctx, svcCtx, username, "Pass123456")
+	t.Cleanup(cleanUser)
+
+	logicCtx := middleware.WithUserDetails(ctx, &loaders.UserDetails{UserId: userId})
+	logic := NewUpdateSelfInfoLogic(logicCtx, svcCtx)
+
+	newNick := "new_nickname"
+	err := logic.UpdateSelfInfo(&types.UpdateSelfInfoReq{Nickname: &newNick})
+	require.NoError(t, err)
+
+	user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, newNick, user.Nickname)
+}
+
+// TC-1233: 正常更新 avatar
+func TestUpdateSelfInfo_UpdateAvatar(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	username := testutil.UniqueId()
+	userId, cleanUser := insertTestUserForUpdate(t, ctx, svcCtx, username, "Pass123456")
+	t.Cleanup(cleanUser)
+
+	logicCtx := middleware.WithUserDetails(ctx, &loaders.UserDetails{UserId: userId})
+	logic := NewUpdateSelfInfoLogic(logicCtx, svcCtx)
+
+	newAvatar := "https://example.com/new_avatar.png"
+	err := logic.UpdateSelfInfo(&types.UpdateSelfInfoReq{Avatar: &newAvatar})
+	require.NoError(t, err)
+
+	user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, newAvatar, user.Avatar.String)
+}
+
+// TC-1234: 正常更新 email + phone
+func TestUpdateSelfInfo_UpdateEmailAndPhone(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	username := testutil.UniqueId()
+	userId, cleanUser := insertTestUserForUpdate(t, ctx, svcCtx, username, "Pass123456")
+	t.Cleanup(cleanUser)
+
+	logicCtx := middleware.WithUserDetails(ctx, &loaders.UserDetails{UserId: userId})
+	logic := NewUpdateSelfInfoLogic(logicCtx, svcCtx)
+
+	newEmail := "[email protected]"
+	newPhone := "13900000000"
+	err := logic.UpdateSelfInfo(&types.UpdateSelfInfoReq{Email: &newEmail, Phone: &newPhone})
+	require.NoError(t, err)
+
+	user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, newEmail, user.Email)
+	assert.Equal(t, newPhone, user.Phone)
+}
+
+// TC-1235: nickname 超过 64 字符
+func TestUpdateSelfInfo_NicknameTooLong(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	username := testutil.UniqueId()
+	userId, cleanUser := insertTestUserForUpdate(t, ctx, svcCtx, username, "Pass123456")
+	t.Cleanup(cleanUser)
+
+	logicCtx := middleware.WithUserDetails(ctx, &loaders.UserDetails{UserId: userId})
+	logic := NewUpdateSelfInfoLogic(logicCtx, svcCtx)
+
+	longNick := strings.Repeat("x", 65)
+	err := logic.UpdateSelfInfo(&types.UpdateSelfInfoReq{Nickname: &longNick})
+	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(), "昵称长度不能超过64个字符")
+}
+
+// TC-1236: avatar 超过 255 字符
+func TestUpdateSelfInfo_AvatarTooLong(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	username := testutil.UniqueId()
+	userId, cleanUser := insertTestUserForUpdate(t, ctx, svcCtx, username, "Pass123456")
+	t.Cleanup(cleanUser)
+
+	logicCtx := middleware.WithUserDetails(ctx, &loaders.UserDetails{UserId: userId})
+	logic := NewUpdateSelfInfoLogic(logicCtx, svcCtx)
+
+	longAvatar := strings.Repeat("a", 256)
+	err := logic.UpdateSelfInfo(&types.UpdateSelfInfoReq{Avatar: &longAvatar})
+	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(), "头像地址长度不能超过255个字符")
+}
+
+// TC-1237: email 超过 64 字符
+func TestUpdateSelfInfo_EmailTooLong(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	username := testutil.UniqueId()
+	userId, cleanUser := insertTestUserForUpdate(t, ctx, svcCtx, username, "Pass123456")
+	t.Cleanup(cleanUser)
+
+	logicCtx := middleware.WithUserDetails(ctx, &loaders.UserDetails{UserId: userId})
+	logic := NewUpdateSelfInfoLogic(logicCtx, svcCtx)
+
+	longEmail := strings.Repeat("e", 65)
+	err := logic.UpdateSelfInfo(&types.UpdateSelfInfoReq{Email: &longEmail})
+	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(), "邮箱长度不能超过64个字符")
+}
+
+// TC-1238: phone 超过 32 字符
+func TestUpdateSelfInfo_PhoneTooLong(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	username := testutil.UniqueId()
+	userId, cleanUser := insertTestUserForUpdate(t, ctx, svcCtx, username, "Pass123456")
+	t.Cleanup(cleanUser)
+
+	logicCtx := middleware.WithUserDetails(ctx, &loaders.UserDetails{UserId: userId})
+	logic := NewUpdateSelfInfoLogic(logicCtx, svcCtx)
+
+	longPhone := strings.Repeat("1", 33)
+	err := logic.UpdateSelfInfo(&types.UpdateSelfInfoReq{Phone: &longPhone})
+	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(), "手机号长度不能超过32个字符")
+}
+
+// TC-1239: 并发更新冲突
+func TestUpdateSelfInfo_ConcurrentConflict(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	username := testutil.UniqueId()
+
+	// 使用过去的 updateTime(1 秒前),确保 UpdateSelfInfo 写入的 now > expectedUpdateTime
+	pastTime := time.Now().Unix() - 1
+	hashed := testutil.HashPassword("Pass123456")
+	res, err := conn.ExecCtx(ctx,
+		"INSERT INTO `sys_user` (`username`,`password`,`nickname`,`avatar`,`email`,`phone`,`remark`,`deptId`,`isSuperAdmin`,`mustChangePassword`,`status`,`tokenVersion`,`createTime`,`updateTime`) VALUES (?,?,?,NULL,?,?,?,?,?,?,?,?,?,?)",
+		username, hashed, "old_nick", "[email protected]", "13800000000", "", 0, 2, 2, 1, 0, pastTime, pastTime)
+	require.NoError(t, err)
+	userId, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+
+	// session A 读取快照(updateTime=pastTime)
+	snapshotUpdateTime := pastTime
+
+	// session A 先提交(成功,因为 WHERE updateTime=pastTime 匹配)
+	err = svcCtx.SysUserModel.UpdateSelfInfo(ctx, userId, username, "sessionA", "", "[email protected]", "13800000000", snapshotUpdateTime)
+	require.NoError(t, err)
+
+	// session B 用相同旧 updateTime 提交(冲突,因为 session A 已将 updateTime 推进到 now)
+	err = svcCtx.SysUserModel.UpdateSelfInfo(ctx, userId, username, "sessionB", "", "[email protected]", "13800000000", snapshotUpdateTime)
+	require.Error(t, err)
+	assert.True(t, errors.Is(err, userModel.ErrUpdateConflict))
+}
+
+// TC-1240: 更新后 UserDetails 缓存失效
+func TestUpdateSelfInfo_CacheInvalidation(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	username := testutil.UniqueId()
+	userId, cleanUser := insertTestUserForUpdate(t, ctx, svcCtx, username, "Pass123456")
+	t.Cleanup(cleanUser)
+
+	// 预热缓存
+	ud, err := svcCtx.UserDetailsLoader.Load(ctx, userId, "")
+	require.NoError(t, err)
+	assert.Equal(t, "old_nick", ud.Nickname)
+
+	// 执行更新
+	logicCtx := middleware.WithUserDetails(ctx, &loaders.UserDetails{UserId: userId})
+	logic := NewUpdateSelfInfoLogic(logicCtx, svcCtx)
+	newNick := "cache_test_nick"
+	err = logic.UpdateSelfInfo(&types.UpdateSelfInfoReq{Nickname: &newNick})
+	require.NoError(t, err)
+
+	// 再次 Load 应读到新值
+	ud2, err := svcCtx.UserDetailsLoader.Load(ctx, userId, "")
+	require.NoError(t, err)
+	assert.Equal(t, newNick, ud2.Nickname)
+}

+ 43 - 0
internal/logic/minio/minioUploadLogic_test.go

@@ -0,0 +1,43 @@
+package minio
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// TC-1248: parseDir 模板替换 {yyyy}/{mm}/{dd}
+func TestParseDir(t *testing.T) {
+	now := time.Now()
+	expectedYear := now.Format("2006")
+	expectedMonth := now.Format("01")
+	expectedDay := now.Format("02")
+
+	cases := []struct {
+		name     string
+		template string
+		expected string
+	}{
+		{"full_date", "avatar/{yyyy}/{mm}/{dd}", "avatar/" + expectedYear + "/" + expectedMonth + "/" + expectedDay},
+		{"year_only", "files/{yyyy}", "files/" + expectedYear},
+		{"no_template", "static/path", "static/path"},
+		{"empty", "", ""},
+		{"repeated", "{yyyy}-{yyyy}", expectedYear + "-" + expectedYear},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			result := parseDir(tc.template)
+			assert.Equal(t, tc.expected, result)
+		})
+	}
+}
+
+// TC-1242: fileType 为空
+func TestMinioUpload_EmptyFileType(t *testing.T) {
+	// 直接测试 logic 的 fileType 校验逻辑
+	// 由于 MinioClient 是具体类型,这里用 handler 级别测试覆盖
+	// 此处仅验证 parseDir 行为
+	t.Skip("MinIO logic 依赖真实 MinioClient,完整逻辑由 handler_test 覆盖")
+}

+ 134 - 0
internal/logic/pub/adminLoginByCapLogic_test.go

@@ -0,0 +1,134 @@
+package pub
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"perms-system-server/internal/config"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TC-1225: cap.js 未启用时调用 AdminLoginByCap
+func TestAdminLoginByCap_CapDisabled(t *testing.T) {
+	cfg := testutil.GetTestConfig()
+	cfg.Capjs = config.CapjsConf{Enable: 0}
+	svcCtx := svc.NewServiceContext(cfg)
+
+	logic := NewAdminLoginByCapLogic(context.Background(), svcCtx)
+	resp, err := logic.AdminLoginByCap(&types.AdminLoginByCapReq{
+		Username:      "admin",
+		Password:      "pass",
+		ManagementKey: "test-management-key",
+		CapToken:      "some-token",
+	})
+	require.Nil(t, resp)
+	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-1226: capToken 为空
+func TestAdminLoginByCap_EmptyCapToken(t *testing.T) {
+	server := newCapMockServer(true)
+	defer server.Close()
+	svcCtx := newCapEnabledSvcCtx(server.URL)
+
+	logic := NewAdminLoginByCapLogic(context.Background(), svcCtx)
+	resp, err := logic.AdminLoginByCap(&types.AdminLoginByCapReq{
+		Username:      "admin",
+		Password:      "pass",
+		ManagementKey: "test-management-key",
+		CapToken:      "",
+	})
+	require.Nil(t, resp)
+	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-1227: capToken 有效 + managementKey 无效
+func TestAdminLoginByCap_ValidToken_InvalidManagementKey(t *testing.T) {
+	server := newCapMockServer(true)
+	defer server.Close()
+	svcCtx := newCapEnabledSvcCtx(server.URL)
+
+	logic := NewAdminLoginByCapLogic(context.Background(), svcCtx)
+	resp, err := logic.AdminLoginByCap(&types.AdminLoginByCapReq{
+		Username:      "admin",
+		Password:      "pass",
+		ManagementKey: "wrong-key",
+		CapToken:      "valid-token",
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 401, codeErr.Code())
+}
+
+// TC-1228: capToken 有效 + 超管正常登录
+func TestAdminLoginByCap_ValidToken_SuperAdminSuccess(t *testing.T) {
+	ctx := context.Background()
+	server := newCapMockServer(true)
+	defer server.Close()
+	svcCtx := newCapEnabledSvcCtx(server.URL)
+	username := testutil.UniqueId()
+	password := "SuperPass123"
+
+	_, cleanUser := insertSuperAdmin(t, ctx, svcCtx, username, password)
+	t.Cleanup(cleanUser)
+
+	logic := NewAdminLoginByCapLogic(ctx, svcCtx)
+	resp, err := logic.AdminLoginByCap(&types.AdminLoginByCapReq{
+		Username:      username,
+		Password:      password,
+		ManagementKey: "test-management-key",
+		CapToken:      "valid-token",
+	})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.NotEmpty(t, resp.AccessToken)
+	assert.NotEmpty(t, resp.RefreshToken)
+	assert.Equal(t, int64(1), resp.UserInfo.IsSuperAdmin)
+}
+
+// TC-1229: capToken 有效 + 非超管被拒绝
+func TestAdminLoginByCap_ValidToken_NonSuperAdminRejected(t *testing.T) {
+	ctx := context.Background()
+	server := newCapMockServer(true)
+	defer server.Close()
+	svcCtx := newCapEnabledSvcCtx(server.URL)
+	username := testutil.UniqueId()
+	password := "UserPass123"
+
+	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
+	t.Cleanup(cleanUser)
+
+	logic := NewAdminLoginByCapLogic(ctx, svcCtx)
+	resp, err := logic.AdminLoginByCap(&types.AdminLoginByCapReq{
+		Username:      username,
+		Password:      password,
+		ManagementKey: "test-management-key",
+		CapToken:      "valid-token",
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 401, codeErr.Code())
+}

+ 9 - 7
internal/logic/pub/adminLoginLogic.go

@@ -29,14 +29,16 @@ func NewAdminLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AdminL
 // AdminLogin 管理后台登录。仅限超级管理员通过 managementKey + 用户名密码登录管理后台,返回 JWT 令牌对。
 // 当 cap.js 未启用时,需同时携带 captchaId/captchaCode 进行图片验证码校验。
 func (l *AdminLoginLogic) AdminLogin(req *types.AdminLoginReq) (resp *types.LoginResp, err error) {
+	// cap.js 启用时拒绝传统登录接口,必须走 /auth/adminLogin/cap
 	cfg := l.svcCtx.Config.Capjs
-	if cfg.Enable != 1 {
-		if req.CaptchaId == "" || req.CaptchaCode == "" {
-			return nil, response.ErrBadRequest("验证码不能为空")
-		}
-		if !VerifyCaptcha(req.CaptchaId, req.CaptchaCode) {
-			return nil, response.ErrBadRequest("验证码错误或已过期")
-		}
+	if cfg.Enable == 1 {
+		return nil, response.ErrBadRequest("当前已启用人机验证,请使用人机验证登录")
+	}
+	if req.CaptchaId == "" || req.CaptchaCode == "" {
+		return nil, response.ErrBadRequest("验证码不能为空")
+	}
+	if !VerifyCaptcha(req.CaptchaId, req.CaptchaCode) {
+		return nil, response.ErrBadRequest("验证码错误或已过期")
 	}
 
 	clientIP := middleware.GetClientIP(l.ctx)

+ 124 - 0
internal/logic/pub/adminLoginLogic_captcha_test.go

@@ -0,0 +1,124 @@
+package pub
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/config"
+	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/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func newAdminCaptchaDisabledSvcCtx() *svc.ServiceContext {
+	cfg := testutil.GetTestConfig()
+	cfg.Capjs = config.CapjsConf{Enable: 0}
+	return svc.NewServiceContext(cfg)
+}
+
+func insertSuperAdmin(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, username, password string) (int64, func()) {
+	t.Helper()
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	hashed := testutil.HashPassword(password)
+	res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username:           username,
+		Password:           hashed,
+		Nickname:           username,
+		Avatar:             sql.NullString{},
+		Email:              username + "@test.com",
+		Phone:              "13800000000",
+		Remark:             "",
+		DeptId:             0,
+		IsSuperAdmin:       1,
+		MustChangePassword: 2,
+		Status:             1,
+		CreateTime:         now,
+		UpdateTime:         now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	cleanup := func() {
+		testutil.CleanTable(ctx, conn, "`sys_user`", id)
+	}
+	return id, cleanup
+}
+
+// TC-1216: cap.js 未启用 + 验证码为空
+func TestAdminLogin_CaptchaDisabled_EmptyCaptcha(t *testing.T) {
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
+	logic := NewAdminLoginLogic(context.Background(), svcCtx)
+
+	resp, err := logic.AdminLogin(&types.AdminLoginReq{
+		Username:      "admin",
+		Password:      "pass",
+		ManagementKey: "test-management-key",
+		CaptchaId:     "",
+		CaptchaCode:   "",
+	})
+	require.Nil(t, resp)
+	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-1217: cap.js 未启用 + 验证码错误/过期
+func TestAdminLogin_CaptchaDisabled_WrongCaptcha(t *testing.T) {
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
+	logic := NewAdminLoginLogic(context.Background(), svcCtx)
+
+	resp, err := logic.AdminLogin(&types.AdminLoginReq{
+		Username:      "admin",
+		Password:      "pass",
+		ManagementKey: "test-management-key",
+		CaptchaId:     "bad_id",
+		CaptchaCode:   "0000",
+	})
+	require.Nil(t, resp)
+	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-1218: cap.js 未启用 + 验证码正确 → 超管正常登录
+func TestAdminLogin_CaptchaDisabled_CorrectCaptcha(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
+	username := testutil.UniqueId()
+	password := "SuperPass123"
+
+	_, cleanUser := insertSuperAdmin(t, ctx, svcCtx, username, password)
+	t.Cleanup(cleanUser)
+
+	captchaId := "test_admin_captcha_" + testutil.UniqueId()
+	captchaCode := "4321"
+	defaultCaptchaStore.Set(captchaId, captchaCode)
+
+	logic := NewAdminLoginLogic(ctx, svcCtx)
+	resp, err := logic.AdminLogin(&types.AdminLoginReq{
+		Username:      username,
+		Password:      password,
+		ManagementKey: "test-management-key",
+		CaptchaId:     captchaId,
+		CaptchaCode:   captchaCode,
+	})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.NotEmpty(t, resp.AccessToken)
+	assert.NotEmpty(t, resp.RefreshToken)
+	assert.Equal(t, int64(1), resp.UserInfo.IsSuperAdmin)
+}

+ 86 - 114
internal/logic/pub/adminLoginLogic_test.go

@@ -3,24 +3,60 @@ package pub
 import (
 	"context"
 	"errors"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-	"github.com/zeromicro/go-zero/core/limit"
-	"github.com/zeromicro/go-zero/core/stores/redis"
+	"testing"
+	"time"
+
 	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
 	"perms-system-server/internal/types"
-	"testing"
-	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/limit"
+	"github.com/zeromicro/go-zero/core/stores/redis"
 )
 
+func newAdminLoginReq(username, password, managementKey string) *types.AdminLoginReq {
+	id, code := "cap_"+testutil.UniqueId(), "9999"
+	defaultCaptchaStore.Set(id, code)
+	return &types.AdminLoginReq{
+		Username:      username,
+		Password:      password,
+		ManagementKey: managementKey,
+		CaptchaId:     id,
+		CaptchaCode:   code,
+	}
+}
+
+// TC-1251: cap.js 已启用时管理后台传统登录接口被拒绝
+func TestAdminLogin_CapEnabled_Rejected(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx() // Capjs.Enable=1
+
+	logic := NewAdminLoginLogic(ctx, svcCtx)
+	resp, err := logic.AdminLogin(&types.AdminLoginReq{
+		Username:      "user",
+		Password:      "pass",
+		ManagementKey: svcCtx.Config.Auth.ManagementKey,
+	})
+	require.Nil(t, resp)
+	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-0015: 超管正常登录
 func TestAdminLogin_SuperAdmin(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
 	username := testutil.UniqueId()
 	password := "TestPass123"
+	captchaId, captchaCode := setupCaptcha(t)
 
 	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 1)
 	t.Cleanup(cleanUser)
@@ -30,6 +66,8 @@ func TestAdminLogin_SuperAdmin(t *testing.T) {
 		Username:      username,
 		Password:      password,
 		ManagementKey: svcCtx.Config.Auth.ManagementKey,
+		CaptchaId:     captchaId,
+		CaptchaCode:   captchaCode,
 	})
 	require.NoError(t, err)
 	require.NotNil(t, resp)
@@ -48,7 +86,7 @@ func TestAdminLogin_SuperAdmin(t *testing.T) {
 // TC-0016: 普通用户被拒绝(1修复: 仅超管可通过管理后台登录)
 func TestAdminLogin_NormalUserRejected(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
 	username := testutil.UniqueId()
 	password := "TestPass123"
 
@@ -56,11 +94,7 @@ func TestAdminLogin_NormalUserRejected(t *testing.T) {
 	t.Cleanup(cleanUser)
 
 	logic := NewAdminLoginLogic(ctx, svcCtx)
-	resp, err := logic.AdminLogin(&types.AdminLoginReq{
-		Username:      username,
-		Password:      password,
-		ManagementKey: svcCtx.Config.Auth.ManagementKey,
-	})
+	resp, err := logic.AdminLogin(newAdminLoginReq(username, password, svcCtx.Config.Auth.ManagementKey))
 	require.Nil(t, resp)
 	require.Error(t, err)
 
@@ -73,14 +107,10 @@ func TestAdminLogin_NormalUserRejected(t *testing.T) {
 // TC-0017: managementKey无效
 func TestAdminLogin_InvalidManagementKey(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
 
 	logic := NewAdminLoginLogic(ctx, svcCtx)
-	resp, err := logic.AdminLogin(&types.AdminLoginReq{
-		Username:      "anyone",
-		Password:      "pass",
-		ManagementKey: "wrong-key",
-	})
+	resp, err := logic.AdminLogin(newAdminLoginReq("anyone", "pass", "wrong-key"))
 	require.Nil(t, resp)
 	require.Error(t, err)
 
@@ -93,14 +123,10 @@ func TestAdminLogin_InvalidManagementKey(t *testing.T) {
 // TC-0018: managementKey为空
 func TestAdminLogin_EmptyManagementKey(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
 
 	logic := NewAdminLoginLogic(ctx, svcCtx)
-	resp, err := logic.AdminLogin(&types.AdminLoginReq{
-		Username:      "anyone",
-		Password:      "pass",
-		ManagementKey: "",
-	})
+	resp, err := logic.AdminLogin(newAdminLoginReq("anyone", "pass", ""))
 	require.Nil(t, resp)
 	require.Error(t, err)
 
@@ -113,14 +139,10 @@ func TestAdminLogin_EmptyManagementKey(t *testing.T) {
 // TC-0019: 用户不存在
 func TestAdminLogin_UserNotFound(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
 
 	logic := NewAdminLoginLogic(ctx, svcCtx)
-	resp, err := logic.AdminLogin(&types.AdminLoginReq{
-		Username:      "nonexistent_" + testutil.UniqueId(),
-		Password:      "whatever",
-		ManagementKey: svcCtx.Config.Auth.ManagementKey,
-	})
+	resp, err := logic.AdminLogin(newAdminLoginReq("nonexistent_"+testutil.UniqueId(), "whatever", svcCtx.Config.Auth.ManagementKey))
 	require.Nil(t, resp)
 	require.Error(t, err)
 
@@ -133,18 +155,14 @@ func TestAdminLogin_UserNotFound(t *testing.T) {
 // TC-0020: 密码错误
 func TestAdminLogin_WrongPassword(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
 	username := testutil.UniqueId()
 
 	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, "CorrectPass", 1, 2)
 	t.Cleanup(cleanUser)
 
 	logic := NewAdminLoginLogic(ctx, svcCtx)
-	resp, err := logic.AdminLogin(&types.AdminLoginReq{
-		Username:      username,
-		Password:      "WrongPass",
-		ManagementKey: svcCtx.Config.Auth.ManagementKey,
-	})
+	resp, err := logic.AdminLogin(newAdminLoginReq(username, "WrongPass", svcCtx.Config.Auth.ManagementKey))
 	require.Nil(t, resp)
 	require.Error(t, err)
 
@@ -157,7 +175,7 @@ func TestAdminLogin_WrongPassword(t *testing.T) {
 // TC-0021: 账号冻结
 func TestAdminLogin_AccountFrozen(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
 	username := testutil.UniqueId()
 	password := "TestPass123"
 
@@ -165,11 +183,7 @@ func TestAdminLogin_AccountFrozen(t *testing.T) {
 	t.Cleanup(cleanUser)
 
 	logic := NewAdminLoginLogic(ctx, svcCtx)
-	resp, err := logic.AdminLogin(&types.AdminLoginReq{
-		Username:      username,
-		Password:      password,
-		ManagementKey: svcCtx.Config.Auth.ManagementKey,
-	})
+	resp, err := logic.AdminLogin(newAdminLoginReq(username, password, svcCtx.Config.Auth.ManagementKey))
 	require.Nil(t, resp)
 	require.Error(t, err)
 
@@ -182,7 +196,7 @@ func TestAdminLogin_AccountFrozen(t *testing.T) {
 // TC-0022: 不带productCode时token无权限(perms为空)
 func TestAdminLogin_NoPermsWithoutProductCode(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
 	username := testutil.UniqueId()
 	password := "TestPass123"
 
@@ -190,11 +204,7 @@ func TestAdminLogin_NoPermsWithoutProductCode(t *testing.T) {
 	t.Cleanup(cleanUser)
 
 	logic := NewAdminLoginLogic(ctx, svcCtx)
-	resp, err := logic.AdminLogin(&types.AdminLoginReq{
-		Username:      username,
-		Password:      password,
-		ManagementKey: svcCtx.Config.Auth.ManagementKey,
-	})
+	resp, err := logic.AdminLogin(newAdminLoginReq(username, password, svcCtx.Config.Auth.ManagementKey))
 	require.NoError(t, err)
 	require.NotNil(t, resp)
 	assert.NotNil(t, resp.UserInfo.Perms, "Perms 必须为非 nil 的空 slice([]string{})")
@@ -205,7 +215,7 @@ func TestAdminLogin_NoPermsWithoutProductCode(t *testing.T) {
 // TC-0025: adminLogin 用户名级别限流(修复验证)
 func TestAdminLogin_UsernameRateLimit(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
 	require.NotNil(t, svcCtx.UsernameLoginLimit, "UsernameLoginLimit 应被配置")
 
 	username := "rl_" + testutil.UniqueId()
@@ -213,11 +223,7 @@ func TestAdminLogin_UsernameRateLimit(t *testing.T) {
 	logic := NewAdminLoginLogic(ctx, svcCtx)
 	var last error
 	for i := 0; i < 11; i++ {
-		_, last = logic.AdminLogin(&types.AdminLoginReq{
-			Username:      username,
-			Password:      "wrong_pass",
-			ManagementKey: svcCtx.Config.Auth.ManagementKey,
-		})
+		_, last = logic.AdminLogin(newAdminLoginReq(username, "wrong_pass", svcCtx.Config.Auth.ManagementKey))
 		require.Error(t, last)
 	}
 	var ce *response.CodeError
@@ -228,14 +234,10 @@ func TestAdminLogin_UsernameRateLimit(t *testing.T) {
 // TC-0024: SQL注入username
 func TestAdminLogin_SQLInjection(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
 
 	logic := NewAdminLoginLogic(ctx, svcCtx)
-	resp, err := logic.AdminLogin(&types.AdminLoginReq{
-		Username:      "' OR 1=1 --",
-		Password:      "anything",
-		ManagementKey: svcCtx.Config.Auth.ManagementKey,
-	})
+	resp, err := logic.AdminLogin(newAdminLoginReq("' OR 1=1 --", "anything", svcCtx.Config.Auth.ManagementKey))
 	require.Nil(t, resp)
 	require.Error(t, err)
 
@@ -248,8 +250,9 @@ func TestAdminLogin_SQLInjection(t *testing.T) {
 func newAdminLimitSvcCtx(t *testing.T, quota int) *svc.ServiceContext {
 	t.Helper()
 	cfg := testutil.GetTestConfig()
+	cfg.Capjs.Enable = 0
 	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
-	svcCtx := newTestSvcCtx()
+	svcCtx := svc.NewServiceContext(cfg)
 	svcCtx.UsernameLoginLimit = limit.NewPeriodLimit(300, quota, rds,
 		cfg.CacheRedis.KeyPrefix+":rl:adminlogin:ut:"+testutil.UniqueId())
 	return svcCtx
@@ -260,19 +263,15 @@ func TestAdminLogin_H1_SameIPSameUsername_OverQuota429(t *testing.T) {
 	svcCtx := newAdminLimitSvcCtx(t, 1)
 	username := "h1_user_" + testutil.UniqueId()
 	ctx := middleware.WithClientIP(context.Background(), "1.2.3.4")
-	req := &types.AdminLoginReq{
-		Username:      username,
-		Password:      "bad",
-		ManagementKey: svcCtx.Config.Auth.ManagementKey,
-	}
+	mk := svcCtx.Config.Auth.ManagementKey
 
-	_, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(req)
+	_, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk))
 	require.Error(t, err)
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
 	assert.Equal(t, 401, ce.Code(), "首次调用应被限流放行并进入业务层,得到 401")
 
-	_, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(req)
+	_, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk))
 	require.Error(t, err)
 	require.True(t, errors.As(err, &ce))
 	assert.Equal(t, 429, ce.Code(), "同 IP+同 username 第二次必须 429")
@@ -283,26 +282,22 @@ func TestAdminLogin_H1_SameIPSameUsername_OverQuota429(t *testing.T) {
 func TestAdminLogin_H1_DifferentIPSameUsername_IndependentBucket(t *testing.T) {
 	svcCtx := newAdminLimitSvcCtx(t, 1)
 	username := "h1_iso_" + testutil.UniqueId()
-	req := &types.AdminLoginReq{
-		Username:      username,
-		Password:      "bad",
-		ManagementKey: svcCtx.Config.Auth.ManagementKey,
-	}
+	mk := svcCtx.Config.Auth.ManagementKey
 
 	ctxA := middleware.WithClientIP(context.Background(), "10.0.0.1")
-	_, err := NewAdminLoginLogic(ctxA, svcCtx).AdminLogin(req)
+	_, err := NewAdminLoginLogic(ctxA, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk))
 	require.Error(t, err)
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
 	assert.Equal(t, 401, ce.Code())
 
-	_, err = NewAdminLoginLogic(ctxA, svcCtx).AdminLogin(req)
+	_, err = NewAdminLoginLogic(ctxA, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk))
 	require.Error(t, err)
 	require.True(t, errors.As(err, &ce))
 	assert.Equal(t, 429, ce.Code(), "IP-A 配额已满")
 
 	ctxB := middleware.WithClientIP(context.Background(), "10.0.0.2")
-	_, err = NewAdminLoginLogic(ctxB, svcCtx).AdminLogin(req)
+	_, err = NewAdminLoginLogic(ctxB, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk))
 	require.Error(t, err)
 	require.True(t, errors.As(err, &ce))
 	assert.Equal(t, 401, ce.Code(),
@@ -313,20 +308,16 @@ func TestAdminLogin_H1_DifferentIPSameUsername_IndependentBucket(t *testing.T) {
 func TestAdminLogin_H1_MissingClientIP_FallbackBucket(t *testing.T) {
 	svcCtx := newAdminLimitSvcCtx(t, 1)
 	username := "h1_unk_" + testutil.UniqueId()
-	req := &types.AdminLoginReq{
-		Username:      username,
-		Password:      "bad",
-		ManagementKey: svcCtx.Config.Auth.ManagementKey,
-	}
+	mk := svcCtx.Config.Auth.ManagementKey
 	ctx := context.Background()
 
-	_, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(req)
+	_, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk))
 	require.Error(t, err)
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
 	assert.Equal(t, 401, ce.Code())
 
-	_, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(req)
+	_, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "bad", mk))
 	require.Error(t, err)
 	require.True(t, errors.As(err, &ce))
 	assert.Equal(t, 429, ce.Code(),
@@ -339,31 +330,24 @@ func TestAdminLogin_H1_BadManagementKey_DoesNotConsumeQuota(t *testing.T) {
 	username := "h1_mk_" + testutil.UniqueId()
 	ctx := middleware.WithClientIP(context.Background(), "172.16.0.9")
 
-	_, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(&types.AdminLoginReq{
-		Username:      username,
-		Password:      "whatever",
-		ManagementKey: "WRONG-KEY",
-	})
+	_, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "whatever", "WRONG-KEY"))
 	require.Error(t, err)
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
 	assert.Equal(t, 401, ce.Code())
 	assert.Equal(t, "managementKey无效", ce.Error())
 
-	_, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(&types.AdminLoginReq{
-		Username:      username,
-		Password:      "whatever",
-		ManagementKey: svcCtx.Config.Auth.ManagementKey,
-	})
+	_, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, "whatever", svcCtx.Config.Auth.ManagementKey))
 	require.Error(t, err)
 	require.True(t, errors.As(err, &ce))
 	assert.Equal(t, 401, ce.Code(),
 		"managementKey 错误应在 Take 之前 return,不应消耗 per-IP+user 配额")
 }
 
+// TC-1008: 非超管+错密码 vs 用户不存在,响应不得区分两条分支
 func TestAdminLogin_LN3_NonSuperAdminWrongPassword_IndistinguishableFromAbsent(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
 	svcCtx.UsernameLoginLimit = nil
 
 	username := "ln3_nonsa_" + testutil.UniqueId()
@@ -374,21 +358,13 @@ func TestAdminLogin_LN3_NonSuperAdminWrongPassword_IndistinguishableFromAbsent(t
 	logic := NewAdminLoginLogic(ctx, svcCtx)
 
 	// (B) 用户存在但非超管 —— 走  新增的 dummy bcrypt 分支
-	_, errExisting := logic.AdminLogin(&types.AdminLoginReq{
-		Username:      username,
-		Password:      "WrongPass",
-		ManagementKey: svcCtx.Config.Auth.ManagementKey,
-	})
+	_, errExisting := logic.AdminLogin(newAdminLoginReq(username, "WrongPass", svcCtx.Config.Auth.ManagementKey))
 	require.Error(t, errExisting)
 	var ceB *response.CodeError
 	require.True(t, errors.As(errExisting, &ceB))
 
 	// (A) 用户不存在 —— 原有 dummy bcrypt 分支
-	_, errAbsent := logic.AdminLogin(&types.AdminLoginReq{
-		Username:      "ln3_absent_" + testutil.UniqueId(),
-		Password:      "WhateverPass",
-		ManagementKey: svcCtx.Config.Auth.ManagementKey,
-	})
+	_, errAbsent := logic.AdminLogin(newAdminLoginReq("ln3_absent_"+testutil.UniqueId(), "WhateverPass", svcCtx.Config.Auth.ManagementKey))
 	require.Error(t, errAbsent)
 	var ceA *response.CodeError
 	require.True(t, errors.As(errAbsent, &ceA))
@@ -404,7 +380,7 @@ func TestAdminLogin_LN3_NonSuperAdminWrongPassword_IndistinguishableFromAbsent(t
 // 保证即使攻击者命中密码,也不得通过 response 推断该账号是"存在的普通用户"。
 func TestAdminLogin_LN3_NonSuperAdminCorrectPassword_Still401(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
 	svcCtx.UsernameLoginLimit = nil
 
 	username := "ln3_cp_" + testutil.UniqueId()
@@ -412,11 +388,7 @@ func TestAdminLogin_LN3_NonSuperAdminCorrectPassword_Still401(t *testing.T) {
 	_, clean := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
 	t.Cleanup(clean)
 
-	_, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(&types.AdminLoginReq{
-		Username:      username,
-		Password:      password,
-		ManagementKey: svcCtx.Config.Auth.ManagementKey,
-	})
+	_, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(newAdminLoginReq(username, password, svcCtx.Config.Auth.ManagementKey))
 	require.Error(t, err)
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
@@ -437,7 +409,7 @@ func TestAdminLogin_LN3_DummyBcryptBranches_TimingEqualized(t *testing.T) {
 		t.Skip("timing-sensitive test skipped under -short")
 	}
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newAdminCaptchaDisabledSvcCtx()
 	svcCtx.UsernameLoginLimit = nil
 
 	normalUser := "ln3_t_nm_" + testutil.UniqueId()
@@ -448,12 +420,12 @@ func TestAdminLogin_LN3_DummyBcryptBranches_TimingEqualized(t *testing.T) {
 	mk := svcCtx.Config.Auth.ManagementKey
 
 	measure := func(username, password string) time.Duration {
-		_, _ = logic.AdminLogin(&types.AdminLoginReq{Username: username, Password: password, ManagementKey: mk})
+		_, _ = logic.AdminLogin(newAdminLoginReq(username, password, mk))
 		const N = 3
 		var total time.Duration
 		for i := 0; i < N; i++ {
 			start := time.Now()
-			_, _ = logic.AdminLogin(&types.AdminLoginReq{Username: username, Password: password, ManagementKey: mk})
+			_, _ = logic.AdminLogin(newAdminLoginReq(username, password, mk))
 			total += time.Since(start)
 		}
 		return total / N

+ 68 - 0
internal/logic/pub/capEndpointLogic_test.go

@@ -0,0 +1,68 @@
+package pub
+
+import (
+	"context"
+	"testing"
+
+	"perms-system-server/internal/config"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TC-1211: cap.js 已启用
+func TestCapEndpoint_Enabled(t *testing.T) {
+	cfg := testutil.GetTestConfig()
+	cfg.Capjs = config.CapjsConf{
+		Enable:      1,
+		EndpointURL: "https://cap.example.com",
+		Key:         "site-key-123",
+		Secret:      "secret",
+	}
+	svcCtx := svc.NewServiceContext(cfg)
+	logic := NewCapEndpointLogic(context.Background(), svcCtx)
+
+	resp, err := logic.CapEndpoint()
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.Equal(t, "https://cap.example.com/site-key-123/", resp.Data)
+}
+
+// TC-1212: cap.js 未启用(Enable=0)
+func TestCapEndpoint_Disabled(t *testing.T) {
+	cfg := testutil.GetTestConfig()
+	cfg.Capjs = config.CapjsConf{Enable: 0}
+	svcCtx := svc.NewServiceContext(cfg)
+	logic := NewCapEndpointLogic(context.Background(), svcCtx)
+
+	resp, err := logic.CapEndpoint()
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.Equal(t, "", resp.Data)
+}
+
+// TC-1255: cap.js 启用但 EndpointURL 为空
+func TestCapEndpoint_EnabledButNoURL(t *testing.T) {
+	cfg := testutil.GetTestConfig()
+	cfg.Capjs = config.CapjsConf{Enable: 1, EndpointURL: "", Key: "k"}
+	svcCtx := svc.NewServiceContext(cfg)
+	logic := NewCapEndpointLogic(context.Background(), svcCtx)
+
+	resp, err := logic.CapEndpoint()
+	require.NoError(t, err)
+	assert.Equal(t, "", resp.Data)
+}
+
+// TC-1256: cap.js 启用但 Key 为空
+func TestCapEndpoint_EnabledButNoKey(t *testing.T) {
+	cfg := testutil.GetTestConfig()
+	cfg.Capjs = config.CapjsConf{Enable: 1, EndpointURL: "https://cap.example.com", Key: ""}
+	svcCtx := svc.NewServiceContext(cfg)
+	logic := NewCapEndpointLogic(context.Background(), svcCtx)
+
+	resp, err := logic.CapEndpoint()
+	require.NoError(t, err)
+	assert.Equal(t, "", resp.Data)
+}

+ 88 - 0
internal/logic/pub/captchaLogic_test.go

@@ -0,0 +1,88 @@
+package pub
+
+import (
+	"context"
+	"testing"
+
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TC-1208: 正常获取(默认宽高)
+func TestCaptcha_DefaultSize(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	logic := NewCaptchaLogic(context.Background(), svcCtx)
+
+	resp, err := logic.Captcha(&types.CaptchaReq{})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.NotEmpty(t, resp.Id)
+	assert.NotEmpty(t, resp.Base64Image)
+}
+
+// TC-1209: 自定义宽高
+func TestCaptcha_CustomSize(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	logic := NewCaptchaLogic(context.Background(), svcCtx)
+
+	resp, err := logic.Captcha(&types.CaptchaReq{Width: 300, Height: 100})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.NotEmpty(t, resp.Id)
+	assert.NotEmpty(t, resp.Base64Image)
+}
+
+// TC-1210: 宽高为 0 或负数,退化为默认值
+func TestCaptcha_ZeroOrNegativeSize(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	logic := NewCaptchaLogic(context.Background(), svcCtx)
+
+	cases := []struct {
+		name   string
+		width  int
+		height int
+	}{
+		{"zero_both", 0, 0},
+		{"negative_width", -1, 80},
+		{"negative_height", 240, -1},
+		{"negative_both", -100, -100},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			resp, err := logic.Captcha(&types.CaptchaReq{Width: tc.width, Height: tc.height})
+			require.NoError(t, err)
+			require.NotNil(t, resp)
+			assert.NotEmpty(t, resp.Id)
+			assert.NotEmpty(t, resp.Base64Image)
+		})
+	}
+}
+
+// TC-1252: VerifyCaptcha 验证:正确码消费后不可重用
+func TestVerifyCaptcha_CorrectCodeConsumes(t *testing.T) {
+	id := "test_captcha_id_001"
+	code := "1234"
+	defaultCaptchaStore.Set(id, code)
+
+	assert.True(t, VerifyCaptcha(id, code))
+	assert.False(t, VerifyCaptcha(id, code), "should be consumed after first verification")
+}
+
+// TC-1253: VerifyCaptcha 验证:错误码不消费
+func TestVerifyCaptcha_WrongCode(t *testing.T) {
+	id := "test_captcha_id_002"
+	code := "5678"
+	defaultCaptchaStore.Set(id, code)
+
+	assert.False(t, VerifyCaptcha(id, "0000"))
+}
+
+// TC-1254: VerifyCaptcha 验证:不存在的 id
+func TestVerifyCaptcha_NonExistentId(t *testing.T) {
+	assert.False(t, VerifyCaptcha("non_existent_id", "1234"))
+}

+ 206 - 0
internal/logic/pub/loginByCapLogic_test.go

@@ -0,0 +1,206 @@
+package pub
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/config"
+	productmemberModel "perms-system-server/internal/model/productmember"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func newCapMockServer(success bool) *httptest.Server {
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]bool{"success": success})
+	}))
+}
+
+func newCapEnabledSvcCtx(endpointURL string) *svc.ServiceContext {
+	cfg := testutil.GetTestConfig()
+	cfg.Capjs = config.CapjsConf{
+		Enable:      1,
+		EndpointURL: endpointURL,
+		Key:         "test-key",
+		Secret:      "test-secret",
+	}
+	return svc.NewServiceContext(cfg)
+}
+
+// TC-1219: cap.js 未启用时调用 LoginByCap
+func TestLoginByCap_CapDisabled(t *testing.T) {
+	cfg := testutil.GetTestConfig()
+	cfg.Capjs = config.CapjsConf{Enable: 0}
+	svcCtx := svc.NewServiceContext(cfg)
+
+	logic := NewLoginByCapLogic(context.Background(), svcCtx)
+	resp, err := logic.LoginByCap(&types.LoginByCapReq{
+		Username:    "user",
+		Password:    "pass",
+		ProductCode: "pc",
+		CapToken:    "some-token",
+	})
+	require.Nil(t, resp)
+	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-1220: capToken 为空
+func TestLoginByCap_EmptyCapToken(t *testing.T) {
+	server := newCapMockServer(true)
+	defer server.Close()
+	svcCtx := newCapEnabledSvcCtx(server.URL)
+
+	logic := NewLoginByCapLogic(context.Background(), svcCtx)
+	resp, err := logic.LoginByCap(&types.LoginByCapReq{
+		Username:    "user",
+		Password:    "pass",
+		ProductCode: "pc",
+		CapToken:    "",
+	})
+	require.Nil(t, resp)
+	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-1221: capToken 无效(远端校验失败)
+func TestLoginByCap_InvalidCapToken(t *testing.T) {
+	server := newCapMockServer(false)
+	defer server.Close()
+	svcCtx := newCapEnabledSvcCtx(server.URL)
+
+	logic := NewLoginByCapLogic(context.Background(), svcCtx)
+	resp, err := logic.LoginByCap(&types.LoginByCapReq{
+		Username:    "user",
+		Password:    "pass",
+		ProductCode: "pc",
+		CapToken:    "invalid-token",
+	})
+	require.Nil(t, resp)
+	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-1222: capToken 有效 + 正常登录
+func TestLoginByCap_ValidToken_Success(t *testing.T) {
+	ctx := context.Background()
+	server := newCapMockServer(true)
+	defer server.Close()
+	svcCtx := newCapEnabledSvcCtx(server.URL)
+	conn := testutil.GetTestSqlConn()
+	username := testutil.UniqueId()
+	password := "TestPass123"
+	pc := testutil.UniqueId()
+	now := time.Now().Unix()
+
+	userId, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
+	t.Cleanup(cleanUser)
+
+	_, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret")
+	t.Cleanup(cleanProduct)
+
+	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{
+		ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pmId, _ := pmRes.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId) })
+
+	logic := NewLoginByCapLogic(ctx, svcCtx)
+	resp, err := logic.LoginByCap(&types.LoginByCapReq{
+		Username:    username,
+		Password:    password,
+		ProductCode: pc,
+		CapToken:    "valid-token",
+	})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.NotEmpty(t, resp.AccessToken)
+	assert.NotEmpty(t, resp.RefreshToken)
+	assert.Equal(t, username, resp.UserInfo.Username)
+}
+
+// TC-1223: capToken 有效 + 密码错误
+func TestLoginByCap_ValidToken_WrongPassword(t *testing.T) {
+	ctx := context.Background()
+	server := newCapMockServer(true)
+	defer server.Close()
+	svcCtx := newCapEnabledSvcCtx(server.URL)
+	username := testutil.UniqueId()
+	pc := testutil.UniqueId()
+
+	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, "RealPass123", 1, 2)
+	t.Cleanup(cleanUser)
+
+	_, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret")
+	t.Cleanup(cleanProduct)
+
+	logic := NewLoginByCapLogic(ctx, svcCtx)
+	resp, err := logic.LoginByCap(&types.LoginByCapReq{
+		Username:    username,
+		Password:    "WrongPass",
+		ProductCode: pc,
+		CapToken:    "valid-token",
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 401, codeErr.Code())
+}
+
+// TC-1224: capToken 有效 + 超管被拒绝
+func TestLoginByCap_ValidToken_SuperAdminRejected(t *testing.T) {
+	ctx := context.Background()
+	server := newCapMockServer(true)
+	defer server.Close()
+	svcCtx := newCapEnabledSvcCtx(server.URL)
+	username := testutil.UniqueId()
+	password := "TestPass123"
+	pc := testutil.UniqueId()
+
+	_, cleanUser := insertSuperAdmin(t, ctx, svcCtx, username, password)
+	t.Cleanup(cleanUser)
+
+	_, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret")
+	t.Cleanup(cleanProduct)
+
+	logic := NewLoginByCapLogic(ctx, svcCtx)
+	resp, err := logic.LoginByCap(&types.LoginByCapReq{
+		Username:    username,
+		Password:    password,
+		ProductCode: pc,
+		CapToken:    "valid-token",
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 403, codeErr.Code())
+}
+

+ 9 - 8
internal/logic/pub/loginLogic.go

@@ -29,15 +29,16 @@ func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic
 // Login 产品端登录。产品成员通过用户名密码 + productCode 登录指定产品,返回 JWT 令牌对及用户权限信息。
 // 当 cap.js 未启用时,需同时携带 captchaId/captchaCode 进行图片验证码校验。
 func (l *LoginLogic) Login(req *types.LoginReq) (resp *types.LoginResp, err error) {
-	// cap.js 未启用时强制校验图片验证码
+	// cap.js 启用时拒绝传统登录接口,必须走 /auth/login/cap
 	cfg := l.svcCtx.Config.Capjs
-	if cfg.Enable != 1 {
-		if req.CaptchaId == "" || req.CaptchaCode == "" {
-			return nil, response.ErrBadRequest("验证码不能为空")
-		}
-		if !VerifyCaptcha(req.CaptchaId, req.CaptchaCode) {
-			return nil, response.ErrBadRequest("验证码错误或已过期")
-		}
+	if cfg.Enable == 1 {
+		return nil, response.ErrBadRequest("当前已启用人机验证,请使用人机验证登录")
+	}
+	if req.CaptchaId == "" || req.CaptchaCode == "" {
+		return nil, response.ErrBadRequest("验证码不能为空")
+	}
+	if !VerifyCaptcha(req.CaptchaId, req.CaptchaCode) {
+		return nil, response.ErrBadRequest("验证码错误或已过期")
 	}
 
 	clientIP := middleware.GetClientIP(l.ctx)

+ 129 - 0
internal/logic/pub/loginLogic_captcha_test.go

@@ -0,0 +1,129 @@
+package pub
+
+import (
+	"context"
+	"errors"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/config"
+	productmemberModel "perms-system-server/internal/model/productmember"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func newCaptchaDisabledSvcCtx() *svc.ServiceContext {
+	cfg := testutil.GetTestConfig()
+	cfg.Capjs = config.CapjsConf{Enable: 0}
+	return svc.NewServiceContext(cfg)
+}
+
+// TC-1213: cap.js 未启用 + 验证码为空
+func TestLogin_CaptchaDisabled_EmptyCaptcha(t *testing.T) {
+	svcCtx := newCaptchaDisabledSvcCtx()
+	logic := NewLoginLogic(context.Background(), svcCtx)
+
+	resp, err := logic.Login(&types.LoginReq{
+		Username:    "testuser",
+		Password:    "pass123",
+		ProductCode: "pc",
+		CaptchaId:   "",
+		CaptchaCode: "",
+	})
+	require.Nil(t, resp)
+	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-1214: cap.js 未启用 + 验证码错误/过期
+func TestLogin_CaptchaDisabled_WrongCaptcha(t *testing.T) {
+	svcCtx := newCaptchaDisabledSvcCtx()
+	logic := NewLoginLogic(context.Background(), svcCtx)
+
+	resp, err := logic.Login(&types.LoginReq{
+		Username:    "testuser",
+		Password:    "pass123",
+		ProductCode: "pc",
+		CaptchaId:   "non_existent_captcha",
+		CaptchaCode: "0000",
+	})
+	require.Nil(t, resp)
+	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-1215: cap.js 未启用 + 验证码正确 → 正常登录
+func TestLogin_CaptchaDisabled_CorrectCaptcha(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newCaptchaDisabledSvcCtx()
+	conn := testutil.GetTestSqlConn()
+	username := testutil.UniqueId()
+	password := "TestPass123"
+	pc := testutil.UniqueId()
+	now := time.Now().Unix()
+
+	userId, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
+	t.Cleanup(cleanUser)
+
+	_, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret")
+	t.Cleanup(cleanProduct)
+
+	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{
+		ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pmId, _ := pmRes.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId) })
+
+	captchaId := "test_login_captcha_" + testutil.UniqueId()
+	captchaCode := "9876"
+	defaultCaptchaStore.Set(captchaId, captchaCode)
+
+	logic := NewLoginLogic(ctx, svcCtx)
+	resp, err := logic.Login(&types.LoginReq{
+		Username:    username,
+		Password:    password,
+		ProductCode: pc,
+		CaptchaId:   captchaId,
+		CaptchaCode: captchaCode,
+	})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.NotEmpty(t, resp.AccessToken)
+	assert.NotEmpty(t, resp.RefreshToken)
+	assert.Equal(t, username, resp.UserInfo.Username)
+}
+
+// TC-1250: 验证 cap.js 已启用时传统登录接口被拒绝
+func TestLogin_CapEnabled_Rejected(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx() // Capjs.Enable=1
+
+	logic := NewLoginLogic(ctx, svcCtx)
+	resp, err := logic.Login(&types.LoginReq{
+		Username:    "user",
+		Password:    "pass",
+		ProductCode: "pc",
+	})
+	require.Nil(t, resp)
+	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(), "当前已启用人机验证")
+}
+

+ 8 - 2
internal/logic/pub/loginLogic_mock_test.go

@@ -25,11 +25,17 @@ func TestLogin_Mock_FindOneByUsernameDBError(t *testing.T) {
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
 		User: mockUser,
 	})
+	svcCtx.Config.Capjs.Enable = 0
+
+	captchaId := "mock_cap_" + t.Name()
+	defaultCaptchaStore.Set(captchaId, "1234")
 
 	logic := NewLoginLogic(context.Background(), svcCtx)
 	resp, err := logic.Login(&types.LoginReq{
-		Username: "testuser",
-		Password: "pass123",
+		Username:    "testuser",
+		Password:    "pass123",
+		CaptchaId:   captchaId,
+		CaptchaCode: "1234",
 	})
 
 	assert.ErrorIs(t, err, dbErr)

+ 66 - 22
internal/logic/pub/loginLogic_test.go

@@ -24,6 +24,14 @@ func newTestSvcCtx() *svc.ServiceContext {
 	return svc.NewServiceContext(testutil.GetTestConfig())
 }
 
+func setupCaptcha(t *testing.T) (string, string) {
+	t.Helper()
+	id := "captcha_" + testutil.UniqueId()
+	code := "1234"
+	defaultCaptchaStore.Set(id, code)
+	return id, code
+}
+
 func insertTestUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, username, password string, status, isSuperAdmin int64) (int64, func()) {
 	t.Helper()
 	conn := testutil.GetTestSqlConn()
@@ -76,12 +84,13 @@ func insertTestProduct(t *testing.T, ctx context.Context, svcCtx *svc.ServiceCon
 // TC-0001: 正常登录(普通用户+productCode)
 func TestLogin_NormalWithProductCodeBasic(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newCaptchaDisabledSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	username := testutil.UniqueId()
 	password := "TestPass123"
 	pc := testutil.UniqueId()
 	now := time.Now().Unix()
+	captchaId, captchaCode := setupCaptcha(t)
 
 	userId, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
 	t.Cleanup(cleanUser)
@@ -101,6 +110,8 @@ func TestLogin_NormalWithProductCodeBasic(t *testing.T) {
 		Username:    username,
 		Password:    password,
 		ProductCode: pc,
+		CaptchaId:   captchaId,
+		CaptchaCode: captchaCode,
 	})
 	require.NoError(t, err)
 	require.NotNil(t, resp)
@@ -114,12 +125,13 @@ func TestLogin_NormalWithProductCodeBasic(t *testing.T) {
 // TC-0002: 正常登录-带productCode
 func TestLogin_NormalWithProductCode(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newCaptchaDisabledSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	username := testutil.UniqueId()
 	password := "TestPass123"
 	pc := testutil.UniqueId()
 	now := time.Now().Unix()
+	captchaId, captchaCode := setupCaptcha(t)
 
 	userId, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
 	t.Cleanup(cleanUser)
@@ -146,6 +158,8 @@ func TestLogin_NormalWithProductCode(t *testing.T) {
 		Username:    username,
 		Password:    password,
 		ProductCode: pc,
+		CaptchaId:   captchaId,
+		CaptchaCode: captchaCode,
 	})
 	require.NoError(t, err)
 	require.NotNil(t, resp)
@@ -156,10 +170,11 @@ func TestLogin_NormalWithProductCode(t *testing.T) {
 // TC-0003: 超管通过产品端登录被拒绝
 func TestLogin_SuperAdminRejected(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newCaptchaDisabledSvcCtx()
 	username := testutil.UniqueId()
 	password := "TestPass123"
 	pc := testutil.UniqueId()
+	captchaId, captchaCode := setupCaptcha(t)
 
 	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 1)
 	t.Cleanup(cleanUser)
@@ -172,6 +187,8 @@ func TestLogin_SuperAdminRejected(t *testing.T) {
 		Username:    username,
 		Password:    password,
 		ProductCode: pc,
+		CaptchaId:   captchaId,
+		CaptchaCode: captchaCode,
 	})
 	require.Nil(t, resp)
 	require.Error(t, err)
@@ -185,17 +202,20 @@ func TestLogin_SuperAdminRejected(t *testing.T) {
 // TC-0004: 超管无productCode被拒绝
 func TestLogin_SuperAdminWithoutProductCodeRejected(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newCaptchaDisabledSvcCtx()
 	username := testutil.UniqueId()
 	password := "TestPass123"
+	captchaId, captchaCode := setupCaptcha(t)
 
 	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 1)
 	t.Cleanup(cleanUser)
 
 	logic := NewLoginLogic(ctx, svcCtx)
 	resp, err := logic.Login(&types.LoginReq{
-		Username: username,
-		Password: password,
+		Username:    username,
+		Password:    password,
+		CaptchaId:   captchaId,
+		CaptchaCode: captchaCode,
 	})
 	require.Nil(t, resp)
 	require.Error(t, err)
@@ -209,12 +229,15 @@ func TestLogin_SuperAdminWithoutProductCodeRejected(t *testing.T) {
 // TC-0005: 用户不存在
 func TestLogin_UserNotFound(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newCaptchaDisabledSvcCtx()
+	captchaId, captchaCode := setupCaptcha(t)
 
 	logic := NewLoginLogic(ctx, svcCtx)
 	resp, err := logic.Login(&types.LoginReq{
-		Username: "nonexistent_" + testutil.UniqueId(),
-		Password: "whatever",
+		Username:    "nonexistent_" + testutil.UniqueId(),
+		Password:    "whatever",
+		CaptchaId:   captchaId,
+		CaptchaCode: captchaCode,
 	})
 	require.Nil(t, resp)
 	require.Error(t, err)
@@ -228,16 +251,19 @@ func TestLogin_UserNotFound(t *testing.T) {
 // TC-0007: 密码错误
 func TestLogin_WrongPassword(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newCaptchaDisabledSvcCtx()
 	username := testutil.UniqueId()
+	captchaId, captchaCode := setupCaptcha(t)
 
 	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, "CorrectPass", 1, 2)
 	t.Cleanup(cleanUser)
 
 	logic := NewLoginLogic(ctx, svcCtx)
 	resp, err := logic.Login(&types.LoginReq{
-		Username: username,
-		Password: "WrongPass",
+		Username:    username,
+		Password:    "WrongPass",
+		CaptchaId:   captchaId,
+		CaptchaCode: captchaCode,
 	})
 	require.Nil(t, resp)
 	require.Error(t, err)
@@ -251,17 +277,20 @@ func TestLogin_WrongPassword(t *testing.T) {
 // TC-0008: 账号冻结
 func TestLogin_AccountFrozen(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newCaptchaDisabledSvcCtx()
 	username := testutil.UniqueId()
 	password := "TestPass123"
+	captchaId, captchaCode := setupCaptcha(t)
 
 	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 2, 2)
 	t.Cleanup(cleanUser)
 
 	logic := NewLoginLogic(ctx, svcCtx)
 	resp, err := logic.Login(&types.LoginReq{
-		Username: username,
-		Password: password,
+		Username:    username,
+		Password:    password,
+		CaptchaId:   captchaId,
+		CaptchaCode: captchaCode,
 	})
 	require.Nil(t, resp)
 	require.Error(t, err)
@@ -275,10 +304,11 @@ func TestLogin_AccountFrozen(t *testing.T) {
 // TC-0009: 非产品成员
 func TestLogin_NonMemberWithProductCode(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newCaptchaDisabledSvcCtx()
 	username := testutil.UniqueId()
 	password := "TestPass123"
 	pc := testutil.UniqueId()
+	captchaId, captchaCode := setupCaptcha(t)
 
 	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
 	t.Cleanup(cleanUser)
@@ -291,6 +321,8 @@ func TestLogin_NonMemberWithProductCode(t *testing.T) {
 		Username:    username,
 		Password:    password,
 		ProductCode: pc,
+		CaptchaId:   captchaId,
+		CaptchaCode: captchaCode,
 	})
 	require.Nil(t, resp)
 	require.Error(t, err)
@@ -305,12 +337,13 @@ func TestLogin_NonMemberWithProductCode(t *testing.T) {
 // TC-0010: DEVELOPER成员
 func TestLogin_DeveloperMember(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newCaptchaDisabledSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	username := testutil.UniqueId()
 	password := "TestPass123"
 	pc := testutil.UniqueId()
 	now := time.Now().Unix()
+	captchaId, captchaCode := setupCaptcha(t)
 
 	userId, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
 	t.Cleanup(cleanUser)
@@ -338,6 +371,8 @@ func TestLogin_DeveloperMember(t *testing.T) {
 		Username:    username,
 		Password:    password,
 		ProductCode: pc,
+		CaptchaId:   captchaId,
+		CaptchaCode: captchaCode,
 	})
 	require.NoError(t, err)
 	require.NotNil(t, resp)
@@ -348,12 +383,15 @@ func TestLogin_DeveloperMember(t *testing.T) {
 // TC-0011: SQL注入
 func TestLogin_SQLInjection(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newCaptchaDisabledSvcCtx()
+	captchaId, captchaCode := setupCaptcha(t)
 
 	logic := NewLoginLogic(ctx, svcCtx)
 	resp, err := logic.Login(&types.LoginReq{
-		Username: "' OR 1=1 --",
-		Password: "anything",
+		Username:    "' OR 1=1 --",
+		Password:    "anything",
+		CaptchaId:   captchaId,
+		CaptchaCode: captchaCode,
 	})
 	require.Nil(t, resp)
 	require.Error(t, err)
@@ -367,12 +405,13 @@ func TestLogin_SQLInjection(t *testing.T) {
 // TC-0013: 产品成员被禁用时拒绝登录(修复验证)
 func TestLogin_DisabledMemberRejected(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newCaptchaDisabledSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	username := testutil.UniqueId()
 	password := "TestPass123"
 	pc := testutil.UniqueId()
 	now := time.Now().Unix()
+	captchaId, captchaCode := setupCaptcha(t)
 
 	userId, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
 	t.Cleanup(cleanUser)
@@ -392,6 +431,8 @@ func TestLogin_DisabledMemberRejected(t *testing.T) {
 		Username:    username,
 		Password:    password,
 		ProductCode: pc,
+		CaptchaId:   captchaId,
+		CaptchaCode: captchaCode,
 	})
 	require.Nil(t, resp)
 	require.Error(t, err)
@@ -406,12 +447,13 @@ func TestLogin_DisabledMemberRejected(t *testing.T) {
 // TC-0014: 产品已被禁用时拒绝登录
 func TestLogin_DisabledProductRejected(t *testing.T) {
 	ctx := context.Background()
-	svcCtx := newTestSvcCtx()
+	svcCtx := newCaptchaDisabledSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	username := testutil.UniqueId()
 	password := "TestPass123"
 	pc := testutil.UniqueId()
 	now := time.Now().Unix()
+	captchaId, captchaCode := setupCaptcha(t)
 
 	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
 	t.Cleanup(cleanUser)
@@ -429,6 +471,8 @@ func TestLogin_DisabledProductRejected(t *testing.T) {
 		Username:    username,
 		Password:    password,
 		ProductCode: pc,
+		CaptchaId:   captchaId,
+		CaptchaCode: captchaCode,
 	})
 	require.Nil(t, resp)
 	require.Error(t, err)

+ 2 - 0
internal/logic/pub/loginService_test.go

@@ -11,6 +11,7 @@ import (
 	"testing"
 )
 
+// TC-0838: 冻结用户 + 错误密码 —— 不得返回 403,必须与"用户不存在"合并为 401
 func TestValidateProductLogin_FrozenWrongPassword_Return401(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
@@ -108,6 +109,7 @@ func TestValidateProductLogin_UnknownUserSame401(t *testing.T) {
 		"未知用户 / 冻结+错密 / 存在+错密 三路必须归一为同一 401 + 文案")
 }
 
+// TC-0751: 用户名不存在 + 任意密码,不得暴露差异化文案
 func TestValidateProductLogin_UnknownUserSameError(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()

+ 75 - 0
test-design.md

@@ -92,6 +92,10 @@ MySQL (InnoDB) + Redis Cache
 | TC-0840 | POST /api/auth/login | 超管走产品端登录 + 错误密码 | IsSuperAdmin=1,密码错误 | 401 "用户名或密码错误"(不得提前暴露"超管"身份) | 安全/侧信道 | P0 | 超管状态同样延迟披露 |
 | TC-0841 | POST /api/auth/login | 超管走产品端登录 + 正确密码 | IsSuperAdmin=1,密码正确 | 403 "超级管理员不允许通过产品端登录,请使用管理后台" | 正常 | P0 | 披露顺序反转后的正面路径仍保持原有业务语义 |
 | TC-0842 | POST /api/auth/login | 用户名不存在 | 不存在用户名 + 任意密码 | 401 "用户名或密码错误",走 dummy bcrypt 恒时对齐 | 安全/枚举 | P0 | 沿用 dummy hash 路径,不被 H-2 新顺序破坏 |
+| TC-1213 | POST /api/auth/login | cap.js 未启用 + 验证码为空 | `{"username":"u","password":"p","productCode":"pc","captchaId":"","captchaCode":""}` | 400 "验证码不能为空" | 输入校验 | P0 | Capjs.Enable=0 时强制验证码校验 |
+| TC-1214 | POST /api/auth/login | cap.js 未启用 + 验证码错误/过期 | `{"captchaId":"non_existent","captchaCode":"0000"}` | 400 "验证码错误或已过期" | 异常路径 | P0 | VerifyCaptcha 失败分支 |
+| TC-1215 | POST /api/auth/login | cap.js 未启用 + 验证码正确 → 正常登录 | 预设验证码 + 有效凭证 | 200 + accessToken/refreshToken | 正常路径 | P0 | 验证码通过后走完整登录流程 |
+| TC-1250 | POST /api/auth/login | cap.js 已启用时传统接口被拒绝 | Capjs.Enable=1,任意凭证 | 400 "当前已启用人机验证,请使用人机验证登录" | 互斥门控 | P0 | Enable=1 时传统登录接口必须拒绝,强制走 /auth/login/cap |
 
 ### 2.1b 管理后台登录 `POST /api/auth/adminLogin`
 
@@ -117,6 +121,10 @@ MySQL (InnoDB) + Redis Cache
 | TC-1008 | POST /api/auth/adminLogin | 非超管+错密码 vs 用户不存在 | 错密码 / 不存在用户名 | code + body 完全一致 | 安全/Oracle | P0 | 响应不得区分两条分支 |
 | TC-1009 | POST /api/auth/adminLogin | 非超管+正确密码 | 真实普通用户正确密码 | 仍 401 "用户名或密码错误" | 安全 | P0 | 不得以 200 暴露账号存在性 |
 | TC-1010 | POST /api/auth/adminLogin | 两条 dummy bcrypt 分支时序 | 连续 3 次平均耗时 | `非超管+错密` 与 `不存在` 耗时比 <3× | 时序/性能 | P0 | 若比例 >3× 说明 L-N3 被回退(非超管分支跳过了 dummy bcrypt) |
+| TC-1216 | POST /api/auth/adminLogin | cap.js 未启用 + 验证码为空 | `{"username":"u","password":"p","managementKey":"valid","captchaId":"","captchaCode":""}` | 400 "验证码不能为空" | 输入校验 | P0 | Capjs.Enable=0 时强制验证码校验 |
+| TC-1217 | POST /api/auth/adminLogin | cap.js 未启用 + 验证码错误/过期 | `{"captchaId":"non_existent","captchaCode":"0000","managementKey":"valid"}` | 400 "验证码错误或已过期" | 异常路径 | P0 | VerifyCaptcha 失败分支 |
+| TC-1218 | POST /api/auth/adminLogin | cap.js 未启用 + 验证码正确 → 超管正常登录 | 预设验证码 + 超管凭证 + managementKey | 200 + accessToken/refreshToken | 正常路径 | P0 | 验证码通过后走完整管理端登录流程 |
+| TC-1251 | POST /api/auth/adminLogin | cap.js 已启用时传统接口被拒绝 | Capjs.Enable=1,任意凭证 + managementKey | 400 "当前已启用人机验证,请使用人机验证登录" | 互斥门控 | P0 | Enable=1 时传统管理端登录接口必须拒绝,强制走 /auth/adminLogin/cap |
 
 ### 2.2 刷新Token `POST /api/auth/refreshToken`
 
@@ -645,6 +653,73 @@ MySQL (InnoDB) + Redis Cache
 | TC-1162 | POST /api/member/remove | 移除成员后被移除用户 sys_user.tokenVersion 必须 +1 | seed 2 个 ADMIN 绕过 last-admin,`{id: targetMemberId}` | DB `sys_user.tokenVersion` 严格 +1;`sys_product_member` 行被删;post-commit 产品成员缓存失效 | 安全/会话吊销 | P0 | 镜像 updateMember 的 tokenVersion 契约,避免被踢出产品后旧 access token 仍能访问该产品 |
 | TC-1163 | POST /api/member/remove | 移除失败(last-admin 场景)时 tokenVersion 绝不得 +1 | 唯一启用 ADMIN,`{id: adminMemberId}` | 返回 400 "不能移除该产品的最后一个管理员";DB `sys_user.tokenVersion` 与初值严格相等;`sys_product_member` 行仍在 | 事务回滚 | P0 | tokenVersion 增量必须与 member 删除同事务;失败路径不得污染 tokenVersion 让合法会话被无故踢下线 |
 
+### 2.18 获取验证码 `POST /api/captcha/get`
+
+| TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1208 | POST /api/captcha/get | 正常获取(默认宽高) | `{}` | 200, id 非空, base64image 非空 | 正常路径 | P0 | captchaLogic 默认参数 |
+| TC-1209 | POST /api/captcha/get | 自定义宽高 | `{"width":200,"height":80}` | 200, 返回数据正常 | 正常路径 | P1 | 自定义尺寸分支 |
+| TC-1210 | POST /api/captcha/get | 宽高为 0 或负数,退化为默认值 | `{"width":0,"height":-1}` | 200, 不报错,使用默认宽高 | 边界 | P1 | 负值/零值兜底 |
+| TC-1252 | VerifyCaptcha | 正确码消费后不可重用 | Set(id, code) → Verify(id, code) ×2 | 首次 true,二次 false | 单元/安全 | P0 | 一次性消费语义防重放 |
+| TC-1253 | VerifyCaptcha | 错误码不消费 | Set(id, "5678") → Verify(id, "0000") | false | 单元 | P0 | 错误码不影响 store 状态 |
+| TC-1254 | VerifyCaptcha | 不存在的 id | Verify("non_existent", "1234") | false | 单元/边界 | P0 | 无条目直接拒绝 |
+
+### 2.19 Cap.js 端点 `POST /api/capjs/endpoint`
+
+| TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1211 | POST /api/capjs/endpoint | cap.js 已启用 | Capjs.Enable=1 + EndpointURL 非空 | 200, data=EndpointURL | 正常路径 | P0 | capEndpointLogic Enable=1 |
+| TC-1212 | POST /api/capjs/endpoint | cap.js 未启用 | Capjs.Enable=0 | 200, data="" | 分支覆盖 | P0 | capEndpointLogic Enable!=1 |
+| TC-1255 | POST /api/capjs/endpoint | cap.js 启用但 EndpointURL 为空 | Capjs.Enable=1, EndpointURL="" | 200, data="" | 边界 | P0 | 启用但 URL 缺失时兜底返回空 |
+| TC-1256 | POST /api/capjs/endpoint | cap.js 启用但 Key 为空 | Capjs.Enable=1, Key="" | 200, data="" | 边界 | P0 | 启用但 Key 缺失时兜底返回空 |
+
+### 2.20 产品端 Cap.js 登录 `POST /api/auth/login/cap`
+
+| TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1219 | POST /api/auth/login/cap | cap.js 未启用时调用 | Capjs.Enable=0 | 400 "当前未启用人机验证" | 前置校验 | P0 | Enable!=1 分支 |
+| TC-1220 | POST /api/auth/login/cap | capToken 为空 | `{"capToken":""}` | 400 "人机验证不能为空" | 输入校验 | P0 | 空 token 校验 |
+| TC-1221 | POST /api/auth/login/cap | capToken 无效(远端校验失败) | mock server 返回 `{"success":false}` | 400 "人机验证失败" | 异常路径 | P0 | 远端验证失败分支 |
+| TC-1222 | POST /api/auth/login/cap | capToken 有效 + 正常登录 | mock server 返回 `{"success":true}` + 有效凭证 | 200 + accessToken/refreshToken | 正常路径 | P0 | 全路径(mock HTTP 服务端) |
+| TC-1223 | POST /api/auth/login/cap | capToken 有效 + 密码错误 | mock 成功 + 错误密码 | 401 | 异常路径 | P0 | 验证通过后密码校验 |
+| TC-1224 | POST /api/auth/login/cap | capToken 有效 + 超管被拒绝 | mock 成功 + 超管用户 | 403 | 安全 | P0 | 超管不允许产品端登录 |
+
+### 2.21 管理后台 Cap.js 登录 `POST /api/auth/adminLogin/cap`
+
+| TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1225 | POST /api/auth/adminLogin/cap | cap.js 未启用时调用 | Capjs.Enable=0 | 400 "当前未启用人机验证" | 前置校验 | P0 | Enable!=1 分支 |
+| TC-1226 | POST /api/auth/adminLogin/cap | capToken 为空 | `{"capToken":""}` | 400 "人机验证不能为空" | 输入校验 | P0 | 空 token 校验 |
+| TC-1227 | POST /api/auth/adminLogin/cap | capToken 有效 + managementKey 无效 | mock 成功 + 错误 managementKey | 401 "managementKey无效" | 安全 | P0 | managementKey 校验 |
+| TC-1228 | POST /api/auth/adminLogin/cap | capToken 有效 + 超管正常登录 | mock 成功 + 超管凭证 + 正确 managementKey | 200 + accessToken/refreshToken | 正常路径 | P0 | 全路径 |
+| TC-1229 | POST /api/auth/adminLogin/cap | capToken 有效 + 非超管被拒绝 | mock 成功 + 普通用户 + 正确 managementKey | 401 | 安全 | P0 | 非超管拒绝管理后台登录 |
+
+### 2.22 更新用户信息 `POST /api/auth/updateInfo`
+
+| TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1230 | POST /api/auth/updateInfo | 未登录 | ctx 无 UserDetails | 401 "未登录" | 安全 | P0 | caller==nil |
+| TC-1231 | POST /api/auth/updateInfo | 所有字段为 nil | `{}` | 400 "至少需要修改一个字段" | 输入校验 | P0 | 全 nil 校验 |
+| TC-1232 | POST /api/auth/updateInfo | 正常更新 nickname | `{"nickname":"new_nick"}` | 200, DB 中 nickname 已更新 | 正常路径 | P0 | 单字段更新 |
+| TC-1233 | POST /api/auth/updateInfo | 正常更新 avatar | `{"avatar":"https://..."}` | 200, DB 中 avatar 已更新 | 正常路径 | P0 | avatar 更新 |
+| TC-1234 | POST /api/auth/updateInfo | 正常更新 email + phone | `{"email":"[email protected]","phone":"138..."}` | 200, DB 中 email/phone 更新 | 正常路径 | P0 | 多字段更新 |
+| TC-1235 | POST /api/auth/updateInfo | nickname 超过 64 字符 | `{"nickname":"x*65"}` | 400 "昵称长度不能超过64个字符" | 边界 | P0 | 长度校验 |
+| TC-1236 | POST /api/auth/updateInfo | avatar 超过 255 字符 | `{"avatar":"a*256"}` | 400 "头像地址长度不能超过255个字符" | 边界 | P0 | 长度校验 |
+| TC-1237 | POST /api/auth/updateInfo | email 超过 64 字符 | `{"email":"e*65"}` | 400 "邮箱长度不能超过64个字符" | 边界 | P0 | 长度校验 |
+| TC-1238 | POST /api/auth/updateInfo | phone 超过 32 字符 | `{"phone":"1*33"}` | 400 "手机号长度不能超过32个字符" | 边界 | P0 | 长度校验 |
+| TC-1239 | POST /api/auth/updateInfo | 并发更新冲突 | 两 session 用相同旧 updateTime 提交 | 第二个返回错误 ErrUpdateConflict | 并发 | P0 | 乐观锁 WHERE updateTime=? |
+| TC-1240 | POST /api/auth/updateInfo | 更新后 UserDetails 缓存失效 | 更新 nickname 后 Load | 再次 Load 应读到新值 | 缓存一致性 | P0 | InvalidateProfileCache |
+
+### 2.23 MinIO 文件上传 `POST /api/minio/upload`
+
+| TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1242 | POST /api/minio/upload | fileType 为空 | multipart 无 fileType 字段 | 400 "fileType is required" | 输入校验 | P0 | handler fileType 校验 |
+| TC-1243 | POST /api/minio/upload | fileType 不在配置中 | `fileType=unknown_type` | 400 "fileType not configured" | 输入校验 | P0 | 配置映射查找失败 |
+| TC-1244 | POST /api/minio/upload | Content-Type 不在白名单中 | `fileType=avatar`, Content-Type=application/zip | 400 "invalid contentType" | 安全 | P0 | AllowedContentTypes 白名单 |
+| TC-1248 | POST /api/minio/upload | parseDir 模板替换 {yyyy}/{mm}/{dd} | `"avatar/{yyyy}/{mm}/{dd}"` | 路径包含当前日期 | 单元测试 | P0 | parseDir 逻辑 |
+| TC-1249 | POST /api/minio/upload | handler 缺少 file 字段 | multipart 仅有 fileType 无 file | 400 | 输入校验 | P0 | FormFile 解析失败 |
+
 ---
 
 ## 三、gRPC 接口测试用例

+ 79 - 2
test-report.md

@@ -11,8 +11,8 @@
 
 | 指标 | 数值 |
 | :--- | :--- |
-| 测试包总数 | **26** |
-| TC 用例总数 (test-design.md) | **979** |
+| 测试包总数 | **28** |
+| TC 用例总数 (test-design.md) | **1021** |
 | 顶层测试函数数 (Functions) | **1089** |
 | 测试执行事件总数 (含 `t.Run` 子用例) | **1221** |
 | ✅ 通过 | **1220** |
@@ -49,6 +49,8 @@
 | 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 |
 
@@ -100,6 +102,10 @@
 | TC-0840 | 超管走产品端登录 + 错误密码 | ✅ pass |
 | TC-0841 | 超管走产品端登录 + 正确密码 | ✅ pass |
 | TC-0842 | 用户名不存在 | ✅ pass |
+| TC-1213 | cap.js 未启用 + 验证码为空 | ✅ pass |
+| TC-1214 | cap.js 未启用 + 验证码错误/过期 | ✅ pass |
+| TC-1215 | cap.js 未启用 + 验证码正确 → 正常登录 | ✅ pass |
+| TC-1250 | cap.js 已启用时传统接口被拒绝 | ✅ pass |
 
 ### 2.1b 管理后台登录 `POST /api/auth/adminLogin`
 
@@ -125,6 +131,10 @@
 | TC-1008 | 非超管+错密码 vs 用户不存在 | ✅ pass |
 | TC-1009 | 非超管+正确密码 | ✅ pass |
 | TC-1010 | 两条 dummy bcrypt 分支时序 | ✅ pass |
+| TC-1216 | cap.js 未启用 + 验证码为空 | ✅ pass |
+| TC-1217 | cap.js 未启用 + 验证码错误/过期 | ✅ pass |
+| TC-1218 | cap.js 未启用 + 验证码正确 → 超管正常登录 | ✅ pass |
+| TC-1251 | cap.js 已启用时传统接口被拒绝 | ✅ pass |
 
 ### 2.2 刷新Token `POST /api/auth/refreshToken`
 
@@ -649,6 +659,73 @@
 | TC-1162 | 移除成员后被移除用户 sys_user.tokenVersion 必须 +1 | ✅ pass |
 | TC-1163 | 移除失败(last-admin 场景)时 tokenVersion 绝不得 +1 | ✅ pass |
 
+### 2.18 获取验证码 `POST /api/captcha/get`
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
+| TC-1208 | 正常获取(默认宽高) | ✅ pass |
+| TC-1209 | 自定义宽高 | ✅ pass |
+| TC-1210 | 宽高为 0 或负数,退化为默认值 | ✅ pass |
+| TC-1252 | VerifyCaptcha 正确码消费后不可重用 | ✅ pass |
+| TC-1253 | VerifyCaptcha 错误码不消费 | ✅ pass |
+| TC-1254 | VerifyCaptcha 不存在的 id | ✅ pass |
+
+### 2.19 Cap.js 端点 `POST /api/capjs/endpoint`
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
+| TC-1211 | cap.js 已启用 | ✅ pass |
+| TC-1212 | cap.js 未启用 | ✅ pass |
+| TC-1255 | cap.js 启用但 EndpointURL 为空 | ✅ pass |
+| TC-1256 | cap.js 启用但 Key 为空 | ✅ pass |
+
+### 2.20 产品端 Cap.js 登录 `POST /api/auth/login/cap`
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
+| TC-1219 | cap.js 未启用时调用 | ✅ pass |
+| TC-1220 | capToken 为空 | ✅ pass |
+| TC-1221 | capToken 无效(远端校验失败) | ✅ pass |
+| TC-1222 | capToken 有效 + 正常登录 | ✅ pass |
+| TC-1223 | capToken 有效 + 密码错误 | ✅ pass |
+| TC-1224 | capToken 有效 + 超管被拒绝 | ✅ pass |
+
+### 2.21 管理后台 Cap.js 登录 `POST /api/auth/adminLogin/cap`
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
+| TC-1225 | cap.js 未启用时调用 | ✅ pass |
+| TC-1226 | capToken 为空 | ✅ pass |
+| TC-1227 | capToken 有效 + managementKey 无效 | ✅ pass |
+| TC-1228 | capToken 有效 + 超管正常登录 | ✅ pass |
+| TC-1229 | capToken 有效 + 非超管被拒绝 | ✅ pass |
+
+### 2.22 更新用户信息 `POST /api/auth/updateInfo`
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
+| TC-1230 | 未登录 | ✅ pass |
+| TC-1231 | 所有字段为 nil | ✅ pass |
+| TC-1232 | 正常更新 nickname | ✅ pass |
+| TC-1233 | 正常更新 avatar | ✅ pass |
+| TC-1234 | 正常更新 email + phone | ✅ pass |
+| TC-1235 | nickname 超过 64 字符 | ✅ pass |
+| TC-1236 | avatar 超过 255 字符 | ✅ pass |
+| TC-1237 | email 超过 64 字符 | ✅ pass |
+| TC-1238 | phone 超过 32 字符 | ✅ pass |
+| TC-1239 | 并发更新冲突 | ✅ pass |
+| TC-1240 | 更新后 UserDetails 缓存失效 | ✅ pass |
+
+### 2.23 MinIO 文件上传 `POST /api/minio/upload`
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
+| TC-1242 | fileType 为空 | ✅ pass |
+| TC-1243 | fileType 不在配置中 | ✅ pass |
+| TC-1244 | Content-Type 不在白名单中 | ✅ pass |
+| TC-1248 | parseDir 模板替换 {yyyy}/{mm}/{dd} | ✅ pass |
+| TC-1249 | handler 缺少 file 字段 | ✅ pass |
+
 ---
 
 ## 三、gRPC 接口测试用例