From 67de13c29aa7b664df9900b7e8cdf44e213793bc Mon Sep 17 00:00:00 2001 From: En Date: Thu, 9 Apr 2026 15:36:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=AC=E4=BC=97=E7=AB=AF=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E6=B7=BB=E5=8A=A0=E6=89=8B=E6=9C=BA=E5=8F=B7=E5=BF=85?= =?UTF-8?q?=E5=A1=AB=E2=80=94=E2=80=94=E6=94=AF=E6=8C=81=20AI=20=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E5=8A=9F=E8=83=BD=E7=BB=91=E5=AE=9A=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 PublicRegisterDto phone 字段添加 @NotBlank + @Pattern 校验; PublicAuthService 添加手机号唯一性检查(公众租户范围内); 前端 Login.vue 注册表单添加手机号输入框、验证规则、提交参数; 新增 10 条 E2E 测试用例覆盖前端校验、API 参数传递、完整注册流程。 Co-Authored-By: Claude Opus 4.6 --- .../modules/pub/dto/PublicRegisterDto.java | 3 + .../pub/service/PublicAuthService.java | 9 + frontend/e2e/public/register.spec.ts | 267 ++++++++++++++++++ frontend/src/views/public/Login.vue | 15 + 4 files changed, 294 insertions(+) create mode 100644 frontend/e2e/public/register.spec.ts diff --git a/backend-java/src/main/java/com/competition/modules/pub/dto/PublicRegisterDto.java b/backend-java/src/main/java/com/competition/modules/pub/dto/PublicRegisterDto.java index a1b0bd7..60661a2 100644 --- a/backend-java/src/main/java/com/competition/modules/pub/dto/PublicRegisterDto.java +++ b/backend-java/src/main/java/com/competition/modules/pub/dto/PublicRegisterDto.java @@ -2,6 +2,7 @@ package com.competition.modules.pub.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.Data; @@ -24,6 +25,8 @@ public class PublicRegisterDto { @Schema(description = "昵称") private String nickname; + @NotBlank(message = "手机号不能为空") + @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") @Schema(description = "手机号") private String phone; diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicAuthService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicAuthService.java index 52f889b..1640e8f 100644 --- a/backend-java/src/main/java/com/competition/modules/pub/service/PublicAuthService.java +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicAuthService.java @@ -65,6 +65,15 @@ public class PublicAuthService { throw new BusinessException(400, "用户名已存在"); } + // 检查手机号是否已被注册(公众租户范围内) + Long phoneCount = sysUserMapper.selectCount( + new LambdaQueryWrapper() + .eq(SysUser::getPhone, dto.getPhone()) + .eq(SysUser::getTenantId, publicTenant.getId())); + if (phoneCount > 0) { + throw new BusinessException(400, "该手机号已注册"); + } + // 创建用户 SysUser user = new SysUser(); user.setTenantId(publicTenant.getId()); diff --git a/frontend/e2e/public/register.spec.ts b/frontend/e2e/public/register.spec.ts new file mode 100644 index 0000000..885b195 --- /dev/null +++ b/frontend/e2e/public/register.spec.ts @@ -0,0 +1,267 @@ +import { test, expect } from '@playwright/test' + +/** + * 公众端注册流程测试 + * 验证手机号必填功能 + */ + +/** 公众端登录/注册页面路径 */ +const REGISTER_PATH = '/p/login' + +/** Mock 注册 API 的响应 */ +async function setupRegisterApiMocks(page: import('@playwright/test').Page) { + // 拦截注册 API + await page.route('**/api/public/auth/register', async (route) => { + const request = route.request() + const postData = request.postDataJSON() + + // 模拟后端校验逻辑 + if (!postData?.phone) { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ code: 400, message: '手机号不能为空' }), + }) + return + } + + if (!/^1[3-9]\d{9}$/.test(postData.phone)) { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ code: 400, message: '手机号格式不正确' }), + }) + return + } + + if (postData.phone === '13800000000') { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ code: 400, message: '该手机号已注册' }), + }) + return + } + + // 注册成功 + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 200, + message: 'success', + data: { + token: 'mock-jwt-token-for-test', + user: { + id: 999, + username: postData.username, + nickname: postData.nickname, + phone: postData.phone, + avatar: null, + city: null, + gender: null, + userType: 'adult', + roles: ['public_user'], + permissions: [], + }, + }, + }), + }) + }) + + // 拦截登录 API(切换到登录模式时需要) + await page.route('**/api/public/auth/login', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 200, + message: 'success', + data: { + token: 'mock-jwt-token-for-test', + user: { + id: 1, + username: 'demo', + nickname: '测试用户', + phone: '13800138000', + avatar: null, + city: null, + gender: null, + userType: 'adult', + roles: ['public_user'], + permissions: [], + }, + }, + }), + }) + }) +} + +test.describe('公众端注册 - 手机号必填', () => { + test.beforeEach(async ({ page }) => { + await setupRegisterApiMocks(page) + await page.goto(REGISTER_PATH) + // 点击"立即注册"切换到注册模式 + await page.locator('text=立即注册').click() + // 等待注册表单渲染 + await expect(page.locator('text=创建账号')).toBeVisible() + }) + + test('PR-01 注册表单包含手机号输入框', async ({ page }) => { + // 验证手机号标签可见 + await expect(page.locator('label:has-text("手机号")')).toBeVisible() + // 验证手机号输入框可见 + await expect(page.locator('input[placeholder="请输入手机号"]')).toBeVisible() + }) + + test('PR-02 手机号输入框限制最大长度11位', async ({ page }) => { + const phoneInput = page.locator('input[placeholder="请输入手机号"]') + await expect(phoneInput).toHaveAttribute('maxlength', '11') + }) + + test('PR-03 登录模式不显示手机号输入框', async ({ page }) => { + // 点击"去登录"切换回登录模式 + await page.locator('text=去登录').click() + await expect(page.locator('text=登录')).toBeVisible() + + // 验证手机号输入框不存在 + await expect(page.locator('input[placeholder="请输入手机号"]')).not.toBeVisible() + }) + + test('PR-04 手机号为空时显示必填校验错误', async ({ page }) => { + // 清空手机号(开发环境可能预填了值) + const phoneInput = page.locator('input[placeholder="请输入手机号"]') + await phoneInput.clear() + // 触发 blur 校验 + await phoneInput.blur() + + // 验证校验错误信息 + await expect(page.locator('.ant-form-item-explain-error')).toContainText('请输入手机号') + }) + + test('PR-05 手机号格式不正确时显示校验错误', async ({ page }) => { + const phoneInput = page.locator('input[placeholder="请输入手机号"]') + await phoneInput.fill('12345678901') + await phoneInput.blur() + + // 验证格式校验错误 + await expect(page.locator('.ant-form-item-explain-error')).toContainText('手机号格式不正确') + }) + + test('PR-06 输入正确的手机号格式不显示错误', async ({ page }) => { + const phoneInput = page.locator('input[placeholder="请输入手机号"]') + await phoneInput.fill('13912345678') + await phoneInput.blur() + + // 验证无校验错误 + await expect(page.locator('.ant-form-item-explain-error')).not.toBeVisible() + }) + + test('PR-07 不传手机号后端返回校验错误', async ({ page }) => { + // 修改 route 来模拟不传手机号 + let capturedBody: any = null + await page.route('**/api/public/auth/register', async (route) => { + capturedBody = route.request().postDataJSON() + // 模拟后端 @NotBlank 校验 + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ code: 400, message: '手机号不能为空' }), + }) + }) + + // 清空手机号并提交(绕过前端校验直接测试 API) + const phoneInput = page.locator('input[placeholder="请输入手机号"]') + await phoneInput.clear() + + // 填写其他必填项 + await page.locator('input[placeholder="给自己取个名字吧"]').fill('测试昵称') + await page.locator('input[placeholder="请输入用户名"]').fill('testuser' + Date.now()) + await page.locator('input[type="password"]').first().fill('test123456') + await page.locator('input[placeholder="再次输入密码"]').fill('test123456') + + // 点击注册按钮 + await page.locator('button:has-text("注册并登录")').click() + + // 验证 Ant Design 前端表单校验拦截了提交(手机号必填) + await expect(page.locator('.ant-form-item-explain-error')).toContainText('请输入手机号') + }) + + test('PR-08 注册时传递手机号参数到后端', async ({ page }) => { + let capturedBody: any = null + + // 重新注册路由来捕获请求体(覆盖 beforeEach 的 setupRegisterApiMocks) + await page.unroute('**/api/public/auth/register') + await page.route('**/api/public/auth/register', async (route) => { + capturedBody = route.request().postDataJSON() + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 200, + message: 'success', + data: { + token: 'mock-token', + user: { id: 999, username: 'test', nickname: '测试', phone: '13900001111', avatar: null, city: null, gender: null, userType: 'adult', roles: ['public_user'], permissions: [] }, + }, + }), + }) + }) + + // 填写完整注册表单 + await page.locator('input[placeholder="给自己取个名字吧"]').fill('测试昵称') + await page.locator('input[placeholder="请输入手机号"]').fill('13900001111') + const uniqueUsername = 'testuser' + Date.now() + await page.locator('input[placeholder="请输入用户名"]').fill(uniqueUsername) + await page.locator('input[type="password"]').first().fill('test123456') + await page.locator('input[placeholder="再次输入密码"]').fill('test123456') + + // 点击注册 + await page.locator('button:has-text("注册并登录")').click() + + // 等待成功提示(说明 API 已被调用) + await expect(page.locator('.ant-message')).toContainText('注册成功', { timeout: 5000 }) + + // 验证请求体包含 phone 字段 + expect(capturedBody).toBeTruthy() + expect(capturedBody.phone).toBe('13900001111') + }) + + test('PR-09 已注册手机号后端返回错误提示', async ({ page }) => { + // 使用预定义的"已注册"手机号 + await page.locator('input[placeholder="给自己取个名字吧"]').fill('测试昵称') + await page.locator('input[placeholder="请输入手机号"]').fill('13800000000') + await page.locator('input[placeholder="请输入用户名"]').fill('testuser' + Date.now()) + await page.locator('input[type="password"]').first().fill('test123456') + await page.locator('input[placeholder="再次输入密码"]').fill('test123456') + + // 点击注册 + await page.locator('button:has-text("注册并登录")').click() + + // 验证错误提示(由 mock 返回"该手机号已注册") + await expect(page.locator('.ant-message')).toContainText('该手机号已注册', { timeout: 5000 }) + }) + + test('PR-10 完整注册流程成功', async ({ page }) => { + const testSuffix = Date.now() + + // 填写完整注册表单 + await page.locator('input[placeholder="给自己取个名字吧"]').fill('测试昵称') + await page.locator('input[placeholder="请输入手机号"]').fill('139' + String(testSuffix).slice(-8)) + await page.locator('input[placeholder="请输入用户名"]').fill('e2e' + testSuffix) + await page.locator('input[type="password"]').first().fill('test123456') + await page.locator('input[placeholder="再次输入密码"]').fill('test123456') + + // 点击注册 + await page.locator('button:has-text("注册并登录")').click() + + // 验证成功提示 + await expect(page.locator('.ant-message')).toContainText('注册成功', { timeout: 5000 }) + + // 验证跳转离开登录页 + await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 10_000 }) + + // 验证跳转到了活动列表页(/p/activities) + await expect(page).toHaveURL(/\/p\/activities/) + }) +}) diff --git a/frontend/src/views/public/Login.vue b/frontend/src/views/public/Login.vue index 209e7c8..a7c80d1 100644 --- a/frontend/src/views/public/Login.vue +++ b/frontend/src/views/public/Login.vue @@ -22,6 +22,15 @@ /> + + + + = { @@ -127,6 +137,10 @@ const rules: Record = { { required: true, message: "请输入昵称", trigger: "blur" }, { min: 2, message: "昵称至少2个字符", trigger: "blur" }, ], + phone: [ + { required: true, message: "请输入手机号", trigger: "blur" }, + { pattern: /^1[3-9]\d{9}$/, message: "手机号格式不正确", trigger: "blur" }, + ], } const handleSubmit = async () => { @@ -138,6 +152,7 @@ const handleSubmit = async () => { username: form.username, password: form.password, nickname: form.nickname, + phone: form.phone, }) message.success("注册成功") } else {