From 0bc7ee02dcb74c6432def4a82d8b74e2a7c8d89b Mon Sep 17 00:00:00 2001
From: lovife <474239041@qq.com>
Date: Tue, 29 Sep 2026 10:29:14 +0800
Subject: [PATCH] =?UTF-8?q?feat(mp):=20=E5=B0=8F=E7=A8=8B=E5=BA=8F?=
=?UTF-8?q?=E7=AB=AF=E9=AA=A8=E6=9E=B6=20+=20=E6=89=AB=E7=A0=81=E8=90=BD?=
=?UTF-8?q?=E5=9C=B0=E9=A1=B5=EF=BC=88scene=20=E8=AF=86=E5=88=AB=E7=A7=9F?=
=?UTF-8?q?=E6=88=B7=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
小程序端从零建起来了,并在微信开发者工具里真机模拟跑通:
扫 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。
·
diff --git a/frontend-mp/src/config/index.ts b/frontend-mp/src/config/index.ts
new file mode 100644
index 0000000..77561eb
--- /dev/null
+++ b/frontend-mp/src/config/index.ts
@@ -0,0 +1,32 @@
+/**
+ * 小程序端配置。
+ *
+ * ⚠️ API 地址在三种场景下不一样,写死会导致"开发者工具里好好的,真机打不开":
+ *
+ * 1. 微信开发者工具:可以用 http://localhost:8080,
+ * 但必须在「详情 → 本地设置」里勾上 **「不校验合法域名、web-view、TLS 版本以及 HTTPS 证书」**。
+ * 不勾的话请求会被拦,报错是「不在以下 request 合法域名列表中」——
+ * 而那句话看起来像"域名没配",会让人跑去小程序后台白改一通。
+ *
+ * 2. 真机预览/体验版:localhost 是**手机自己**,不是你的电脑。
+ * 要把地址换成电脑的局域网 IP(如 http://192.168.1.5:8080),
+ * 并确保手机与电脑在同一网络、电脑防火墙放行 8080。
+ *
+ * 3. 正式发布:必须是 **HTTPS + 已在微信后台配好 request 合法域名**(见待办:域名白名单),
+ * 且域名不能带端口、不能是 IP。
+ *
+ * 所以这里留成一处配置,而不是散在请求代码里。
+ */
+export const API_BASE_URL = 'http://localhost:8080'
+
+/** 主题色(与 PC 管理端同一套腾讯云 TDesign 色板,两端视觉要一致)。 */
+export const THEME = {
+ primary: '#0052D9',
+ success: '#00A870',
+ warning: '#ED7B2F',
+ danger: '#E34D59',
+ pageBg: '#F3F3F3',
+ textPrimary: '#000000E6',
+ textSecondary: '#00000099',
+ textPlaceholder: '#0000004D'
+} as const
diff --git a/frontend-mp/src/env.d.ts b/frontend-mp/src/env.d.ts
new file mode 100644
index 0000000..d27eb5a
--- /dev/null
+++ b/frontend-mp/src/env.d.ts
@@ -0,0 +1,8 @@
+///
+
+declare module '*.vue' {
+ import { DefineComponent } from 'vue'
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
+ const component: DefineComponent<{}, {}, any>
+ export default component
+}
diff --git a/frontend-mp/src/main.ts b/frontend-mp/src/main.ts
new file mode 100644
index 0000000..b12c1ee
--- /dev/null
+++ b/frontend-mp/src/main.ts
@@ -0,0 +1,8 @@
+import { createSSRApp } from "vue";
+import App from "./App.vue";
+export function createApp() {
+ const app = createSSRApp(App);
+ return {
+ app,
+ };
+}
diff --git a/frontend-mp/src/manifest.json b/frontend-mp/src/manifest.json
new file mode 100644
index 0000000..b6c2ca6
--- /dev/null
+++ b/frontend-mp/src/manifest.json
@@ -0,0 +1,72 @@
+{
+ "name" : "填报通",
+ "appid" : "",
+ "description" : "",
+ "versionName" : "1.0.0",
+ "versionCode" : "100",
+ "transformPx" : false,
+ /* 5+App特有相关 */
+ "app-plus" : {
+ "usingComponents" : true,
+ "nvueStyleCompiler" : "uni-app",
+ "compilerVersion" : 3,
+ "splashscreen" : {
+ "alwaysShowBeforeRender" : true,
+ "waiting" : true,
+ "autoclose" : true,
+ "delay" : 0
+ },
+ /* 模块配置 */
+ "modules" : {},
+ /* 应用发布信息 */
+ "distribute" : {
+ /* android打包配置 */
+ "android" : {
+ "permissions" : [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ""
+ ]
+ },
+ /* ios打包配置 */
+ "ios" : {},
+ /* SDK配置 */
+ "sdkConfigs" : {}
+ }
+ },
+ /* 快应用特有相关 */
+ "quickapp" : {},
+ /* 小程序特有相关 */
+ "mp-weixin" : {
+ "appid" : "wxf01d3fa60f87f0b7",
+ "setting" : {
+ "urlCheck" : false
+ },
+ "usingComponents" : true
+ },
+ "mp-alipay" : {
+ "usingComponents" : true
+ },
+ "mp-baidu" : {
+ "usingComponents" : true
+ },
+ "mp-toutiao" : {
+ "usingComponents" : true
+ },
+ "uniStatistics": {
+ "enable": false
+ },
+ "vueVersion" : "3"
+}
diff --git a/frontend-mp/src/pages.json b/frontend-mp/src/pages.json
new file mode 100644
index 0000000..e5f5701
--- /dev/null
+++ b/frontend-mp/src/pages.json
@@ -0,0 +1,19 @@
+{
+ "pages": [
+ {
+ "path": "pages/index/index",
+ "style": {
+ "navigationBarTitleText": "材料填报",
+ "navigationBarBackgroundColor": "#0052D9",
+ "navigationBarTextStyle": "white",
+ "enablePullDownRefresh": true
+ }
+ }
+ ],
+ "globalStyle": {
+ "navigationBarTextStyle": "white",
+ "navigationBarTitleText": "填报通",
+ "navigationBarBackgroundColor": "#0052D9",
+ "backgroundColor": "#F3F3F3"
+ }
+}
diff --git a/frontend-mp/src/pages/index/index.vue b/frontend-mp/src/pages/index/index.vue
new file mode 100644
index 0000000..16ec0f9
--- /dev/null
+++ b/frontend-mp/src/pages/index/index.vue
@@ -0,0 +1,309 @@
+
+
+
+
+ {{ ctx.tenantName || '填报通' }}
+ {{ ctx.taskName || title }}
+
+ {{ ctx.taskStatusText }}
+
+
+
+
+
+ {{ message }}
+
+
+
+
+ 班级
+ {{ ctx.orgName }}
+
+
+
+
+ 正在读取…
+
+
+
+ scene:{{ scene || '(未获取到)' }}
+
+ 原始值:{{ rawScene }}
+
+
+
+ 职业技能等级认定材料收集中台
+
+
+
+
+
+
diff --git a/frontend-mp/src/shime-uni.d.ts b/frontend-mp/src/shime-uni.d.ts
new file mode 100644
index 0000000..5ecbb26
--- /dev/null
+++ b/frontend-mp/src/shime-uni.d.ts
@@ -0,0 +1,6 @@
+export {}
+
+declare module "vue" {
+ type Hooks = App.AppInstance & Page.PageInstance;
+ interface ComponentCustomOptions extends Hooks {}
+}
\ No newline at end of file
diff --git a/frontend-mp/src/static/logo.png b/frontend-mp/src/static/logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..b5771e209bb677e2ebd5ff766ad5ee11790f305a
GIT binary patch
literal 4023
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
SYs}WS&%*SCNx89m_ {
+ code: number
+ msg: string
+ data: T
+}
+
+export function request(options: {
+ url: string
+ method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
+ data?: Record
+ /** 已登录时带上(登录流程见契约 §5.x 的 /mp/login,属 M3) */
+ token?: string
+}): Promise {
+ const { url, method = 'GET', data, token } = options
+ return new Promise((resolve, reject) => {
+ uni.request({
+ url: API_BASE_URL + url,
+ method,
+ data,
+ header: {
+ 'Content-Type': 'application/json; charset=utf-8',
+ ...(token ? { Authorization: `Bearer ${token}` } : {})
+ },
+ success: (res) => {
+ const body = res.data as ApiResult | undefined
+ if (!body || typeof body.code !== 'number') {
+ reject(new Error(`响应格式不对(HTTP ${res.statusCode})`))
+ return
+ }
+ if (body.code !== 0) {
+ // 把后端的 msg 原样带出去:这些 message 是刻意写成人话的
+ // (例如"本次收集已截止,如需补交请联系班主任"),
+ // 前端再包一层"请求失败"只会把有用信息盖掉
+ reject(new Error(body.msg || `请求失败(code=${body.code})`))
+ return
+ }
+ resolve(body.data)
+ },
+ fail: (err) => {
+ // 最常见的失败是域名未放行 / 真机访问了 localhost,
+ // 这两种都不是"网络不好",所以提示要指向配置见 src/config/index.ts
+ reject(new Error(`网络请求失败:${err.errMsg || '未知原因'}(请检查 API 地址配置)`))
+ }
+ })
+ })
+}
diff --git a/frontend-mp/tsconfig.json b/frontend-mp/tsconfig.json
new file mode 100644
index 0000000..11fffd2
--- /dev/null
+++ b/frontend-mp/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "@vue/tsconfig/tsconfig.json",
+ "compilerOptions": {
+ "sourceMap": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+ "lib": ["esnext", "dom"],
+ "types": ["@dcloudio/types"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
+}
diff --git a/frontend-mp/vite.config.ts b/frontend-mp/vite.config.ts
new file mode 100644
index 0000000..46e36fe
--- /dev/null
+++ b/frontend-mp/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vite";
+import uni from "@dcloudio/vite-plugin-uni";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [uni()],
+});
diff --git a/scripts/e2e-m1-scene.py b/scripts/e2e-m1-scene.py
new file mode 100644
index 0000000..ddd78a1
--- /dev/null
+++ b/scripts/e2e-m1-scene.py
@@ -0,0 +1,385 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+扫码落地页(GET /api/v1/mp/scene-context)验收测试。
+
+覆盖 FR-105(统一小程序 + 扫码带参 scene 识别租户):
+ [x] 正常 scene:返回租户名、任务名、状态、能否填报、给学生的话
+ [x] 非法 scene / 不存在的租户 / 不存在的任务:各自给出**不同的**提示
+ [x] 租户级 scene(不带任务):提示去扫任务码
+ [x] 带班级:班级名只在"该班确实在任务覆盖范围里"时才返回
+ [x] 跨租户:A 租户的 scene 拼上 B 租户的任务 id → 必须当作"不存在"
+ [x] 状态文案随任务状态变化(收集中/已暂停/已截止)
+ [x] **限流**:scene 可穷举,必须能挡住批量扫描
+ [x] 这个接口**匿名**就能访问(学生登录前就要用)
+
+为什么这些值得逐条断言:
+ 这是学生扫码后的第一屏,而且它是**匿名**接口、scene 又可穷举(t1-k1、t1-k2…)。
+ 一旦哪个分支写错,后果不是"报错",而是"把不该给的信息给出去了",
+ 或者"扫对了码却告诉他这个码不是本系统的" —— 后者会让学生直接放弃。
+
+用法
+ python scripts/e2e-m1-scene.py
+
+依赖:cryptography;且需要能 `docker exec xt-pg` / `xt-redis`
+退出码:0 = 全部通过
+"""
+from __future__ import annotations
+
+import argparse
+import base64
+import json
+import os
+import subprocess
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+import uuid
+
+if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(encoding="utf-8")
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
+
+PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
+PG_USER = os.environ.get("PG_USER", "fill")
+PG_DB = os.environ.get("PG_DB", "filldb")
+PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
+REDIS_CONTAINER = os.environ.get("XT_REDIS_CONTAINER", "xt-redis")
+REDIS_PWD = os.environ.get("REDIS_PASSWORD", "redis_dev_pwd")
+
+PASSED: list[str] = []
+FAILED: list[tuple[str, str]] = []
+
+CREATED_TASK_IDS: list[int] = []
+CREATED_TENANT_IDS: list[int] = []
+
+
+def ok(name: str) -> None:
+ PASSED.append(name)
+ print(f" [OK] {name}")
+
+
+def fail(name: str, detail: str) -> None:
+ FAILED.append((name, detail))
+ print(f" [FAIL] {name}\n {detail}")
+
+
+def check(name: str, cond: bool, detail: str = "") -> bool:
+ if cond:
+ ok(name)
+ else:
+ fail(name, detail or "断言不成立")
+ return cond
+
+
+def sql(stmt: str) -> tuple[bool, str]:
+ try:
+ r = subprocess.run(
+ ["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
+ "psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
+ capture_output=True, text=True, timeout=40)
+ return r.returncode == 0, (r.stdout or r.stderr).strip()
+ except Exception as e:
+ return False, str(e)
+
+
+def redis(args: list[str]) -> tuple[bool, str]:
+ try:
+ r = subprocess.run(
+ ["docker", "exec", "-e", f"REDISCLI_AUTH={REDIS_PWD}", REDIS_CONTAINER,
+ "redis-cli"] + args, capture_output=True, text=True, timeout=30)
+ return r.returncode == 0, (r.stdout or r.stderr).strip()
+ except Exception as e:
+ return False, str(e)
+
+
+def cleanup_all() -> None:
+ if CREATED_TASK_IDS:
+ ids = ",".join(str(i) for i in CREATED_TASK_IDS)
+ for stmt in [
+ f"DELETE FROM collect_task_org WHERE task_id IN ({ids})",
+ f"DELETE FROM collect_material_def WHERE task_id IN ({ids})",
+ f"DELETE FROM collect_roster WHERE task_id IN ({ids})",
+ f"DELETE FROM collect_task WHERE id IN ({ids})",
+ ]:
+ sql(stmt)
+ for tid in CREATED_TENANT_IDS:
+ for stmt in [
+ f"DELETE FROM sys_user_role WHERE user_id IN (SELECT id FROM sys_user WHERE tenant_id = {tid})",
+ f"DELETE FROM sys_user WHERE tenant_id = {tid}",
+ f"DELETE FROM sys_org WHERE tenant_id = {tid}",
+ f"DELETE FROM sys_role WHERE tenant_id = {tid}",
+ f"DELETE FROM collect_task WHERE tenant_id = {tid}",
+ f"DELETE FROM sys_tenant WHERE id = {tid}",
+ ]:
+ sql(stmt)
+ redis(["DEL", "xt:mp:scene:rate:127.0.0.1"])
+ redis(["DEL", "xt:mp:scene:rate:localhost"])
+
+
+def rsa_encrypt(plain: str) -> str:
+ from cryptography.hazmat.primitives import serialization
+ from cryptography.hazmat.primitives.asymmetric import padding
+ with open(PUB_KEY, "rb") as f:
+ pub = serialization.load_pem_public_key(f.read())
+ return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
+
+
+def call(method: str, url: str, token: str | None = None,
+ body: dict | None = None, timeout: int = 30) -> tuple[int, dict]:
+ headers = {"Content-Type": "application/json; charset=utf-8"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ return resp.status, json.loads(resp.read().decode("utf-8"))
+ except urllib.error.HTTPError as e:
+ raw = e.read().decode("utf-8")
+ try:
+ return e.code, json.loads(raw)
+ except json.JSONDecodeError:
+ return e.code, {"raw": raw}
+
+
+def login(base: str, username: str, password: str, tenant: str | None = None):
+ payload = {"username": username, "password": rsa_encrypt(password)}
+ if tenant:
+ payload["tenantCode"] = tenant
+ return call("POST", f"{base}/api/v1/auth/login", body=payload)
+
+
+def scene(base: str, value: str, token: str | None = None) -> dict:
+ """调 scene-context,返回 data。"""
+ url = f"{base}/api/v1/mp/scene-context?scene={urllib.parse.quote(value)}"
+ _, body = call("GET", url, token)
+ return body.get("data") or {}
+
+
+def scene_raw(base: str, value: str) -> tuple[int, dict]:
+ url = f"{base}/api/v1/mp/scene-context?scene={urllib.parse.quote(value)}"
+ return call("GET", url)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="扫码落地页 E2E")
+ ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
+ ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
+ ap.add_argument("--password", default="Admin@123")
+ args = ap.parse_args()
+ base = args.base.rstrip("/")
+
+ print("=" * 72)
+ print(f"扫码落地页 E2E base={base}")
+ print(" (本接口是匿名的 —— 学生登录前就要用;测试也刻意不带 token)")
+ print("=" * 72)
+
+ suffix = uuid.uuid4().hex[:6]
+ redis(["DEL", "xt:mp:scene:rate:127.0.0.1"])
+
+ # ── 0. 准备(用管理员造数据,但 scene 查询都不带 token)────
+ print("\n[0] 准备")
+ status, body = login(base, "admin", args.password, args.tenant)
+ if body.get("code") != 0:
+ fail("管理员登录", f"code={body.get('code')} msg={body.get('msg')}")
+ return 1
+ token = body["data"]["token"]
+ tenant_id = (body["data"].get("tenant") or {}).get("id")
+ ok(f"管理员登录(租户 id={tenant_id})")
+
+ # 本次要用的任务
+ status, body = call("POST", f"{base}/api/v1/tasks", token, {
+ "taskName": f"E2E落地页 {suffix}", "collectMode": 1, "batchNo": "2005",
+ "majorName": "数控加工", "certType": 1,
+ "startTime": "2026-09-28", "endTime": "2026-12-31",
+ })
+ if body.get("code") != 0:
+ fail("建任务", f"code={body.get('code')} msg={body.get('msg')}")
+ return 1
+ task_id = body["data"]["id"]
+ CREATED_TASK_IDS.append(task_id)
+ ok(f"建任务(id={task_id},草稿状态)")
+
+ status, body = call("GET", f"{base}/api/v1/orgs/tree", token)
+ classes: list[dict] = []
+
+ def walk(nodes):
+ for n in nodes or []:
+ if n.get("orgType") == 3:
+ classes.append(n)
+ walk(n.get("children"))
+
+ walk(body.get("data"))
+ if not classes:
+ fail("取班级", "demo 租户里没有班级")
+ return 1
+
+ # ── 1. 正常 scene(匿名)─────────────────────────────
+ print("\n[1] 正常 scene(匿名,不带 token)")
+ d = scene(base, f"t{tenant_id}-k{task_id}")
+ check("返回租户名(学生要能确认扫对了学校)", bool(d.get("tenantName")),
+ f"tenantName={d.get('tenantName')}")
+ check("返回任务名", d.get("taskName") == f"E2E落地页 {suffix}", f"taskName={d.get('taskName')}")
+ check("草稿状态 → 尚未发布", d.get("taskStatusText") == "尚未发布", f"{d.get('taskStatusText')}")
+ check("草稿不可填报", d.get("canSubmit") is False, f"canSubmit={d.get('canSubmit')}")
+ check("提示语是「尚未开始」(不是笼统的不可填报)",
+ "尚未开始" in (d.get("message") or ""), f"message={d.get('message')}")
+ check("valid 为 true", d.get("valid") is True, f"valid={d.get('valid')}")
+
+ # ── 2. 各种非法输入要给不同的提示 ─────────────────────
+ print("\n[2] 非法输入:提示要各不相同(否则学生不知道该找谁)")
+ d_garbage = scene(base, "garbage")
+ d_no_tenant = scene(base, "t99999999-k1")
+ d_no_task = scene(base, f"t{tenant_id}-k99999999")
+ check("乱码 → 不是本系统的二维码", "不是本系统生成" in (d_garbage.get("message") or ""),
+ f"{d_garbage.get('message')}")
+ check("不存在的租户 → 单位不存在", "单位不存在" in (d_no_tenant.get("message") or ""),
+ f"{d_no_tenant.get('message')}")
+ check("不存在的任务 → 任务不存在", "任务不存在" in (d_no_task.get("message") or ""),
+ f"{d_no_task.get('message')}")
+ msgs = {d_garbage.get("message"), d_no_tenant.get("message"), d_no_task.get("message")}
+ check("三种失败的提示互不相同(不是同一句话敷衍)", len(msgs) == 3, f"{msgs}")
+
+ # ── 3. 租户级 scene ──────────────────────────────────
+ print("\n[3] 租户级 scene(M0 开通租户时约定的 t{tenantId})")
+ d_tenant = scene(base, f"t{tenant_id}")
+ check("租户级码有效但提示去扫任务码",
+ d_tenant.get("valid") is True and "填报二维码" in (d_tenant.get("message") or ""),
+ f"valid={d_tenant.get('valid')} message={d_tenant.get('message')}")
+ check("租户级码没有任务信息", d_tenant.get("taskId") is None, f"{d_tenant.get('taskId')}")
+
+ # ── 4. 带班级 ────────────────────────────────────────
+ print("\n[4] 带班级的 scene")
+ d_not_covered = scene(base, f"t{tenant_id}-k{task_id}-o{classes[0]['id']}")
+ check("班级不在覆盖范围时不返回 orgName(不能凭空显示一个班)",
+ d_not_covered.get("orgName") is None, f"orgName={d_not_covered.get('orgName')}")
+
+ call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token,
+ {"orgs": [{"orgId": classes[0]["id"]}]})
+ d_covered = scene(base, f"t{tenant_id}-k{task_id}-o{classes[0]['id']}")
+ check("班级在覆盖范围时返回班级名",
+ d_covered.get("orgName") == classes[0]["orgName"],
+ f"orgName={d_covered.get('orgName')} 期望={classes[0]['orgName']}")
+ check("带班不影响任务信息", d_covered.get("taskId") == task_id, f"{d_covered.get('taskId')}")
+
+ # ── 5. 跨租户 ────────────────────────────────────────
+ print("\n[5] 跨租户:A 租户的 scene 拼 B 租户的任务 id")
+ status, plat = login(base, "platform", "Platform@123")
+ other_task_id = None
+ if plat.get("code") == 0:
+ plat_token = plat["data"]["token"]
+ other_code = f"scn{suffix}"
+ status, body = call("POST", f"{base}/api/v1/tenants", plat_token, {
+ "tenantName": f"E2E扫码隔离 {suffix}", "tenantCode": other_code, "tenantType": 1,
+ "contactName": "测试", "contactPhone": "13900139601", "expireTime": "2027-12-31",
+ "adminUsername": "admin", "adminRealName": "隔离管理员", "adminPhone": "13900139602",
+ })
+ if body.get("code") == 0:
+ other_tenant_id = body["data"]["id"]
+ other_pwd = body["data"]["adminInitialPassword"]
+ CREATED_TENANT_IDS.append(other_tenant_id)
+ login(base, "admin", other_pwd, other_code)
+ _, ob = login(base, "admin", other_pwd, other_code)
+ if ob.get("code") == 0:
+ ot = ob["data"]["token"]
+ _, tb = call("POST", f"{base}/api/v1/tasks", ot, {
+ "taskName": f"别校的任务 {suffix}", "collectMode": 1, "batchNo": "2011",
+ "majorName": "x", "certType": 1,
+ "startTime": "2026-09-28", "endTime": "2026-12-31",
+ })
+ if tb.get("code") == 0:
+ other_task_id = tb["data"]["id"]
+ sql(f"DELETE FROM collect_task WHERE id = {other_task_id}") # 用 SQL 收尾更省事
+ if other_task_id:
+ d_cross = scene(base, f"t{tenant_id}-k{other_task_id}")
+ check("用 A 租户的租户号 + B 租户的任务 id → 任务不存在(不跨租户泄露)",
+ "任务不存在" in (d_cross.get("message") or ""), f"message={d_cross.get('message')}")
+ d_cross2 = scene(base, f"t{CREATED_TENANT_IDS[0]}-k{task_id}")
+ check("反过来(B 的租户号 + A 的任务 id)也不泄露",
+ "任务不存在" in (d_cross2.get("message") or ""), f"message={d_cross2.get('message')}")
+ else:
+ fail("跨租户用例", "第二个租户没造出来,这条隔离断言没执行")
+
+ # ── 6. 状态文案随状态变化 ────────────────────────────
+ print("\n[6] 状态文案:五种状态给五句不同的话")
+ sql(f"INSERT INTO collect_roster (tenant_id, task_id, real_name, id_card_no, id_card_hash) "
+ f"VALUES ({tenant_id}, {task_id}, 'E2E学生A', 'seed', 'seed')")
+ sql(f"INSERT INTO collect_material_def (tenant_id, task_id, material_code, material_name, required) "
+ f"VALUES ({tenant_id}, {task_id}, 'IDCARD', '身份证', 1)")
+
+ call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
+ d_collecting = scene(base, f"t{tenant_id}-k{task_id}")
+ check("收集中 → 可填报", d_collecting.get("canSubmit") is True,
+ f"canSubmit={d_collecting.get('canSubmit')} status={d_collecting.get('taskStatusText')}")
+ check("收集中 → 提示「开始填报」",
+ "开始填报" in (d_collecting.get("message") or ""), f"{d_collecting.get('message')}")
+
+ call("POST", f"{base}/api/v1/tasks/{task_id}/pause", token, {})
+ d_paused = scene(base, f"t{tenant_id}-k{task_id}")
+ check("已暂停 → 不可填报", d_paused.get("canSubmit") is False, f"{d_paused.get('canSubmit')}")
+ check("已暂停 → 提示等恢复通知(不是「已截止」,学生的下一步动作完全不同)",
+ "暂停" in (d_paused.get("message") or ""), f"{d_paused.get('message')}")
+
+ call("POST", f"{base}/api/v1/tasks/{task_id}/resume", token)
+ call("POST", f"{base}/api/v1/tasks/{task_id}/close", token, {})
+ d_closed = scene(base, f"t{tenant_id}-k{task_id}")
+ check("已截止 → 不可填报", d_closed.get("canSubmit") is False, f"{d_closed.get('canSubmit')}")
+ check("已截止 → 提示联系班主任补交(§7.12:码不做失效,靠这句话说明状态)",
+ "截止" in (d_closed.get("message") or "") and "班主任" in (d_closed.get("message") or ""),
+ f"{d_closed.get('message')}")
+ check("截止后仍能查到任务(码永久有效)", d_closed.get("taskName") == f"E2E落地页 {suffix}",
+ f"{d_closed.get('taskName')}")
+
+ # ── 7. 限流 ──────────────────────────────────────────
+ print("\n[7] 限流:scene 可穷举,必须挡得住批量扫描")
+ rate_key = None
+ okk, out = redis(["--scan", "--pattern", "xt:mp:scene:rate:*"])
+ if okk and out.strip():
+ rate_key = out.strip().splitlines()[0]
+ if rate_key:
+ redis(["DEL", rate_key])
+ codes = []
+ for i in range(25):
+ st, b = scene_raw(base, f"t{tenant_id}-k{task_id}")
+ codes.append((st, b.get("code")))
+ # 前 20 次应成功(code=0),第 21 次起应被限流
+ passed_n = sum(1 for st, c in codes if c == 0)
+ limited_n = sum(1 for st, c in codes if c == 40071)
+ check("前 20 次正常通过", passed_n == 20, f"通过 {passed_n} 次")
+ check("第 21 次起被限流(40071)", limited_n == 5, f"被限 {limited_n} 次")
+ check("限流用的是项目的 GATE_RATE_LIMITED(40071),不是自制错误码",
+ any(c == 40071 for _, c in codes), f"{set(c for _, c in codes)}")
+ redis(["DEL", rate_key])
+ _, after = scene_raw(base, f"t{tenant_id}-k{task_id}")
+ check("清掉计数器后立刻恢复(说明是限流不是封禁)", after.get("code") == 0,
+ f"code={after.get('code')}")
+ else:
+ fail("限流用例", "没找到限流键,说明限流可能没生效")
+
+ # ── 8. 清理 ──────────────────────────────────────────
+ print("\n[8] 清理测试数据")
+ cleanup_all()
+ ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_TENANT_IDS)} 个测试租户")
+
+ print("\n" + "=" * 72)
+ print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
+ if FAILED:
+ print("\n失败项:")
+ for name, detail in FAILED:
+ print(f" · {name}\n {detail}")
+ print("=" * 72)
+ return 1 if FAILED else 0
+
+
+if __name__ == "__main__":
+ try:
+ code = main()
+ except BaseException:
+ print("\n ⚠ 脚本异常退出,正在清理…")
+ cleanup_all()
+ raise
+ raise SystemExit(code)