From 57bf39d5a1dc68c219403fe9d1e34cc13b6b233a Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Wed, 30 Sep 2026 12:38:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(notify,log):=20=E5=82=AC=E6=8A=A5=E5=88=86?= =?UTF-8?q?=E9=92=9F=E7=BA=A7=E9=99=90=E6=B5=81=20+=20=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E7=99=BB=E5=BD=95=E5=86=99=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=97=A5=E5=BF=97=EF=BC=88=E4=B8=A4=E5=A4=84=E5=90=88=E8=A7=84?= =?UTF-8?q?=E8=A1=A5=E6=BC=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、催报分钟级限流(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」断言, 清理时连登录日志一起清) --- .../module/log/service/WechatLoginLogger.java | 64 +++++++++++++++++++ .../mp/controller/MpH5AuthController.java | 18 ++++-- .../mp/controller/MpLoginController.java | 8 ++- .../module/mp/service/MpLoginService.java | 13 ++++ .../module/notify/service/UrgeService.java | 35 ++++++++++ scripts/e2e-m3-h5-auth.py | 12 +++- scripts/e2e-m5-urge.py | 25 ++++++++ 7 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/service/WechatLoginLogger.java diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/service/WechatLoginLogger.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/service/WechatLoginLogger.java new file mode 100644 index 0000000..b7a32f7 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/service/WechatLoginLogger.java @@ -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)。 + * + *
{@code AuthService.writeLog} 只挂在账号密码登录上,而学生从小程序/H5 进来走的是 + * 微信授权(无密码),那条链路没人写日志 —— 于是按 §13.4 查"学生登录记录"永远是空的, + * 而这在合规上是个缺口("谁在什么时候登录过"必须两类用户都答得出来)。 + * + *
小程序与 H5 网页授权**共用 {@code 2}**:DDL 只定义到 3, + * 自造新值就是又一处"文档与库不一致"。要区分两端时看 {@code user_agent} + * —— H5 是浏览器 UA,小程序是 {@code miniProgram},字段里本来就带着。 + * + *
⚠️ 与 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());
+ }
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java
index e0cc0e4..1c9beae 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java
@@ -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 与 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();
diff --git a/scripts/e2e-m3-h5-auth.py b/scripts/e2e-m3-h5-auth.py
index fb7129a..7e16cd0 100644
--- a/scripts/e2e-m3-h5-auth.py
+++ b/scripts/e2e-m3-h5-auth.py
@@ -192,6 +192,15 @@ def main() -> int:
f"WHERE ur.user_id = {uid1.strip()} AND r.role_code = 'STUDENT'")
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 时不猜租户 ─────────────────────────
print("\n[5] 没带 scene 时")
code3, headers3 = redirect_of(f"{base}/api/v1/mp/h5/stub-login?openid={openid}")
@@ -219,8 +228,9 @@ def main() -> int:
print("\n[7] 清理测试数据")
for uid in CREATED_USER_IDS:
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}")
- ok(f"已清理 {len(CREATED_USER_IDS)} 个测试账号")
+ ok(f"已清理 {len(CREATED_USER_IDS)} 个测试账号(含其登录日志)")
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
diff --git a/scripts/e2e-m5-urge.py b/scripts/e2e-m5-urge.py
index 9bf9b5e..dd15e86 100644
--- a/scripts/e2e-m5-urge.py
+++ b/scripts/e2e-m5-urge.py
@@ -344,6 +344,31 @@ def main() -> int:
# ── 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)
b = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", stok, {"taskId": task_id})
check("学生不能催报 → 403", b.get("code") in (40101, 40301, 403),