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:
2026-09-30 12:38:48 +08:00
parent 8f80744d63
commit 57bf39d5a1
7 changed files with 165 additions and 10 deletions
@@ -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());
}
}
}
@@ -48,6 +48,7 @@ public class MpH5AuthController {
private final MpH5AuthService authService; private final MpH5AuthService authService;
private final JwtService jwtService; private final JwtService jwtService;
private final cn.tianbaotong.fill.config.FillProperties props; private final cn.tianbaotong.fill.config.FillProperties props;
private final cn.tianbaotong.fill.module.log.service.WechatLoginLogger wechatLoginLogger;
@PublicApi @PublicApi
@GetMapping("/authorize") @GetMapping("/authorize")
@@ -64,7 +65,8 @@ public class MpH5AuthController {
@Operation(summary = "微信授权回调(匿名)", @Operation(summary = "微信授权回调(匿名)",
description = "code 换 openid → 找/建学生 → 下发会话 → 302 回 H5(token 在 URL fragment 里)") description = "code 换 openid → 找/建学生 → 下发会话 → 302 回 H5(token 在 URL fragment 里)")
public ResponseEntity<Void> callback(@RequestParam(required = false) String code, 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); String scene = authService.consumeScene(state);
try { try {
if (code == null || code.isBlank()) { if (code == null || code.isBlank()) {
@@ -72,7 +74,7 @@ public class MpH5AuthController {
return redirectToApp(null, scene, "你取消了微信授权,请重新进入"); return redirectToApp(null, scene, "你取消了微信授权,请重新进入");
} }
String openId = authService.exchangeOpenId(code); String openId = authService.exchangeOpenId(code);
return loginAndRedirect(openId, scene); return loginAndRedirect(openId, scene, request);
} catch (BizException e) { } catch (BizException e) {
// ⚠️ 这个地址是**浏览器直接跳转**过来的。抛业务错误的话,用户看到的是 // ⚠️ 这个地址是**浏览器直接跳转**过来的。抛业务错误的话,用户看到的是
// 一屏 {"code":40001,"msg":"..."} 的 JSON —— 一个学生完全无法理解的画面, // 一屏 {"code":40001,"msg":"..."} 的 JSON —— 一个学生完全无法理解的画面,
@@ -99,12 +101,13 @@ public class MpH5AuthController {
return redirectToApp(null, scene, "打桩登录需要带合法的 scene(t{租户id}...)"); return redirectToApp(null, scene, "打桩登录需要带合法的 scene(t{租户id}...)");
} }
SysUser user = authService.stubLogin(tenantId, openid, request); 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); Long tenantId = MpSceneService.tenantIdOf(scene);
if (tenantId == null) { if (tenantId == null) {
// 没带 scene 就没法定租户:这个人属于哪个学校还不知道。 // 没带 scene 就没法定租户:这个人属于哪个学校还不知道。
@@ -112,10 +115,13 @@ public class MpH5AuthController {
return redirectToApp(null, scene, "请扫描班主任发的二维码进入"); return redirectToApp(null, scene, "请扫描班主任发的二维码进入");
} }
SysUser user = authService.resolveUser(tenantId, openId); 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( String token = jwtService.issue(
new LoginUser(user.getId(), user.getTenantId(), List.of(), java.util.Set.of("STUDENT"), 4), new LoginUser(user.getId(), user.getTenantId(), List.of(), java.util.Set.of("STUDENT"), 4),
JwtService.Client.MP); JwtService.Client.MP);
@@ -41,11 +41,13 @@ public class MpLoginController {
description = "`{code, scene}` → 令牌 + `needBindPhone` + `boundRoster`。" description = "`{code, scene}` → 令牌 + `needBindPhone` + `boundRoster`。"
+ "⚠️ **scene 是必需的**:它决定你属于哪个学校(统一应用 + scene 识别租户)。" + "⚠️ **scene 是必需的**:它决定你属于哪个学校(统一应用 + 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 code = str(body == null ? null : body.get("code"));
String scene = str(body == null ? null : body.get("scene")); 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") @PostMapping("/bind-phone")
@@ -71,6 +71,7 @@ public class MpLoginService {
private final TaskMapper taskMapper; private final TaskMapper taskMapper;
private final JwtService jwtService; private final JwtService jwtService;
private final SensitiveCodec codec; private final SensitiveCodec codec;
private final cn.tianbaotong.fill.module.log.service.WechatLoginLogger wechatLoginLogger;
private final HttpClient http = HttpClient.newBuilder() private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10)).build(); .connectTimeout(Duration.ofSeconds(10)).build();
@@ -79,6 +80,14 @@ public class MpLoginService {
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public LoginVO login(String code, String scene) { 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()) { if (code == null || code.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 code"); throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 code");
} }
@@ -130,6 +139,10 @@ public class MpLoginService {
} }
log.info("小程序登录:tenant={} uid={} taskId={} 需绑手机={}", log.info("小程序登录:tenant={} uid={} taskId={} 需绑手机={}",
s.tenantId(), user.getId(), s.taskId(), vo.getNeedBindPhone()); 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; return vo;
} }
@@ -60,9 +60,39 @@ public class UrgeService {
/** 默认去重窗口(小时)。 */ /** 默认去重窗口(小时)。 */
private static final int DEFAULT_EXCLUDE_HOURS = 24; 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 UrgeMapper urgeMapper;
private final AuditScopeMapper auditScopeMapper; private final AuditScopeMapper auditScopeMapper;
private final NotificationService notificationService; 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`)── // ── 定向催报(§8.6 的 `/roster/{rid}/notify`)──
@@ -87,6 +117,7 @@ public class UrgeService {
// 还没核对身份 → 没有账号可发。如实说明,别报"发送失败" // 还没核对身份 → 没有账号可发。如实说明,别报"发送失败"
return result(List.of(detail(t, true, "这个学生还没扫码核对身份,无法触达(先让他扫二维码)"))); return result(List.of(detail(t, true, "这个学生还没扫码核对身份,无法触达(先让他扫二维码)")));
} }
rateLimit("task:" + t.getTaskId());
String extra = req == null ? null : req.getExtraText(); String extra = req == null ? null : req.getExtraText();
NotifyOutcome o = send(t, req == null ? null : req.getTemplateCode(), extra, false); NotifyOutcome o = send(t, req == null ? null : req.getTemplateCode(), extra, false);
@@ -125,6 +156,10 @@ public class UrgeService {
return result(List.of()); return result(List.of());
} }
// 限流放在"确实有活可干"之后:空结果就连点也不计数 ——
// 惩罚"点了但没人可催"只会让人困惑(界面上明明什么都没发生)
rateLimit(taskId != null ? "task:" + taskId : "orgs:" + orgIds);
int hours = req == null || req.getExcludeRecentHours() == null int hours = req == null || req.getExcludeRecentHours() == null
? DEFAULT_EXCLUDE_HOURS : Math.max(0, req.getExcludeRecentHours()); ? DEFAULT_EXCLUDE_HOURS : Math.max(0, req.getExcludeRecentHours());
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
+11 -1
View File
@@ -192,6 +192,15 @@ def main() -> int:
f"WHERE ur.user_id = {uid1.strip()} AND r.role_code = 'STUDENT'") f"WHERE ur.user_id = {uid1.strip()} AND r.role_code = 'STUDENT'")
check("已授予学生角色(用户类型由角色推导)", okk4 and role.strip() == "1", f"{role}") check("已授予学生角色(用户类型由角色推导)", okk4 and role.strip() == "1", f"{role}")
# ── 4b. 登录留痕(2026-09-30 补上,F-31)────────────────
# 此前只有 PC 账号密码登录写 sys_login_log,学生从微信进来完全无痕 ——
# 按 §13.4 查"学生登录记录"永远是空的,合规上是个缺口
okk5, lls = sql("SELECT count(*) FROM sys_login_log "
f"WHERE user_id = {uid1.strip()} AND login_type = 2 AND success = 1")
check("⭐ 微信登录也写 sys_login_log(login_type=2)",
okk5 and int(lls.strip()) >= 2,
f"行数={lls.strip()}(本脚本已做两次 stub-login,应各留一条)")
# ── 5. 没有 scene 时不猜租户 ───────────────────────── # ── 5. 没有 scene 时不猜租户 ─────────────────────────
print("\n[5] 没带 scene 时") print("\n[5] 没带 scene 时")
code3, headers3 = redirect_of(f"{base}/api/v1/mp/h5/stub-login?openid={openid}") code3, headers3 = redirect_of(f"{base}/api/v1/mp/h5/stub-login?openid={openid}")
@@ -219,8 +228,9 @@ def main() -> int:
print("\n[7] 清理测试数据") print("\n[7] 清理测试数据")
for uid in CREATED_USER_IDS: for uid in CREATED_USER_IDS:
sql(f"DELETE FROM sys_user_role WHERE user_id = {uid}") sql(f"DELETE FROM sys_user_role WHERE user_id = {uid}")
sql(f"DELETE FROM sys_login_log WHERE user_id = {uid}")
sql(f"DELETE FROM sys_user WHERE id = {uid}") sql(f"DELETE FROM sys_user WHERE id = {uid}")
ok(f"已清理 {len(CREATED_USER_IDS)} 个测试账号") ok(f"已清理 {len(CREATED_USER_IDS)} 个测试账号(含其登录日志)")
print("\n" + "=" * 72) print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
+25
View File
@@ -344,6 +344,31 @@ def main() -> int:
# ── 9. 边界与权限 ── # ── 9. 边界与权限 ──
print("\n[9] 权限与边界") print("\n[9] 权限与边界")
# ── 9a. 分钟级限流(§3「催报 10 次/分钟/任务」,2026-09-30 补上,F-29)──
# 先清计数再连打 11 次:断言必须**确定**第 11 次是 42901,
# 而"前面已经打了几次"取决于本脚本之前的用例 —— 靠清零把变量拿掉
print(" [9a] 分钟级限流(10 次/分钟/任务)")
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:urge:task:%d > NUL 2>&1' % task_id)
got_limit = None
for i in range(11):
rl = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1,
{"orgIds": [org_a["id"]], "taskId": task_id, "excludeRecentHours": 0})
if rl.get("code") == 42901:
got_limit = i + 1
break
check("⭐ 连点到第 11 次被限流(42901,而不是白跑 11 次全表查询)",
got_limit == 11, "第 %s 次触发(应为 11,前 10 次都要正常)" % got_limit)
check("限流的话说清等多久",
"频繁" in (body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1,
{"orgIds": [org_a["id"]], "taskId": task_id,
"excludeRecentHours": 0}).get("msg") or ""),
"(再打一次仍应是 42901)")
# 清掉计数,别影响后续/重复运行
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:urge:task:%d > NUL 2>&1' % task_id)
stok = mp(S1) stok = mp(S1)
b = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", stok, {"taskId": task_id}) b = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", stok, {"taskId": task_id})
check("学生不能催报 → 403", b.get("code") in (40101, 40301, 403), check("学生不能催报 → 403", b.get("code") in (40101, 40301, 403),