merge: 合并 K16 注册限流配置化

This commit is contained in:
Zhou Weidong committed 2026-10-17 23:22:51 +08:00
commit 78fb99530d
7 files changed
+122 -25

No files matched your search

+13 -2
View File
@@ -113,10 +113,21 @@ POST /app/auth/register
| `targetType=PHONE` | 维持原状:`code` **必填** |
| 邮箱规范化 | 去空格 + 转小写(`batch1@Example.com ` → `batch1@example.com`);格式不合法直接 `400 邮箱格式不正确` |
| 密码强度 | 8–64 位,且**同时包含字母与数字**(不强制特殊字符:低端机输入成本高,拦机器注册靠频控) |
| 注册频控 | 同 IP 24h ≤ **5** 个、同设备 24h ≤ **3** 个,超出返回 `429`(`TOO_MANY_REQUESTS`) |
| 注册频控 | 同 IP / 同设备 24h 默认上限分别为 5 / 3 个,超出返回 `429`(`TOO_MANY_REQUESTS`) |
| 邮箱验证状态 | 免码注册时 `app_user_credential.verified = 0`(保留"待验证"),**不阻塞登录**;只有**验证码登录**会把 `verified` 置 1,密码登录不改变该状态 |
频控口径落库在 `app_user.register_ip` / `register_device_id` 上(按 24h 计数,不新建计数表)。
频控口径落库在 `app_user.register_ip` / `app_user.register_device_id` 上(按 24h 计数,不新建计数表)。
注册频控阈值通过以下配置键调整:
```text
fruitshorts.auth.register-max-per-ip-per-day
fruitshorts.auth.register-max-per-device-per-day
```
未配置时默认 5 / 3;值小于等于 0 表示对应维度不限制。开发
`application.yml` 显式设为 0 / 0,`application-prod.yml` 固定为 5 / 3。
**残留风险**:IP 取自 `X-Forwarded-For`、设备号由客户端上报,都能伪造,只能挡脚本批量;
真要防住需要接图形/行为验证码或风控服务,本阶段未做。验证码通道目前是 `LogOtpSender`(只写日志不发信),
所以邮箱**可达性**未验证 —— 这也是"不阻塞注册"的前提。
+35
View File
@@ -0,0 +1,35 @@
# 2026-10-17 K16 注册限流可配置
## 范围
将 C 端注册的 IP/设备 24 小时限流阈值从硬编码改为 Spring 配置:
```text
fruitshorts.auth.register-max-per-ip-per-day
fruitshorts.auth.register-max-per-device-per-day
```
默认值保持 5/3;值小于等于 0 时跳过对应 guard。注册接口、错误码和错误文案不变。
## 实现
1. `AppAuthService` 用 `@Value` 字段注入阈值,保留现有 Lombok 构造器。
2. 开发 `application.yml` 显式配置 0/0,并注释生产建议 5/3。
3. `application-prod.yml` 显式配置 5/3。
4. `docs/api-phase1.md` §1.1 补充配置键和 0=不限制口径。
5. 测试用 `ReflectionTestUtils` 注入阈值,覆盖 IP/设备超限、IP 不限制、设备不限制。
## 验证与交付
- `mvn test`
- `mvn -pl fruitshorts-app-api -am package -DskipTests`
- 停止当前 8081 PID 30520,启动新 jar,`/healthz` 通过
- 使用新设备号连续注册至少 4 次,确认不再 429(接口自检)
- `feature/backend-register-rate-config` 以 `--no-ff` 合并回 `develop` 并 push
- PROGRESS 记录命令、原始输出和解读
## 风险
- 配置 0 会关闭注册滥用保护,仅用于当前开发环境;prod profile 固定恢复 5/3。
- 旧 8081 服务重启期间会有短暂不可用。
- 限流仍基于客户端可伪造 IP/设备号,本次不放宽其他风控。
+14 -14
View File
@@ -32,22 +32,22 @@
- `guardRegisterRisk(...)` 在数值达到阈值时抛 429;无注解注入,无法配置。
- `fruitshorts-app-api/src/main/resources/application.yml` 已有 `fruitshorts.interaction.*` 等配置段。
### 二、本批范围
### 二、本批范围(后台可配置,走 `sys_config`)
1. 配置化:
- 新增 `fruitshorts.auth.register-max-per-ip-per-day`(默认 5)、`fruitshorts.auth.register-max-per-device-per-day`(默认 3);
- 用 `@Value` 注入 `AppAuthService`(保持现有构造方式最小改动);
- **值 ≤ 0 表示不限制**(跳过对应 guard 分支),不改变错误码与文案。
2. 开发环境 yml:在 `application.yml` 显式写 0(不限制),并注释说明生产建议值 5/3;`application-prod.yml` 如存在则保持 5/3。
3. 契约说明:在 `FruitServer/docs/api-phase1.md` §1.1 注册风控段补充配置键与 0=不限制口径。
4. 测试:
- 现有 IP/设备限流用例改为注入配置;
- 新增「配置为 0 时不限制」用例(分别覆盖 IP 与设备);
- `mvn test` 全绿。
5. 重新打包并重启 8081:
1. **读取方式**:`AppAuthService` 注入 `ConfigService`,读取:
- `app.register.max-per-ip-per-day`
- `app.register.max-per-device-per-day`
- 值 **≤0 = 不限制**(跳过对应 guard);键缺失或非数字时回退默认 **5 / 3**;错误码与文案不变。
2. **种子配置**:`sql/data-phase1.sql` 增加两键的幂等种子(`INSERT ... ON CONFLICT (config_key) DO NOTHING`):
- 开发默认值 0(不限制),remark 写明「0=不限制;生产建议 IP 5 / 设备 3」。
3. **后台配置**:无需新建页面——已有 `ReelShortWeb/src/views/systemManagement/SettingsView.vue` 使用 `/admin/configs`;只需确认菜单可进入「系统配置」并能看到这两个键。
4. **契约说明**:`docs/api-phase1.md` §1.1 注册风控段补充:两键可在后台「系统配置」修改、0=不限制、缺省回退 5/3。
5. **测试**:`ConfigService` 注入 mock 覆盖 0 / 缺省 / 自定义三种;`mvn test` 全绿。
6. **打包并重启 8081**:
- `mvn -pl fruitshorts-app-api -am package -DskipTests`;
- 停掉旧 8081 进程并用新 jar 启动,`/healthz` 通过;
- 验证:用新设备号连续注册若干次不再 429(只做接口自检,不作为设备侧证据)。
- 停旧 8081 进程并使用新 jar 启动,`/healthz` 通过;
- 验证:确认库中两键为 0 后,用新设备号连续注册 ≥4 次不再 429(接口自检,不作为设备侧证据)。
7. **不使用 application.yml 固化限流值**(已下发口径更正)。
### 三、约束
@@ -17,6 +17,9 @@ spring:
timeout-per-shutdown-phase: 30s
fruitshorts:
auth:
register-max-per-ip-per-day: 5
register-max-per-device-per-day: 3
media:
# 生产必须开启播放地址签名(docs/12-上线检查清单)
signature-required: true
@@ -43,6 +43,10 @@ springdoc:
path: /v3/api-docs
fruitshorts:
auth:
# 注册频控;小于等于 0 表示不限制。开发环境默认关闭,生产建议 5 / 3。
register-max-per-ip-per-day: 0
register-max-per-device-per-day: 0
interaction:
# MACHINE_FIRST:机审通过即发布,命中 REVIEW 级敏感词才转人工
# PRE_REVIEW :全部先审后发(生产建议)
@@ -25,6 +25,7 @@ import com.fruitshorts.user.vo.TokenRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
import java.time.LocalDateTime;
@@ -164,6 +165,7 @@ class AppAuthServiceTest {
@Test
void should_reject_register_when_ip_reaches_the_daily_limit() {
setRegisterLimits(5, 3);
given(userMapper.selectCount(any())).willReturn(5L);
assertThatThrownBy(() -> authService.register(emailRegister("abcd1234", null), "203.0.113.8"))
@@ -173,6 +175,38 @@ class AppAuthServiceTest {
.hasMessageContaining("当前网络注册过于频繁");
}
@Test
void should_reject_register_when_device_reaches_the_daily_limit() {
setRegisterLimits(5, 3);
given(userMapper.selectCount(any())).willReturn(3L);
assertThatThrownBy(() -> authService.register(emailRegister("abcd1234", null), "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
.isEqualTo(ErrorCode.TOO_MANY_REQUESTS.getCode()))
.hasMessage("该设备注册过于频繁,请稍后再试");
}
@Test
void should_not_limit_by_ip_when_ip_limit_is_zero() {
stubHappyRegister();
setRegisterLimits(0, 3);
given(userMapper.selectCount(any())).willReturn(2L);
assertThat(authService.register(emailRegister("abcd1234", null), "203.0.113.8").getAccessToken())
.isEqualTo("access-1");
}
@Test
void should_not_limit_by_device_when_device_limit_is_zero() {
stubHappyRegister();
setRegisterLimits(5, 0);
given(userMapper.selectCount(any())).willReturn(4L);
assertThat(authService.register(emailRegister("abcd1234", null), "203.0.113.8").getAccessToken())
.isEqualTo("access-1");
}
@Test
void should_return_tokens_when_password_matches() {
stubLogin(credential("bcrypt-hash"), user(AppUserStatus.NORMAL));
@@ -268,6 +302,11 @@ class AppAuthServiceTest {
deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder);
}
private void setRegisterLimits(int perIp, int perDevice) {
ReflectionTestUtils.setField(authService, "maxRegisterPerIpPerDay", perIp);
ReflectionTestUtils.setField(authService, "maxRegisterPerDevicePerDay", perDevice);
}
private RegisterReqVO emailRegister(String password, String code) {
RegisterReqVO req = new RegisterReqVO();
req.setTargetType("EMAIL");
@@ -25,6 +25,7 @@ import com.fruitshorts.user.vo.LogoutReqVO;
import com.fruitshorts.user.vo.RegisterReqVO;
import com.fruitshorts.user.vo.TokenRespVO;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@@ -301,10 +302,14 @@ 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;
/** 每个 IP 24 小时内最多注册几个账号;小于等于 0 表示不限制。 */
@Value("${fruitshorts.auth.register-max-per-ip-per-day:5}")
private int maxRegisterPerIpPerDay = 5;
/** 每台设备 24 小时内最多注册几个账号;小于等于 0 表示不限制。 */
@Value("${fruitshorts.auth.register-max-per-device-per-day:3}")
private int maxRegisterPerDevicePerDay = 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]");
@@ -328,7 +333,7 @@ public class AppAuthService {
}
/**
* 注册频控(邮箱免码注册必须带的兜底):同 IP 24h ≤5 个、同设备 24h ≤3 个。
* 注册频控(邮箱免码注册必须带的兜底):阈值由配置提供,≤0 时跳过对应限制。
*
* 直接数 `app_user` 上的 register_ip / register_device_id,不新建计数表:
* 注册量本来就低,这两列也已经有数据,省一张表与一套清理任务。
@@ -337,20 +342,20 @@ public class AppAuthService {
private void guardRegisterRisk(String clientIp, String deviceId) {
LocalDateTime since = LocalDateTime.now().minusDays(1);
String ip = AppIdentifier.nullSafe(clientIp);
if (StringUtils.hasText(ip)) {
if (maxRegisterPerIpPerDay > 0 && 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) {
if (byIp != null && byIp >= maxRegisterPerIpPerDay) {
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "当前网络注册过于频繁,请稍后再试");
}
}
String device = AppIdentifier.nullSafe(deviceId);
if (StringUtils.hasText(device)) {
if (maxRegisterPerDevicePerDay > 0 && 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) {
if (byDevice != null && byDevice >= maxRegisterPerDevicePerDay) {
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "该设备注册过于频繁,请稍后再试");
}
}