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)。 + * + *

为什么此前一直只有 PC 的

+ * + *

{@code AuthService.writeLog} 只挂在账号密码登录上,而学生从小程序/H5 进来走的是 + * 微信授权(无密码),那条链路没人写日志 —— 于是按 §13.4 查"学生登录记录"永远是空的, + * 而这在合规上是个缺口("谁在什么时候登录过"必须两类用户都答得出来)。 + * + *

login_type 的取值(与 DDL 注释对齐:1 PC 2 小程序 3 开放接口)

+ * + *

小程序与 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 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 r = redirectWithToken(user, scene, request); + return r; } // ── 内部 ────────────────────────────────────── - private ResponseEntity loginAndRedirect(String openId, String scene) { + private ResponseEntity 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 redirectWithToken(SysUser user, String scene) { + private ResponseEntity 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); diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpLoginController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpLoginController.java index dcb6946..d16b7d7 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpLoginController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpLoginController.java @@ -41,11 +41,13 @@ public class MpLoginController { description = "`{code, scene}` → 令牌 + `needBindPhone` + `boundRoster`。" + "⚠️ **scene 是必需的**:它决定你属于哪个学校(统一应用 + scene 识别租户)。" + "scene 可穷举,所以这里**不返回任何业务数据**,只返回" - + "「需不需要绑手机」与「你在这次任务里有没有绑定名册」") - public ApiResult login(@RequestBody(required = false) Map body) { + + "「需不需要绑手机」与「你在这次任务里有没有绑定名册」。" + + "登录成功会写 sys_login_log(login_type=2,2026-09-30 补上,见 F-31)") + public ApiResult login(@RequestBody(required = false) Map 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") diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpLoginService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpLoginService.java index db12b73..6ae11c4 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpLoginService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpLoginService.java @@ -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; } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/UrgeService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/UrgeService.java index abb1699..05d2f65 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/UrgeService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/UrgeService.java @@ -60,9 +60,39 @@ public class UrgeService { /** 默认去重窗口(小时)。 */ private static final int DEFAULT_EXCLUDE_HOURS = 24; + /** + * 催报分钟级限流(契约 §3:「催报 10 次/分钟/任务」,2026-09-30 补上,见《08》F-29)。 + * + *

与 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),