feat: 公众端注册添加手机号必填——支持 AI 创作功能绑定手机号

后端 PublicRegisterDto phone 字段添加 @NotBlank + @Pattern 校验;
PublicAuthService 添加手机号唯一性检查(公众租户范围内);
前端 Login.vue 注册表单添加手机号输入框、验证规则、提交参数;
新增 10 条 E2E 测试用例覆盖前端校验、API 参数传递、完整注册流程。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
En 2026-04-09 15:36:52 +08:00
parent 87ac3b5ed9
commit 67de13c29a
4 changed files with 294 additions and 0 deletions

View File

@ -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;

View File

@ -65,6 +65,15 @@ public class PublicAuthService {
throw new BusinessException(400, "用户名已存在");
}
// 检查手机号是否已被注册公众租户范围内
Long phoneCount = sysUserMapper.selectCount(
new LambdaQueryWrapper<SysUser>()
.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());

View File

@ -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/)
})
})

View File

@ -22,6 +22,15 @@
/>
</a-form-item>
<a-form-item v-if="isRegister" name="phone" label="手机号">
<a-input
v-model:value="form.phone"
placeholder="请输入手机号"
size="large"
:maxlength="11"
/>
</a-form-item>
<a-form-item name="username" label="用户名">
<a-input
v-model:value="form.username"
@ -100,6 +109,7 @@ const form = reactive({
password: "demo123456",
confirmPassword: "",
nickname: "",
phone: import.meta.env.DEV ? "13800138000" : "",
})
const rules: Record<string, Rule[]> = {
@ -127,6 +137,10 @@ const rules: Record<string, Rule[]> = {
{ 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 {