From f1aaaee8142c3c5538f637876ce73550118865a0 Mon Sep 17 00:00:00 2001
From: Zhou Weidong <502333560@qq.com>
Date: Sat, 17 Oct 2026 23:29:25 +0800
Subject: [PATCH] =?UTF-8?q?fix(backend):=20=E6=94=B9=E4=B8=BA=E5=90=8E?=
=?UTF-8?q?=E5=8F=B0=E9=85=8D=E7=BD=AE=E6=B3=A8=E5=86=8C=E9=99=90=E6=B5=81?=
=?UTF-8?q?=E9=98=88=E5=80=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/api-phase1.md | 6 ++--
docs/plans/2026-10-17-K16.md | 27 +++++++-------
.../src/main/resources/application-prod.yml | 3 --
.../src/main/resources/application.yml | 4 ---
.../app/service/AppAuthServiceTest.java | 29 ++++++++++++---
fruitshorts-module-user/pom.xml | 4 +++
.../user/service/AppAuthService.java | 36 ++++++++++++-------
sql/data-phase1.sql | 9 +++++
8 files changed, 79 insertions(+), 39 deletions(-)
diff --git a/docs/api-phase1.md b/docs/api-phase1.md
index 7987c38..2e9361b 100644
--- a/docs/api-phase1.md
+++ b/docs/api-phase1.md
@@ -118,15 +118,15 @@ POST /app/auth/register
频控口径落库在 `app_user.register_ip` / `app_user.register_device_id` 上(按 24h 计数,不新建计数表)。
-注册频控阈值通过以下配置键调整:
+注册频控阈值存储在 `sys_config`,可在后台「系统设置」修改:
```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。
+键缺失或值不是整数时回退默认 5 / 3;值小于等于 0 表示对应维度不限制。
+开发种子配置为 0 / 0,生产建议恢复为 5 / 3。
**残留风险**:IP 取自 `X-Forwarded-For`、设备号由客户端上报,都能伪造,只能挡脚本批量;
真要防住需要接图形/行为验证码或风控服务,本阶段未做。验证码通道目前是 `LogOtpSender`(只写日志不发信),
diff --git a/docs/plans/2026-10-17-K16.md b/docs/plans/2026-10-17-K16.md
index 55f64c6..a7909a9 100644
--- a/docs/plans/2026-10-17-K16.md
+++ b/docs/plans/2026-10-17-K16.md
@@ -2,34 +2,37 @@
## 范围
-将 C 端注册的 IP/设备 24 小时限流阈值从硬编码改为 Spring 配置:
+将 C 端注册的 IP/设备 24 小时限流阈值从硬编码改为后台 `sys_config` 配置:
```text
-fruitshorts.auth.register-max-per-ip-per-day
-fruitshorts.auth.register-max-per-device-per-day
+app.register.max-per-ip-per-day
+app.register.max-per-device-per-day
```
-默认值保持 5/3;值小于等于 0 时跳过对应 guard。注册接口、错误码和错误文案不变。
+键缺失或非整数时回退默认 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 不限制、设备不限制。
+1. `AppAuthService` 注入 `ConfigService`,从 `sys_config` 读取两个键。
+2. `sql/data-phase1.sql` 幂等写入开发默认 0/0 和说明。
+3. 不使用 `application.yml` 固化限流值。
+4. `docs/api-phase1.md` §1.1 补充后台配置键、缺省/非法回退和 0=不限制口径。
+5. 测试 mock `ConfigService`,覆盖自定义、0、缺省/非数字回退。
+6. 确认后台「系统设置」页面可访问 `/admin/configs`;若现有表单未展示新键,
+ 记录为待后续 Web 表单补充,不把配置固化回 yml。
## 验证与交付
- `mvn test`
- `mvn -pl fruitshorts-app-api -am package -DskipTests`
-- 停止当前 8081 PID 30520,启动新 jar,`/healthz` 通过
+- 停止当前 8081 服务,启动新 jar,`/healthz` 通过
- 使用新设备号连续注册至少 4 次,确认不再 429(接口自检)
-- `feature/backend-register-rate-config` 以 `--no-ff` 合并回 `develop` 并 push
+- `feature/backend-register-rate-config-fix` 以 `--no-ff` 合并回 `develop` 并 push
- PROGRESS 记录命令、原始输出和解读
## 风险
-- 配置 0 会关闭注册滥用保护,仅用于当前开发环境;prod profile 固定恢复 5/3。
+- 配置 0 会关闭注册滥用保护,仅用于当前开发环境;生产需在后台恢复 5/3。
- 旧 8081 服务重启期间会有短暂不可用。
- 限流仍基于客户端可伪造 IP/设备号,本次不放宽其他风控。
diff --git a/fruitshorts-app-api/src/main/resources/application-prod.yml b/fruitshorts-app-api/src/main/resources/application-prod.yml
index 7011d53..39f53d0 100644
--- a/fruitshorts-app-api/src/main/resources/application-prod.yml
+++ b/fruitshorts-app-api/src/main/resources/application-prod.yml
@@ -17,9 +17,6 @@ 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
diff --git a/fruitshorts-app-api/src/main/resources/application.yml b/fruitshorts-app-api/src/main/resources/application.yml
index 8a22657..c2d1f4c 100644
--- a/fruitshorts-app-api/src/main/resources/application.yml
+++ b/fruitshorts-app-api/src/main/resources/application.yml
@@ -43,10 +43,6 @@ 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 :全部先审后发(生产建议)
diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java
index b3cd853..a5f5f6e 100644
--- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java
+++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java
@@ -4,6 +4,7 @@ package com.fruitshorts.app.service;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.framework.security.JwtTokenProvider;
+import com.fruitshorts.system.service.ConfigService;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.entity.AppUserCredential;
import com.fruitshorts.user.dal.entity.AppUserOtp;
@@ -25,7 +26,6 @@ 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;
@@ -58,6 +58,7 @@ class AppAuthServiceTest {
private CredentialHasher hasher;
private JwtTokenProvider tokenProvider;
private PasswordEncoder passwordEncoder;
+ private ConfigService configService;
private AppAuthService authService;
@BeforeEach
@@ -71,8 +72,11 @@ class AppAuthServiceTest {
hasher = mock(CredentialHasher.class);
tokenProvider = mock(JwtTokenProvider.class);
passwordEncoder = mock(PasswordEncoder.class);
+ configService = mock(ConfigService.class);
+ given(configService.get(anyString(), anyString()))
+ .willAnswer(invocation -> invocation.getArgument(1));
authService = new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
- deviceMapper, otpService, hasher, tokenProvider, passwordEncoder);
+ deviceMapper, otpService, hasher, tokenProvider, passwordEncoder, configService);
}
@Test
@@ -187,6 +191,19 @@ class AppAuthServiceTest {
.hasMessage("该设备注册过于频繁,请稍后再试");
}
+ @Test
+ void should_fallback_to_default_limit_when_config_is_not_numeric() {
+ setRegisterLimits(5, 3);
+ given(configService.get("app.register.max-per-ip-per-day", "5")).willReturn("invalid");
+ given(userMapper.selectCount(any())).willReturn(5L);
+
+ 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()))
+ .hasMessageContaining("当前网络注册过于频繁");
+ }
+
@Test
void should_not_limit_by_ip_when_ip_limit_is_zero() {
stubHappyRegister();
@@ -299,12 +316,14 @@ class AppAuthServiceTest {
given(otpHasher.hashOtp(any())).willReturn("hash-of-something-else");
AppOtpService realOtpService = new AppOtpService(otpMapper, mock(OtpSender.class), otpHasher);
return new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
- deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder);
+ deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder, configService);
}
private void setRegisterLimits(int perIp, int perDevice) {
- ReflectionTestUtils.setField(authService, "maxRegisterPerIpPerDay", perIp);
- ReflectionTestUtils.setField(authService, "maxRegisterPerDevicePerDay", perDevice);
+ given(configService.get("app.register.max-per-ip-per-day", "5"))
+ .willReturn(String.valueOf(perIp));
+ given(configService.get("app.register.max-per-device-per-day", "3"))
+ .willReturn(String.valueOf(perDevice));
}
private RegisterReqVO emailRegister(String password, String code) {
diff --git a/fruitshorts-module-user/pom.xml b/fruitshorts-module-user/pom.xml
index 43bf348..6773145 100644
--- a/fruitshorts-module-user/pom.xml
+++ b/fruitshorts-module-user/pom.xml
@@ -21,5 +21,9 @@
com.fruitshorts
fruitshorts-framework
+
+ com.fruitshorts
+ fruitshorts-module-system
+
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 99b9867..8ed03b1 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
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.framework.security.JwtTokenProvider;
+import com.fruitshorts.system.service.ConfigService;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.entity.AppUserConsent;
import com.fruitshorts.user.dal.entity.AppUserCredential;
@@ -25,7 +26,6 @@ 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;
@@ -59,6 +59,7 @@ public class AppAuthService {
private final CredentialHasher hasher;
private final JwtTokenProvider tokenProvider;
private final PasswordEncoder passwordEncoder;
+ private final ConfigService configService;
@Transactional(rollbackFor = Exception.class)
public TokenRespVO register(RegisterReqVO req, String clientIp) {
@@ -302,13 +303,10 @@ public class AppAuthService {
return resp;
}
- /** 每个 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 String REGISTER_IP_LIMIT_KEY = "app.register.max-per-ip-per-day";
+ private static final String REGISTER_DEVICE_LIMIT_KEY = "app.register.max-per-device-per-day";
+ private static final int DEFAULT_REGISTER_IP_LIMIT = 5;
+ private static final int DEFAULT_REGISTER_DEVICE_LIMIT = 3;
private static final int PASSWORD_MIN_LENGTH = 8;
private static final int PASSWORD_MAX_LENGTH = 64;
@@ -341,26 +339,40 @@ public class AppAuthService {
*/
private void guardRegisterRisk(String clientIp, String deviceId) {
LocalDateTime since = LocalDateTime.now().minusDays(1);
+ int ipLimit = registerLimit(REGISTER_IP_LIMIT_KEY, DEFAULT_REGISTER_IP_LIMIT);
+ int deviceLimit = registerLimit(REGISTER_DEVICE_LIMIT_KEY, DEFAULT_REGISTER_DEVICE_LIMIT);
String ip = AppIdentifier.nullSafe(clientIp);
- if (maxRegisterPerIpPerDay > 0 && StringUtils.hasText(ip)) {
+ if (ipLimit > 0 && StringUtils.hasText(ip)) {
Long byIp = appUserMapper.selectCount(Wrappers.lambdaQuery()
.eq(AppUser::getRegisterIp, ip)
.ge(AppUser::getCreatedAt, since));
- if (byIp != null && byIp >= maxRegisterPerIpPerDay) {
+ if (byIp != null && byIp >= ipLimit) {
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "当前网络注册过于频繁,请稍后再试");
}
}
String device = AppIdentifier.nullSafe(deviceId);
- if (maxRegisterPerDevicePerDay > 0 && StringUtils.hasText(device)) {
+ if (deviceLimit > 0 && StringUtils.hasText(device)) {
Long byDevice = appUserMapper.selectCount(Wrappers.lambdaQuery()
.eq(AppUser::getRegisterDeviceId, device)
.ge(AppUser::getCreatedAt, since));
- if (byDevice != null && byDevice >= maxRegisterPerDevicePerDay) {
+ if (byDevice != null && byDevice >= deviceLimit) {
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "该设备注册过于频繁,请稍后再试");
}
}
}
+ private int registerLimit(String key, int defaultValue) {
+ String raw = configService.get(key, String.valueOf(defaultValue));
+ if (!StringUtils.hasText(raw)) {
+ return defaultValue;
+ }
+ try {
+ return Integer.parseInt(raw.trim());
+ } catch (NumberFormatException ex) {
+ return defaultValue;
+ }
+ }
+
private AppUser createUser(RegisterReqVO req, String clientIp) {
AppUser user = new AppUser();
user.setUid(hasher.newUid());
diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql
index 8c980c4..023c9c0 100644
--- a/sql/data-phase1.sql
+++ b/sql/data-phase1.sql
@@ -324,3 +324,12 @@ CROSS JOIN sys_menu m
WHERE r.code = 'super_admin'
AND m.id IN (31, 32, 33)
ON CONFLICT DO NOTHING;
+
+-- ============================================================
+-- K16:注册频控后台配置
+-- ============================================================
+-- 0 = 不限制;生产建议 IP 5 / 设备 3。
+INSERT INTO sys_config (id, config_key, config_value, remark) VALUES
+ (3, 'app.register.max-per-ip-per-day', '0', '0=不限制;生产建议 5'),
+ (4, 'app.register.max-per-device-per-day', '0', '0=不限制;生产建议 3')
+ON CONFLICT (config_key) DO NOTHING;