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;