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 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);
|
||||||
|
|||||||
+5
-3
@@ -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")
|
||||||
|
|||||||
+13
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
@@ -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();
|
||||||
|
|||||||
@@ -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)}")
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user