feat: C 端 1-1 邮箱+密码注册(免码不阻塞,带风控)

为什么:客户端要做的第一件事是账号,但现状下邮箱注册走不通 —— 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。
This commit is contained in:
Vicky committed 2026-10-15 17:20:31 +08:00
1 parent 414d9fd482
commit 6833e01e1f
4 files changed
+129 -6

No files matched your search

+24
View File
@@ -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
@@ -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.<AppUser>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.<AppUser>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());
@@ -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;
}
@@ -13,7 +13,10 @@ public class RegisterReqVO {
@NotBlank(message = "请输入手机号或邮箱")
private String target;
@NotBlank(message = "请输入验证码")
/**
* 验证码:`targetType=PHONE` 时必填;`targetType=EMAIL` 时可以不传(邮箱+密码直接注册),
* 传了就顺手校验一次(邮箱验证能力保留)。必填性在服务端按 targetType 判断。
*/
private String code;
/** 可选:同时设置密码,之后可用密码登录 */