feat(mp): 小程序端骨架 + 扫码落地页(scene 识别租户)
小程序端从零建起来了,并在微信开发者工具里真机模拟跑通: 扫 scene=t1-k17 → 显示「示范职业学校 / 2026年9月机电专业认定材料收集 / 尚未发布」。 交付: · frontend-mp/(uni-app,官方 uni-preset-vue#vite-ts 模板),appid 已填 · src/pages/index/index.vue 扫码落地页:读 scene → 调后端 → 显示 「哪个学校 / 填什么 / 现在能不能填」 · GET /api/v1/mp/scene-context(匿名):登录前的落地页查询 · 主题色与 PC 管理端同一套(腾讯云 TDesign 色板),两端视觉一致 为什么落地页必然是匿名接口: 学生扫开的第一屏要回答"这是哪个学校、要填什么、能不能填", 而这发生在绑定身份之前。它与契约里的 POST /mp/login(code+scene 换 session) 不是一回事 —— 那个要换 openid,属 M3。 ⚠️ 一个必踩的坑(撞上并绕开了): FillTenantLineHandler.getTenantId() 在**匿名请求下直接抛 40101**, 所以不能用 taskMapper.selectById()。正确写法是专用 Mapper + 每条查询 @InterceptorIgnore(tenantLine="true") 并显式带 tenant_id(项目注释称此为 一次代码审查点)。见 module/mp/mapper/MpSceneMapper.java —— 把这几条集中在 一个文件里,正是为了让"关掉框架保护后由人保证隔离"这件事有地方审。 ⚠️ scene 可穷举(t1-k1、t1-k2…),所以匿名接口: ① 只返回落地页必须的最少信息,不往里加"顺带能查出来"的东西; ② 按 IP 限流(20 次/分钟,Redis INCR,复用项目的 GATE_RATE_LIMITED 40071)。 E2E 专门验了"前 20 次通过、第 21 次起被限、清掉计数器立刻恢复"。 状态文案分五种而不是合并成"当前不可填报":学生的下一步动作完全不同 —— 「已截止」要找班主任、「已暂停」要等通知、「尚未发布」是根本不该现在扫。 顺带记两个环境坑(写进交接摘要): · 微信开发者工具的「服务端口」默认关闭,HBuilderX 运行时报 `IDE service port disabled` 然后退出,**调不起开发者工具**。 解法:开发者工具 → 设置 → 安全设置 → 服务端口 → 开启; 或直接「导入项目」dist/dev/mp-weixin 绕过。 · HBuilderX 把这个项目当 CLI 型项目,产物在 dist/dev/mp-weixin, **不是** unpackage/。模拟扫码要在「编译模式」里设 进入场景=扫描二维码、 启动参数 scene=t1-k17。 · <style lang="scss"> 需要装 sass(模板没带),已 pnpm add -D sass。 ⚠️ 对上线计划最重要的一条结论(已写进交接摘要): 开发版(develop)只有开发者能扫、且会过期;体验版(trial)只有体验成员能扫; **只有正式版(release)学生才能扫**,而它需要提交审核,涉及小程序类目与资质。 所以 C1「小程序资质法务确认」不是"以后再说",它直接决定学生能不能开始填报。 已知缺口: · 填报主流程(/mp/login 门禁核验 表单填写)属 M3; 现在点「开始填报」给的是明确的"开发中"提示,不是静默无反应。 · frontend-mp 还没套项目既有的 ESLint/Stylelint 规范。 验收:e2e-m1-scene.py 31/31;八条 E2E 合计 389 项断言全通过; 微信开发者工具里人工确认页面渲染与 scene 传递正确。
This commit is contained in:
+68
@@ -0,0 +1,68 @@
|
||||
package cn.tianbaotong.fill.module.mp.controller;
|
||||
|
||||
import cn.tianbaotong.fill.common.api.ApiResult;
|
||||
import cn.tianbaotong.fill.module.mp.dto.SceneContextVO;
|
||||
import cn.tianbaotong.fill.module.mp.service.MpSceneService;
|
||||
import cn.tianbaotong.fill.security.PublicApi;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 小程序扫码落地页(FR-105:统一小程序 + 扫码带参 scene 识别租户)。
|
||||
*
|
||||
* <p>学生扫开小程序的第一屏必须能回答"这是哪个学校的收集、要填什么、现在能不能填",
|
||||
* 而这发生在<b>绑定身份之前</b>,所以它必然是匿名接口。
|
||||
*
|
||||
* <p>⚠️ 匿名 + scene 可穷举 = 有信息泄露面。这个接口<b>只返回落地页必须的最少信息</b>,
|
||||
* 并做了按 IP 限流。**不要往返回值里加"顺带能查出来"的东西** ——
|
||||
* 加什么就等于公开什么。详见 {@code MpSceneService} 与 {@code MpSceneMapper}。
|
||||
*
|
||||
* <p>注意它与契约里的 {@code POST /mp/login}(code + scene 换 session)<b>不是一回事</b>:
|
||||
* 那个要换 openid、属 M3 的填报主流程;这个是登录前的前置查询,只有它才能让
|
||||
* 落地页在"还没登录"时显示任务信息。
|
||||
*/
|
||||
@Tag(name = "小程序 · 扫码落地页")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/mp")
|
||||
@RequiredArgsConstructor
|
||||
public class MpSceneController {
|
||||
|
||||
private final MpSceneService mpSceneService;
|
||||
|
||||
@PublicApi
|
||||
@GetMapping("/scene-context")
|
||||
@Operation(summary = "解析扫码场景值(匿名)",
|
||||
description = "scene 形如 t{租户}[-k{任务}][-o{班级}]。"
|
||||
+ "返回租户名、任务名与状态、是否可填报,以及给学生的提示语。"
|
||||
+ "任务截止后仍可查询(码不做失效),由返回的 message 告诉学生「已截止」")
|
||||
public ApiResult<SceneContextVO> sceneContext(@RequestParam String scene,
|
||||
HttpServletRequest request) {
|
||||
return ApiResult.ok(mpSceneService.resolve(scene, clientIp(request)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取调用方 IP 用于限流。
|
||||
*
|
||||
* <p>优先取 {@code X-Forwarded-For} 的第一段:上线后请求会经过 Nginx,
|
||||
* 那时 {@code getRemoteAddr()} 是 Nginx 的地址,所有人共用一个限额 ——
|
||||
* 限流会变成"把所有人一起限掉",而不是挡住单个刷接口的人。
|
||||
*/
|
||||
private static String clientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
int comma = forwarded.indexOf(',');
|
||||
return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim();
|
||||
}
|
||||
String realIp = request.getHeader("X-Real-IP");
|
||||
if (realIp != null && !realIp.isBlank()) {
|
||||
return realIp.trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package cn.tianbaotong.fill.module.mp.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 扫码落地页的上下文(登录前查询)。
|
||||
*
|
||||
* <p><b>为什么需要它</b>:学生扫开小程序的第一屏,必须能回答三个问题 ——
|
||||
* 「这是哪个学校的收集」「是要我填什么」「现在能不能填」。
|
||||
* 这三个问题都要在<b>登录之前</b>回答(学生还没绑定身份),
|
||||
* 所以它必然是一个匿名接口。
|
||||
*
|
||||
* <p>⚠️ <b>匿名 + scene 可枚举 = 有信息泄露面</b>。scene 形如 {@code t1-k17},
|
||||
* 是可以穷举的。所以这里刻意只放<b>落地页必须的最少信息</b>:
|
||||
* <ul>
|
||||
* <li>不放学生相关的任何东西(名册人数、已交人数、截止具体时间、班主任姓名)</li>
|
||||
* <li>不放租户联系方式(与"系统内不展示班主任/管理员联系方式"是同一条铁律)</li>
|
||||
* <li>并配了限流,见 {@code MpSceneService}</li>
|
||||
* </ul>
|
||||
* 更进一步的做法是"先 login 再取任务信息"(契约 §5.x 的 {@code /mp/login} 就带 scene),
|
||||
* 那样完全不需要匿名查询 —— 但登录要先换 openid,属 M3 的填报主流程,
|
||||
* 而落地页在登录前就要显示,所以这一步的匿名查询是必要的。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "扫码落地页上下文")
|
||||
public class SceneContextVO {
|
||||
|
||||
/** scene 是否合法。不合法时 {@code message} 说明原因。 */
|
||||
private Boolean valid;
|
||||
|
||||
/** scene 原文,便于小程序端排查。 */
|
||||
private String scene;
|
||||
|
||||
/** 租户(学校)名称 —— FR-105「统一小程序 + scene 识别租户」,学生要能确认扫对了地方。 */
|
||||
private String tenantName;
|
||||
|
||||
private Long taskId;
|
||||
|
||||
private String taskName;
|
||||
|
||||
/** 任务状态:0 草稿 / 1 收集中 / 2 已截止 / 3 已归档 / 4 已暂停。 */
|
||||
private Integer taskStatus;
|
||||
|
||||
private String taskStatusText;
|
||||
|
||||
/** 当前是否可提交。落地页据此决定显不显示"开始填报"。 */
|
||||
private Boolean canSubmit;
|
||||
|
||||
/** 带班级的码才有;用于落地页显示"你属于 XX 班"。 */
|
||||
private Long orgId;
|
||||
|
||||
private String orgName;
|
||||
|
||||
/** 给学生的提示语。不能填报时这里是原因(如「本次收集已截止」)。 */
|
||||
private String message;
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package cn.tianbaotong.fill.module.mp.mapper;
|
||||
|
||||
import cn.tianbaotong.fill.module.org.entity.SysOrg;
|
||||
import cn.tianbaotong.fill.module.task.entity.CollectTask;
|
||||
import cn.tianbaotong.fill.module.task.entity.CollectTaskOrg;
|
||||
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/**
|
||||
* 扫码落地页的匿名查询({@code /mp/scene-context})。
|
||||
*
|
||||
* <p>⚠️ <b>这个接口是匿名的,而多租户拦截器在匿名请求下"默认拒绝"</b>
|
||||
* ({@code FillTenantLineHandler.getTenantId()} 直接抛 {@code 40101})。
|
||||
* 所以这里的每一条查询都必须:
|
||||
* <ol>
|
||||
* <li>加 {@code @InterceptorIgnore(tenantLine = "true")} 关掉拦截器;</li>
|
||||
* <li><b>自己显式带上 {@code tenant_id}</b> —— 关掉拦截器就没人替你把关了。</li>
|
||||
* </ol>
|
||||
* 项目注释把"必须显式加 {@code @InterceptorIgnore}"称为一次<b>代码审查点</b>,
|
||||
* 正是因为它把"租户隔离"从"框架自动做"变成了"人来保证"。
|
||||
* 把这几条集中在<b>一个文件</b>里(而不是散在各处),就是为了让这次审查有地方做:
|
||||
* 改动这个文件时,逐条确认 tenant_id 还在。
|
||||
*
|
||||
* <p>只暴露落地页需要的最小字段语义:任务名/状态、租户名、班级名。
|
||||
* <b>不要往这里加"顺带能查出来"的东西</b>(名册人数、已交人数、联系方式)——
|
||||
* scene 是可穷举的({@code t1-k1}、{@code t1-k2}…),加什么就等于公开什么。
|
||||
*/
|
||||
public interface MpSceneMapper {
|
||||
|
||||
/** 按 (tenantId, taskId) 取任务。跨租户或已删除时返回 null。 */
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
SELECT * FROM collect_task
|
||||
WHERE id = #{taskId} AND tenant_id = #{tenantId} AND deleted = 0
|
||||
""")
|
||||
CollectTask selectTask(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
|
||||
|
||||
/**
|
||||
* 取任务下的班级覆盖关系(只取生效的)。
|
||||
*
|
||||
* <p>{@code collect_task_org} <b>没有 deleted 列</b>,所以这里只能按 {@code status} 过滤,
|
||||
* 不要照抄别的表的 {@code deleted = 0}。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
SELECT * FROM collect_task_org
|
||||
WHERE task_id = #{taskId} AND org_id = #{orgId}
|
||||
AND tenant_id = #{tenantId} AND status = 1
|
||||
LIMIT 1
|
||||
""")
|
||||
CollectTaskOrg selectOrgLink(@Param("tenantId") Long tenantId,
|
||||
@Param("taskId") Long taskId,
|
||||
@Param("orgId") Long orgId);
|
||||
|
||||
/** 取班级名。 */
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
SELECT * FROM sys_org
|
||||
WHERE id = #{orgId} AND tenant_id = #{tenantId} AND deleted = 0
|
||||
""")
|
||||
SysOrg selectOrg(@Param("tenantId") Long tenantId, @Param("orgId") Long orgId);
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package cn.tianbaotong.fill.module.mp.service;
|
||||
|
||||
import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||
import cn.tianbaotong.fill.common.exception.BizException;
|
||||
import cn.tianbaotong.fill.module.mp.dto.SceneContextVO;
|
||||
import cn.tianbaotong.fill.module.mp.mapper.MpSceneMapper;
|
||||
import cn.tianbaotong.fill.module.org.entity.SysOrg;
|
||||
import cn.tianbaotong.fill.module.task.entity.CollectTask;
|
||||
import cn.tianbaotong.fill.module.task.entity.CollectTaskOrg;
|
||||
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
|
||||
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 扫码场景值的解析与落地页上下文(FR-105:统一小程序 + 扫码带参 scene 识别租户)。
|
||||
*
|
||||
* <p>scene 格式(与 M0 开通租户时约定的 {@code miniProgramScene} 同一套前缀):
|
||||
* <pre>
|
||||
* t{tenantId} 租户级码(学校大屏/公众号菜单用)
|
||||
* t{tenantId}-k{taskId} 任务级码
|
||||
* t{tenantId}-k{taskId}-o{orgId} 带班级的码,落地页能直接说出"你属于哪个班"
|
||||
* </pre>
|
||||
*
|
||||
* <p>⚠️ <b>这是匿名接口,必须限流</b>。scene 可以穷举({@code t1-k1}、{@code t1-k2}…),
|
||||
* 不限流的话,一个人用一句脚本就能把所有学校的所有任务名与状态扫一遍 ——
|
||||
* 那些信息本来是要登录才能看到的。限流不是"防黑客",是别让"需要登录才能看到的东西"
|
||||
* 变成"一条 curl 就能拉全表"。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MpSceneService {
|
||||
|
||||
private static final Pattern SCENE = Pattern.compile("^t(\\d+)(?:-k(\\d+))?(?:-o(\\d+))?$");
|
||||
|
||||
/** 同一 IP 每分钟最多查多少次。正常学生一次扫描只会调 1~2 次(含重试)。 */
|
||||
private static final int RATE_LIMIT_PER_MINUTE = 20;
|
||||
|
||||
private static final String RATE_KEY = "xt:mp:scene:rate:";
|
||||
|
||||
private final TenantMapper tenantMapper;
|
||||
private final MpSceneMapper sceneMapper;
|
||||
private final StringRedisTemplate redis;
|
||||
|
||||
/**
|
||||
* 解析 scene 并返回落地页上下文。
|
||||
*
|
||||
* @param scene 小程序码里的场景值
|
||||
* @param clientIp 调用方 IP,用于限流
|
||||
*/
|
||||
public SceneContextVO resolve(String scene, String clientIp) {
|
||||
rateLimit(clientIp);
|
||||
|
||||
SceneContextVO vo = new SceneContextVO();
|
||||
vo.setScene(scene);
|
||||
vo.setCanSubmit(false);
|
||||
|
||||
Matcher m = scene == null ? null : SCENE.matcher(scene.trim());
|
||||
if (m == null || !m.matches()) {
|
||||
vo.setValid(false);
|
||||
vo.setMessage("这个二维码不是本系统生成的,请向班主任确认");
|
||||
return vo;
|
||||
}
|
||||
|
||||
long tenantId = Long.parseLong(m.group(1));
|
||||
Long taskId = m.group(2) == null ? null : Long.parseLong(m.group(2));
|
||||
Long orgId = m.group(3) == null ? null : Long.parseLong(m.group(3));
|
||||
|
||||
// ⚠️ 下面几个查询都关掉了租户拦截器(匿名请求下拦截器是"默认拒绝"的),
|
||||
// 所以每一条都必须**显式带上 tenant_id** —— 漏一个就会跨租户读到别人的数据。
|
||||
SysTenant tenant = tenantMapper.selectById(tenantId);
|
||||
if (tenant == null) {
|
||||
vo.setValid(false);
|
||||
vo.setMessage("这个二维码对应的单位不存在或已停用");
|
||||
return vo;
|
||||
}
|
||||
vo.setValid(true);
|
||||
vo.setTenantName(tenant.getTenantName());
|
||||
|
||||
if (taskId == null) {
|
||||
vo.setMessage("请扫描班主任发的「填报二维码」");
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ⚠️ 必须用 MpSceneMapper(显式带 tenant_id + 关掉拦截器),
|
||||
// 不能用 taskMapper.selectById —— 匿名请求下拦截器会直接抛 40101
|
||||
CollectTask task = sceneMapper.selectTask(tenantId, taskId);
|
||||
if (task == null) {
|
||||
vo.setValid(false);
|
||||
vo.setMessage("这个二维码对应的任务不存在或已删除");
|
||||
return vo;
|
||||
}
|
||||
vo.setTaskId(task.getId());
|
||||
vo.setTaskName(task.getTaskName());
|
||||
vo.setTaskStatus(task.getStatus());
|
||||
vo.setTaskStatusText(statusText(task.getStatus()));
|
||||
vo.setCanSubmit(task.getStatus() != null && task.getStatus() == CollectTask.STATUS_COLLECTING);
|
||||
vo.setMessage(submitMessage(task.getStatus()));
|
||||
|
||||
if (orgId != null) {
|
||||
CollectTaskOrg link = sceneMapper.selectOrgLink(tenantId, taskId, orgId);
|
||||
if (link != null) {
|
||||
SysOrg org = sceneMapper.selectOrg(tenantId, orgId);
|
||||
vo.setOrgId(orgId);
|
||||
vo.setOrgName(org == null ? null : org.getOrgName());
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 IP 限流。
|
||||
*
|
||||
* <p>用 Redis 的 INCR + EXPIRE:第一次请求建键并设 60 秒过期,
|
||||
* 之后每次自增。超限就拒绝。不引额外的限流组件 —— 这个接口的调用量很小
|
||||
* (每个学生扫码 1~2 次),一个计数器足够。
|
||||
*/
|
||||
private void rateLimit(String clientIp) {
|
||||
if (clientIp == null || clientIp.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String key = RATE_KEY + clientIp;
|
||||
Long count = redis.opsForValue().increment(key);
|
||||
if (count != null && count == 1L) {
|
||||
redis.expire(key, Duration.ofMinutes(1));
|
||||
}
|
||||
if (count != null && count > RATE_LIMIT_PER_MINUTE) {
|
||||
throw new BizException(ErrorCode.GATE_RATE_LIMITED);
|
||||
}
|
||||
}
|
||||
|
||||
private static String statusText(Integer status) {
|
||||
if (status == null) {
|
||||
return "未知";
|
||||
}
|
||||
return switch (status) {
|
||||
case CollectTask.STATUS_DRAFT -> "尚未发布";
|
||||
case CollectTask.STATUS_COLLECTING -> "收集中";
|
||||
case CollectTask.STATUS_CLOSED -> "已截止";
|
||||
case CollectTask.STATUS_ARCHIVED -> "已结束";
|
||||
case CollectTask.STATUS_PAUSED -> "已暂停";
|
||||
default -> "未知";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 不能填报时给学生的提示。
|
||||
*
|
||||
* <p>§7.12 写明:「任务截止后扫码进入提示"已截止",<b>不做失效</b>」——
|
||||
* 所以码永远有效,靠这句话告诉学生现在的状态。
|
||||
* 五句话分开写而不是合并成"当前不可填报",是因为学生的下一步动作完全不同:
|
||||
* "已截止"要找班主任,"已暂停"要等通知,"尚未发布"是根本不该现在扫。
|
||||
*/
|
||||
private static String submitMessage(Integer status) {
|
||||
if (status == null) {
|
||||
return "任务状态异常,请联系班主任";
|
||||
}
|
||||
return switch (status) {
|
||||
case CollectTask.STATUS_COLLECTING -> "请核对信息后开始填报";
|
||||
case CollectTask.STATUS_DRAFT -> "本次收集尚未开始,请留意班主任通知";
|
||||
case CollectTask.STATUS_PAUSED -> "本次收集暂时暂停,请等待恢复通知";
|
||||
case CollectTask.STATUS_CLOSED -> "本次收集已截止,如需补交请联系班主任";
|
||||
case CollectTask.STATUS_ARCHIVED -> "本次收集已结束,材料已归档";
|
||||
default -> "当前不可填报";
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user