From 6833e01e1f03ba1a0419355e6f5e87a93528ad80 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Thu, 15 Oct 2026 17:20:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20C=20=E7=AB=AF=201-1=20=E9=82=AE?= =?UTF-8?q?=E7=AE=B1+=E5=AF=86=E7=A0=81=E6=B3=A8=E5=86=8C=EF=BC=88?= =?UTF-8?q?=E5=85=8D=E7=A0=81=E4=B8=8D=E9=98=BB=E5=A1=9E=EF=BC=8C=E5=B8=A6?= =?UTF-8?q?=E9=A3=8E=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:客户端要做的第一件事是账号,但现状下邮箱注册走不通 —— RegisterReqVO.code 是 @NotBlank, 而 OTP 通道现在是 LogOtpSender(只写日志、发不出去),等于注册入口堵死。本轮把"邮箱+密码"打通, 同时按口径补上免码必须带的风控。 - RegisterReqVO.code 放开非必填(PHONE 仍必填,必填性按 targetType 在服务端判断) - 邮箱注册分支:格式校验(AppIdentifier.requireEmailFormat)+ 密码强度(8-64 位且含字母数字); 传了 code 就顺手校验一次,邮箱验证能力保留但不阻塞 - 注册频控:同 IP 24h ≤5、同设备 24h ≤3(数 app_user.register_ip / register_device_id,不新建计数表),超出 429 - 顺带修一个真问题:login() 原来**无条件**把 credential 标成 verified=1, 密码登录并不证明邮箱归用户所有,会把"未验证"状态悄悄抹掉;改成只有验证码登录才置 1 - docs/api-phase1.md 补注册契约表格与残留风险(IP/设备号可伪造、OTP 只写日志、邮箱可达性未验证) 实测:免码注册成功(邮箱大小写与空格被规范化);弱密码/坏邮箱 400;重复 200001; 邮箱+密码登录成功;PHONE 不带码仍报"请先获取验证码";同设备第 4 次、同 IP 第 6 次分别 429; 免码注册后 verified=0 且经一次密码登录仍是 0。 --- docs/api-phase1.md | 24 +++++ .../user/service/AppAuthService.java | 88 +++++++++++++++++-- .../user/service/AppIdentifier.java | 18 ++++ .../fruitshorts/user/vo/RegisterReqVO.java | 5 +- 4 files changed, 129 insertions(+), 6 deletions(-) diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 782d05a..342d54e 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -74,6 +74,30 @@ POST /app/auth/register } ``` +**邮箱 + 密码注册(C 端第一批口径)** + +```json +POST /app/auth/register +{ + "targetType": "EMAIL", "target": "User@Example.com ", "password": "nonton2026", + "deviceId": "d-1", "countryCode": "ID", "agreementVersion": "2026-10-01" +} +``` + +| 规则 | 口径 | +| --- | --- | +| `targetType=EMAIL` | **`code` 可以不传**(邮箱+密码直接注册);传了就走一次验证码校验(邮箱验证能力保留) | +| `targetType=PHONE` | 维持原状:`code` **必填** | +| 邮箱规范化 | 去空格 + 转小写(`batch1@Example.com ` → `batch1@example.com`);格式不合法直接 `400 邮箱格式不正确` | +| 密码强度 | 8–64 位,且**同时包含字母与数字**(不强制特殊字符:低端机输入成本高,拦机器注册靠频控) | +| 注册频控 | 同 IP 24h ≤ **5** 个、同设备 24h ≤ **3** 个,超出返回 `429`(`TOO_MANY_REQUESTS`) | +| 邮箱验证状态 | 免码注册时 `app_user_credential.verified = 0`(保留"待验证"),**不阻塞登录**;只有**验证码登录**会把 `verified` 置 1,密码登录不改变该状态 | + +频控口径落库在 `app_user.register_ip` / `register_device_id` 上(按 24h 计数,不新建计数表)。 +**残留风险**:IP 取自 `X-Forwarded-For`、设备号由客户端上报,都能伪造,只能挡脚本批量; +真要防住需要接图形/行为验证码或风控服务,本阶段未做。验证码通道目前是 `LogOtpSender`(只写日志不发信), +所以邮箱**可达性**未验证 —— 这也是"不阻塞注册"的前提。 + 响应(登录、刷新同结构): ```json diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java index 28cf22d..668ff8a 100644 --- a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java @@ -15,6 +15,7 @@ import com.fruitshorts.user.dal.mapper.AppUserDeviceMapper; import com.fruitshorts.user.dal.mapper.AppUserMapper; import com.fruitshorts.user.dal.mapper.AppUserSessionMapper; import com.fruitshorts.user.enums.AppUserStatus; +import com.fruitshorts.user.enums.CredentialType; import com.fruitshorts.user.enums.OtpScene; import com.fruitshorts.user.vo.AppUserRespVO; import com.fruitshorts.user.vo.LoginReqVO; @@ -30,6 +31,7 @@ import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.util.List; +import java.util.regex.Pattern; /** * C 端账号:注册、登录、令牌轮换、登出。 @@ -59,7 +61,21 @@ public class AppAuthService { public TokenRespVO register(RegisterReqVO req, String clientIp) { String targetType = AppIdentifier.normalizeType(req.getTargetType()); String target = AppIdentifier.normalize(targetType, req.getTarget()); - appOtpService.consume(OtpScene.REGISTER.name(), targetType, target, req.getCode()); + + boolean emailRegister = CredentialType.EMAIL.name().equals(targetType); + if (emailRegister) { + // 邮箱+密码注册:不阻塞于验证码(OTP 目前只写日志、发不出去),但必须有风控 + 密码强度 + AppIdentifier.requireEmailFormat(target); + requirePasswordStrength(req.getPassword()); + if (StringUtils.hasText(req.getCode())) { + // 用户主动走了邮箱验证,就把验证能力用上(保留但不阻塞) + appOtpService.consume(OtpScene.REGISTER.name(), targetType, target, req.getCode()); + } + } else { + // 手机号注册维持原状:必须验证码 + appOtpService.consume(OtpScene.REGISTER.name(), targetType, target, req.getCode()); + } + guardRegisterRisk(clientIp, req.getDeviceId()); if (findCredential(targetType, target) != null) { throw new ServiceException(ErrorCode.DATA_EXISTS, "该账号已注册,请直接登录"); @@ -75,8 +91,10 @@ public class AppAuthService { credential.setPasswordHash(StringUtils.hasText(req.getPassword()) ? passwordEncoder.encode(req.getPassword()) : ""); - credential.setVerified(1); - credential.setVerifiedAt(LocalDateTime.now()); + // 邮箱免码注册时先记"未验证"(verified=0),等用户日后点验证链接再置 1;登录不要求 verified + boolean verified = !emailRegister || StringUtils.hasText(req.getCode()); + credential.setVerified(verified ? 1 : 0); + credential.setVerifiedAt(verified ? LocalDateTime.now() : null); credential.setLastUsedAt(LocalDateTime.now()); credential.setFederationId(""); credential.setAppId(""); @@ -130,8 +148,13 @@ public class AppAuthService { } credential.setLastUsedAt(LocalDateTime.now()); - credential.setVerified(1); - credential.setVerifiedAt(LocalDateTime.now()); + if (byCode) { + // 只有「验证码登录」能证明邮箱/手机号确实归用户所有 → 才算已验证。 + // 密码登录不改验证状态:知道密码不等于能收到这个邮箱的信,否则 + // "邮箱免码注册先记未验证" 这条设计会被一次登录悄悄抹掉。 + credential.setVerified(1); + credential.setVerifiedAt(LocalDateTime.now()); + } credentialMapper.updateById(credential); user.setLastLoginAt(LocalDateTime.now()); @@ -276,6 +299,61 @@ public class AppAuthService { return resp; } + /** 每个 IP 24 小时内最多注册几个账号 */ + private static final int MAX_REGISTER_PER_IP_PER_DAY = 5; + /** 每台设备 24 小时内最多注册几个账号 */ + private static final int MAX_REGISTER_PER_DEVICE_PER_DAY = 3; + private static final int PASSWORD_MIN_LENGTH = 8; + private static final int PASSWORD_MAX_LENGTH = 64; + private static final Pattern HAS_LETTER = Pattern.compile("[A-Za-z]"); + private static final Pattern HAS_DIGIT = Pattern.compile("[0-9]"); + + /** + * 密码强度下限:8~64 位,且同时包含字母与数字。 + * + * 有意不要求特殊字符:印尼低端机用户输入成本高,强制特殊字符会把注册转化率打下去, + * 真正拦机器注册靠的是下面的频控。 + */ + private void requirePasswordStrength(String password) { + String value = password == null ? "" : password; + if (value.length() < PASSWORD_MIN_LENGTH || value.length() > PASSWORD_MAX_LENGTH) { + throw new ServiceException(ErrorCode.BAD_REQUEST, + "密码长度需在 " + PASSWORD_MIN_LENGTH + "-" + PASSWORD_MAX_LENGTH + " 位之间"); + } + if (!HAS_LETTER.matcher(value).find() || !HAS_DIGIT.matcher(value).find()) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "密码需同时包含字母和数字"); + } + } + + /** + * 注册频控(邮箱免码注册必须带的兜底):同 IP 24h ≤5 个、同设备 24h ≤3 个。 + * + * 直接数 `app_user` 上的 register_ip / register_device_id,不新建计数表: + * 注册量本来就低,这两列也已经有数据,省一张表与一套清理任务。 + * 注意这是**弱风控**:IP 与设备号都是客户端/网络给的,改一下就能绕过,只挡脚本批量。 + */ + private void guardRegisterRisk(String clientIp, String deviceId) { + LocalDateTime since = LocalDateTime.now().minusDays(1); + String ip = AppIdentifier.nullSafe(clientIp); + if (StringUtils.hasText(ip)) { + Long byIp = appUserMapper.selectCount(Wrappers.lambdaQuery() + .eq(AppUser::getRegisterIp, ip) + .ge(AppUser::getCreatedAt, since)); + if (byIp != null && byIp >= MAX_REGISTER_PER_IP_PER_DAY) { + throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "当前网络注册过于频繁,请稍后再试"); + } + } + String device = AppIdentifier.nullSafe(deviceId); + if (StringUtils.hasText(device)) { + Long byDevice = appUserMapper.selectCount(Wrappers.lambdaQuery() + .eq(AppUser::getRegisterDeviceId, device) + .ge(AppUser::getCreatedAt, since)); + if (byDevice != null && byDevice >= MAX_REGISTER_PER_DEVICE_PER_DAY) { + throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "该设备注册过于频繁,请稍后再试"); + } + } + } + private AppUser createUser(RegisterReqVO req, String clientIp) { AppUser user = new AppUser(); user.setUid(hasher.newUid()); diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppIdentifier.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppIdentifier.java index 6a82499..6bc34ba 100644 --- a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppIdentifier.java +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppIdentifier.java @@ -6,6 +6,7 @@ import com.fruitshorts.user.enums.CredentialType; import org.springframework.util.StringUtils; import java.util.Locale; +import java.util.regex.Pattern; /** 手机号/邮箱的规范化与脱敏。 */ public final class AppIdentifier { @@ -60,6 +61,23 @@ public final class AppIdentifier { return identifier.substring(0, 4) + "****" + identifier.substring(identifier.length() - 3); } + /** + * 邮箱格式校验。 + * + * 只做"看起来像邮箱"的基础校验(本地部分 + 域名 + 至少一个点,域名后缀 2 位以上): + * 真实可达性要靠发信验证,而本轮邮箱验证是**保留但不阻塞**的,所以格式不合法直接拒, + * 格式合法但收不到信也不挡注册。 + */ + private static final Pattern EMAIL_PATTERN = + Pattern.compile("^[A-Za-z0-9._%+\\-]{1,64}@[A-Za-z0-9]([A-Za-z0-9\\-]{0,61}[A-Za-z0-9])?" + + "(\\.[A-Za-z0-9]([A-Za-z0-9\\-]{0,61}[A-Za-z0-9])?)*\\.[A-Za-z]{2,}$"); + + public static void requireEmailFormat(String email) { + if (!StringUtils.hasText(email) || email.length() > 191 || !EMAIL_PATTERN.matcher(email).matches()) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "邮箱格式不正确"); + } + } + public static String nullSafe(String value) { return value == null ? "" : value; } diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RegisterReqVO.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RegisterReqVO.java index a804d6c..639a178 100644 --- a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RegisterReqVO.java +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RegisterReqVO.java @@ -13,7 +13,10 @@ public class RegisterReqVO { @NotBlank(message = "请输入手机号或邮箱") private String target; - @NotBlank(message = "请输入验证码") + /** + * 验证码:`targetType=PHONE` 时必填;`targetType=EMAIL` 时可以不传(邮箱+密码直接注册), + * 传了就顺手校验一次(邮箱验证能力保留)。必填性在服务端按 targetType 判断。 + */ private String code; /** 可选:同时设置密码,之后可用密码登录 */