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:
2026-09-29 10:29:14 +08:00
parent eb352e3537
commit 0bc7ee02dc
24 changed files with 10011 additions and 1 deletions
@@ -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;
}
@@ -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);
}
@@ -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 -> "当前不可填报";
};
}
}