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 @@ + + + + + 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 zcmaJ^c|25Y`#+XyC`+5OUafkYqmlSEl)+V zC53EJB$S8m@9Vz4*Y&-Yb3W(3Y;(d~fM1#)0003Cvn<7K1}HtM`$d{YenwQ;C^-S(Bw!dKGPRQ{5d$=<+Bb^=&62=9 zyT3g7ffNAnXPh^N0JjBz*>4v5+kn2(URc+5KlGCVF`&OikMw zfqqB8XK2+;V}LL3B>(G>)mVo1y5YXue4A!H*}eQbcg`t##g9HFply&`y$2%Ui`qzhj;o^=JbnXrW48s;xu1fDr z0))La)fp=QkX*N#V0eTJXiqO11AyvJlBY^iBrIQo0Kg>g;^BKnJ9a%2Wz`F2Ka;Jl zm*B>3H!<9`zg|z+c>6eWFMqydnvs-!J))2I(LEmNyxo~2!VjOpv<0SyMNVCup-60Z zm&|RDtd8R2HEIU!!OA0Ic6-G4K{`MZ8S%UjEL!s#vj{vLBWeqI(M&DkE;aT|aziV8 zRiTRN#GNwykvPx{R==`-rP>^pa`AyJ&s**Q!zU$j(pO&Q(YolGLT=2o0>3Wlhx?Gs z#|6b*$3F$ofzT`QIA#}2(Cg}Z?5V5KrtX)WrInh*aTCsP#{@V|*7<0lm`r^xmJQm^ z9n0J^3p#yCxWPX>G11)F(iv5vIIHkbqzdH37jX&JZ~&5AV*OAtL}axw*aLAt(b-!Vf)wRw=S8((e`~WLqlDBobRbj)NXB zS>W`fibSDA>uYN*&&Ml75iep!E%^%eV~SElj=}K;6TCNXs2gYG-L`En&3y~H9fP=W z(t?;5Xalv2F5ROUkg3?7C5~z>QYq|tok{Q}toT5u=~a9mBKDc4zfSM=`?OF-lS(V+pE1(m&x$HE_9vj;Cy)b@OiPMS0bs1 zRL9h?)T!I{4m1aY9>(pR_IDhF?wocEy=CU`m(5ry-&^rJJ*Bb^PfNARJ1{|*1e;FV zGljKhHo|}41Rg|1n&m~I3+-_gFQww-#b2u97o3fIsg67|%6`|aJX{~F&RPa;TayWd zp0l(=(QbROypp_fCeOBW3BJ5PJg@UU`&fs3hd{?U6&@7>mHWNEWnN`rWk>r%`fK|= z=BRVxb2I(y07{Nwj&jZtf{0iN;H%QAvaO1&8VKn8tp5f#! zN#ZlRm)#|IR8144l_=#8)5guWCE`B$T_;p_&0iWR+1=_>mDK1{*kw_8pi=2ewD%Z1 zSVG^6Mc(Vd()@@Y^wYz75Yz{X8jD_x*B)w5@yqn8>U#Kw-qzNvJjm)}wamur^knR_o)EvaGVkz%1gB=%{GIq3%OVcBFpT?D{PKZ079tIh|$fvf?svxl^`nuZV1~ zE?xILl^)O*=ufGhDH_pyUfNjteA>xd#yg*uvj~^Cbv&_EBt0-)!j4#crI>Uhq&0Oy z`b$;!qc=;1Sx>VD%ia^;erQ9!2)(mrrJ5zv;`SWLHu^Td;yik`Z7ioatGHn?aSD1m z@U+Y6wVHj_e`PD>_Noz^2O3?6Yg*5_BlMB@A05*?`Y-jlZ-m^4uDw+Y8A8@7g!P7H zgzZ?*UDN&1x{>g`ZiMkweBs14cdln#6I?YHr7!-)nyY$73 zckv0h$WfEY^%7rYR&g4G-pZL>Vy{3sVkc#OsI@6s?(5whAJqvO5)LEZTD6>Rdkl&h zHusOIlp{!GNUVm69y+XkTlKT;Lp%Ce`igQdYushcyC!}iq4eq#-2van)Ie{RuRq2g zH=9+-th`-$F*y3W=|Z{)eb0Wrxy$2?eT~S=V>Iq5|4fbS@l5+PI<90O)5aZFv- z{-7I*`r#90Z5HrSgU=dsgpnk5?TNyom7_`TM^@+iv+q@OQnFLB3o!zOw1-FDsZ|`T zu=YA~Bw1jbF-d$SlN|kOWn5vEwm2Z>A8FZD_z+WWBPebOEjbeGD(MZ=TPSr~@YnLZU)h_#alQiZu;syu@U^WCAXKCKVZHf%!^8wGMR7*MP@UWP13nuk#~M$mU% z$uszs);TA=a{4!`8Qm`Sn+rdD>w9SLzQ0p-yTPboznqn+ASr#=Td7#J^gVESP9li^ zi{+qONJ8-4_1gZ8&pUnyeZKH;^FF?wIQ-qc-o5j=ix69oFFJQK<>#B|k#6%g^Bx5= zg}8(qIXM{t>6)*e9mylb4~qA6z6x{v$(W(tnHt&{T|3_Cyxupzb2YZJuAEW2NM+wC zy^Cm4Xp*b$U?3N6t(SESgt9ByRYOfRav2BL4L5BTyMExBieFo==ue&BT!*e)T3lo5 zDDLL`TT0PQo#}RDFM1G`iU*85$sTyH1rh6w$KbJ^jI%9xJpkZ2Ot5#RJ6l;IaAcw? zc1uS!m`LHE0YJ|nn1aRm;pt!xyf=Y_gs`91LBIr0B*Y1BrDjDz;e80`5Gvj-jfh?28eh%7933UC(#hWNXRd{2+nv*426JysnGq9kiSVeTiJk7WGWsE zSJhI%!8FvtM|D(Ta2<7RO=YmU8cYkSrU`}VsK7K3oKsT`{QH1#yiq;95Ev7)-@Z6A zB*ceKry!uvpr9btAPrSA)tiIW(SfR|L)Fz)I2tN628oUhRw2<8{#Y=<({NM*g-#%o zz*`ov9^?Qz62f8ncL+p^mDN9nNwnXI;-m~3jHN(fs%lUoaVxH0+B7-_|6dyas!g+J zQ1DO;o<-jJ7|Hhj9zgQ@T40Nl&|EJ)8M4T?#8vfJ1oXI~g0G`C@dMc;A zjqo=rI2*RN7A8ja!Tlbd0QX!*+E1x@K*^ZD{)%J_pe^QRp=+j?jCO1cZN?ryPlN&29$7&Ac>xMM*DwQ*NxtIV%NlmI`lJr2JVZ!|SUM)s{m5-r-hrCim zGEunpTX?76P{|0K32-Ym!wnJFjcNAROWZ-AL8+J1F_-(QHNzMCON{8s2|iO0D*vNr zQhflINtwvCi<$Z|n(_I*HbSmD?h6-!bQZ5=hQ8L&m)|I~)%u)gyCW_QRg`w5P~OC1 z%uCbu%`2nB5zR=>{took!+yKEDi`b>pzAf)^KDGtUM8R*t#G@mH2=PKe4(Ipz-y*c zc~Kzl;GA)s+53_RGg-}F1`$4QjX29!BLu$pn{&KmMu86HO}Y2@q{Jb7v=N}{+PQWx zHF2LIb9qiO+DI~r+eb9ubK7oh6KFdUL6e;9wKv_RvXh$HuqHw)inh2kQGM>}%G4V% zmjkEYsw}?{m%gW>#P7wTXwk}cZO--qydYul`!3w~l(JgX@=yG7|6z{6kO^>c^P;zI zAmO}-iEA~6%U7@PbJN4EXW!v;|5owjl2$w4ZZqafWPCshmRxS}7Zwlg(*rDz;hg}s 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)