feat(notify,log): 催报分钟级限流 + 学生微信登录写登录日志(两处合规补漏)
一、催报分钟级限流(F-29,契约 §3「10 次/分钟/任务」此前未实现)
· UrgeService 加 Redis 计数:定向与一键催报共用,超限 42901
且话说清等多久、24h 去重还在(去重防「学生被重复催」,限流防「老师连点
把系统打爆」—— 两件事);限流放在「确实有活可干」之后(空结果连点不计数)
· Redis 不可用时放行不瘫痪(限流是保护,不是业务前提)
二、学生微信登录也写 sys_login_log(F-31 —— 此前只有 PC 账号密码登录留痕,
按 §13.4 查「学生登录记录」永远为空,合规缺口)
· 新增 WechatLoginLogger:小程序登录(MpLoginService)与 H5 授权回调/
打桩登录(MpH5AuthController)三条路径都写
· login_type=2 与 DDL 注释对齐(1 PC 2 小程序 3 开放接口);H5 与小程序
共用 2(DDL 只定义到 3,不造新值),要区分看 userAgent —— 字段里本来就带着
· 写失败不影响登录(日志表满了不能没人能登录);不记 code/openid
验证:e2e-m5-urge → 42/42(新增「连点第 11 次被限流」断言,先清计数保证确定性)
e2e-m3-h5-auth → 24/24(新增「两次 stub-login 各留一条 login_type=2」断言,
清理时连登录日志一起清)
This commit is contained in:
+64
@@ -0,0 +1,64 @@
|
||||
package cn.tianbaotong.fill.module.log.service;
|
||||
|
||||
import cn.tianbaotong.fill.module.log.entity.SysLoginLog;
|
||||
import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 学生侧(微信)登录也写 {@code sys_login_log}(2026-09-30 客户确认补上,见《08》F-31)。
|
||||
*
|
||||
* <h3>为什么此前一直只有 PC 的</h3>
|
||||
*
|
||||
* <p>{@code AuthService.writeLog} 只挂在账号密码登录上,而学生从小程序/H5 进来走的是
|
||||
* 微信授权(无密码),那条链路没人写日志 —— 于是按 §13.4 查"学生登录记录"永远是空的,
|
||||
* 而这在合规上是个缺口("谁在什么时候登录过"必须两类用户都答得出来)。
|
||||
*
|
||||
* <h3>login_type 的取值(与 DDL 注释对齐:1 PC 2 小程序 3 开放接口)</h3>
|
||||
*
|
||||
* <p>小程序与 H5 网页授权**共用 {@code 2}**:DDL 只定义到 3,
|
||||
* 自造新值就是又一处"文档与库不一致"。要区分两端时看 {@code user_agent}
|
||||
* —— H5 是浏览器 UA,小程序是 {@code miniProgram},字段里本来就带着。
|
||||
*
|
||||
* <p>⚠️ 与 PC 侧同样的纪律:**写失败绝不影响登录本身**(否则日志表一满就没人能登录),
|
||||
* 也不记任何凭据(code / openid 都不落库,username 用姓名或 "wx-{uid}")。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WechatLoginLogger {
|
||||
|
||||
/** DDL:1 PC 2 小程序 3 开放接口。 */
|
||||
public static final int LOGIN_TYPE_WECHAT = 2;
|
||||
|
||||
private final LoginLogMapper loginLogMapper;
|
||||
|
||||
/**
|
||||
* 记一条成功的微信侧登录。
|
||||
*
|
||||
* @param displayName 姓名(没绑定名册的新用户可能是 null,此时记 "wx-{uid}")
|
||||
*/
|
||||
public void success(Long tenantId, Long userId, String displayName,
|
||||
HttpServletRequest req) {
|
||||
try {
|
||||
SysLoginLog row = new SysLoginLog();
|
||||
row.setTenantId(tenantId == null ? 0L : tenantId);
|
||||
row.setUserId(userId);
|
||||
String who = displayName == null || displayName.isBlank()
|
||||
? ("wx-" + userId) : displayName;
|
||||
row.setUsername(who.length() <= 64 ? who : who.substring(0, 64));
|
||||
row.setLoginType(LOGIN_TYPE_WECHAT);
|
||||
row.setSuccess(1);
|
||||
row.setFailReason(null);
|
||||
row.setIp(req == null ? null : cn.tianbaotong.fill.support.WebUtil.clientIp(req));
|
||||
row.setUserAgent(req == null ? null : cn.tianbaotong.fill.support.WebUtil.userAgent(req));
|
||||
row.setTraceId(MDC.get("traceId"));
|
||||
loginLogMapper.insertIgnoreTenant(row);
|
||||
} catch (Exception e) { // noqa: BLE001
|
||||
log.warn("写微信侧登录日志失败(不影响登录): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-6
@@ -48,6 +48,7 @@ public class MpH5AuthController {
|
||||
private final MpH5AuthService authService;
|
||||
private final JwtService jwtService;
|
||||
private final cn.tianbaotong.fill.config.FillProperties props;
|
||||
private final cn.tianbaotong.fill.module.log.service.WechatLoginLogger wechatLoginLogger;
|
||||
|
||||
@PublicApi
|
||||
@GetMapping("/authorize")
|
||||
@@ -64,7 +65,8 @@ public class MpH5AuthController {
|
||||
@Operation(summary = "微信授权回调(匿名)",
|
||||
description = "code 换 openid → 找/建学生 → 下发会话 → 302 回 H5(token 在 URL fragment 里)")
|
||||
public ResponseEntity<Void> callback(@RequestParam(required = false) String code,
|
||||
@RequestParam(required = false) String state) {
|
||||
@RequestParam(required = false) String state,
|
||||
HttpServletRequest request) {
|
||||
String scene = authService.consumeScene(state);
|
||||
try {
|
||||
if (code == null || code.isBlank()) {
|
||||
@@ -72,7 +74,7 @@ public class MpH5AuthController {
|
||||
return redirectToApp(null, scene, "你取消了微信授权,请重新进入");
|
||||
}
|
||||
String openId = authService.exchangeOpenId(code);
|
||||
return loginAndRedirect(openId, scene);
|
||||
return loginAndRedirect(openId, scene, request);
|
||||
} catch (BizException e) {
|
||||
// ⚠️ 这个地址是**浏览器直接跳转**过来的。抛业务错误的话,用户看到的是
|
||||
// 一屏 {"code":40001,"msg":"..."} 的 JSON —— 一个学生完全无法理解的画面,
|
||||
@@ -99,12 +101,13 @@ public class MpH5AuthController {
|
||||
return redirectToApp(null, scene, "打桩登录需要带合法的 scene(t{租户id}...)");
|
||||
}
|
||||
SysUser user = authService.stubLogin(tenantId, openid, request);
|
||||
return redirectWithToken(user, scene);
|
||||
ResponseEntity<Void> r = redirectWithToken(user, scene, request);
|
||||
return r;
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────
|
||||
|
||||
private ResponseEntity<Void> loginAndRedirect(String openId, String scene) {
|
||||
private ResponseEntity<Void> loginAndRedirect(String openId, String scene, HttpServletRequest request) {
|
||||
Long tenantId = MpSceneService.tenantIdOf(scene);
|
||||
if (tenantId == null) {
|
||||
// 没带 scene 就没法定租户:这个人属于哪个学校还不知道。
|
||||
@@ -112,10 +115,13 @@ public class MpH5AuthController {
|
||||
return redirectToApp(null, scene, "请扫描班主任发的二维码进入");
|
||||
}
|
||||
SysUser user = authService.resolveUser(tenantId, openId);
|
||||
return redirectWithToken(user, scene);
|
||||
return redirectWithToken(user, scene, request);
|
||||
}
|
||||
|
||||
private ResponseEntity<Void> redirectWithToken(SysUser user, String scene) {
|
||||
private ResponseEntity<Void> redirectWithToken(SysUser user, String scene, HttpServletRequest request) {
|
||||
// 学生侧登录也写 sys_login_log(2026-09-30 补上,F-31):H5 授权回调与打桩登录
|
||||
// 都从这里下发 token,写一次就覆盖两条路径。写失败不影响登录
|
||||
wechatLoginLogger.success(user.getTenantId(), user.getId(), user.getRealName(), request);
|
||||
String token = jwtService.issue(
|
||||
new LoginUser(user.getId(), user.getTenantId(), List.of(), java.util.Set.of("STUDENT"), 4),
|
||||
JwtService.Client.MP);
|
||||
|
||||
+5
-3
@@ -41,11 +41,13 @@ public class MpLoginController {
|
||||
description = "`{code, scene}` → 令牌 + `needBindPhone` + `boundRoster`。"
|
||||
+ "⚠️ **scene 是必需的**:它决定你属于哪个学校(统一应用 + scene 识别租户)。"
|
||||
+ "scene 可穷举,所以这里**不返回任何业务数据**,只返回"
|
||||
+ "「需不需要绑手机」与「你在这次任务里有没有绑定名册」")
|
||||
public ApiResult<MpLoginService.LoginVO> login(@RequestBody(required = false) Map<String, Object> body) {
|
||||
+ "「需不需要绑手机」与「你在这次任务里有没有绑定名册」。"
|
||||
+ "登录成功会写 sys_login_log(login_type=2,2026-09-30 补上,见 F-31)")
|
||||
public ApiResult<MpLoginService.LoginVO> login(@RequestBody(required = false) Map<String, Object> body,
|
||||
jakarta.servlet.http.HttpServletRequest request) {
|
||||
String code = str(body == null ? null : body.get("code"));
|
||||
String scene = str(body == null ? null : body.get("scene"));
|
||||
return ApiResult.ok(loginService.login(code, scene));
|
||||
return ApiResult.ok(loginService.login(code, scene, request));
|
||||
}
|
||||
|
||||
@PostMapping("/bind-phone")
|
||||
|
||||
+13
@@ -71,6 +71,7 @@ public class MpLoginService {
|
||||
private final TaskMapper taskMapper;
|
||||
private final JwtService jwtService;
|
||||
private final SensitiveCodec codec;
|
||||
private final cn.tianbaotong.fill.module.log.service.WechatLoginLogger wechatLoginLogger;
|
||||
|
||||
private final HttpClient http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10)).build();
|
||||
@@ -79,6 +80,14 @@ public class MpLoginService {
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public LoginVO login(String code, String scene) {
|
||||
return login(code, scene, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param request 用于登录日志取 IP / UA(可为 null:定时或内部调用没有请求上下文)
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public LoginVO login(String code, String scene, jakarta.servlet.http.HttpServletRequest request) {
|
||||
if (code == null || code.isBlank()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 code");
|
||||
}
|
||||
@@ -130,6 +139,10 @@ public class MpLoginService {
|
||||
}
|
||||
log.info("小程序登录:tenant={} uid={} taskId={} 需绑手机={}",
|
||||
s.tenantId(), user.getId(), s.taskId(), vo.getNeedBindPhone());
|
||||
// 学生侧登录也写 sys_login_log(2026-09-30 补上,F-31):
|
||||
// 以前只有 PC 账号密码登录留痕,按 §13.4 查"学生登录记录"永远是空的 —— 合规缺口。
|
||||
// 写失败不影响登录(logger 内部吞掉);tenantId/姓名在这里才齐,所以放 service 而不是 controller
|
||||
wechatLoginLogger.success(user.getTenantId(), user.getId(), user.getRealName(), request);
|
||||
return vo;
|
||||
}
|
||||
|
||||
|
||||
+35
@@ -60,9 +60,39 @@ public class UrgeService {
|
||||
/** 默认去重窗口(小时)。 */
|
||||
private static final int DEFAULT_EXCLUDE_HOURS = 24;
|
||||
|
||||
/**
|
||||
* 催报分钟级限流(契约 §3:「催报 10 次/分钟/任务」,2026-09-30 补上,见《08》F-29)。
|
||||
*
|
||||
* <p>与 24 小时去重是**两件事**:去重防"同一个学生被重复催",限流防"老师连点把系统打爆"。
|
||||
* 没有它时连点 100 次不会造成伤害(第二次起全被去重跳过),但会白跑 100 次全表查询。
|
||||
*/
|
||||
private static final int URGES_PER_MINUTE_PER_TASK = 10;
|
||||
|
||||
private final UrgeMapper urgeMapper;
|
||||
private final AuditScopeMapper auditScopeMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final org.springframework.data.redis.core.StringRedisTemplate redis;
|
||||
|
||||
/** 计一次催报动作,超限抛 {@code 42901}。键按任务(没有任务时按班级组合,见调用处)。 */
|
||||
private void rateLimit(String scopeKey) {
|
||||
String key = "xt:urge:" + scopeKey;
|
||||
try {
|
||||
Long n = redis.opsForValue().increment(key);
|
||||
if (n != null && n == 1L) {
|
||||
redis.expire(key, Duration.ofSeconds(60));
|
||||
}
|
||||
if (n != null && n > URGES_PER_MINUTE_PER_TASK) {
|
||||
throw new BizException(ErrorCode.RATE_LIMITED,
|
||||
"催报操作过于频繁(每分钟最多 " + URGES_PER_MINUTE_PER_TASK
|
||||
+ " 次/任务),请稍后再试。同一学生 24 小时内不会被重复催,不必连点");
|
||||
}
|
||||
} catch (BizException e) {
|
||||
throw e;
|
||||
} catch (Exception e) { // noqa: BLE001
|
||||
// Redis 不可用不该让催报瘫痪 —— 限流是保护,不是业务前提
|
||||
log.warn("催报限流计数失败(放行本次):{}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 定向催报(§8.6 的 `/roster/{rid}/notify`)──
|
||||
|
||||
@@ -87,6 +117,7 @@ public class UrgeService {
|
||||
// 还没核对身份 → 没有账号可发。如实说明,别报"发送失败"
|
||||
return result(List.of(detail(t, true, "这个学生还没扫码核对身份,无法触达(先让他扫二维码)")));
|
||||
}
|
||||
rateLimit("task:" + t.getTaskId());
|
||||
|
||||
String extra = req == null ? null : req.getExtraText();
|
||||
NotifyOutcome o = send(t, req == null ? null : req.getTemplateCode(), extra, false);
|
||||
@@ -125,6 +156,10 @@ public class UrgeService {
|
||||
return result(List.of());
|
||||
}
|
||||
|
||||
// 限流放在"确实有活可干"之后:空结果就连点也不计数 ——
|
||||
// 惩罚"点了但没人可催"只会让人困惑(界面上明明什么都没发生)
|
||||
rateLimit(taskId != null ? "task:" + taskId : "orgs:" + orgIds);
|
||||
|
||||
int hours = req == null || req.getExcludeRecentHours() == null
|
||||
? DEFAULT_EXCLUDE_HOURS : Math.max(0, req.getExcludeRecentHours());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
Reference in New Issue
Block a user