Compare commits

...

4 Commits

Author SHA1 Message Date
lovife 31e7483464 docs: 同步契约 §6.5~6.9 / §13.4、问题清单 F-112~F-124、交接摘要 §一·三十九
1) docs/03-接口设计.md:
   - §6.5 补 expireBefore **含当天**的语义与"格式不对返回 40001,不静默忽略筛选";补
     "列表 contactPhone 恒为 null"与"packageName 与内嵌 usage{} 不实现(200 行内嵌用量会变成
     N+1 统计)"。
   - §6.6 的 tenantType 由 3 值改回真实的 **4 值**(1 学校 / 2 认定机构 / 3 报名点 / 4 企业)、
     packageId 由 ✅ 必填改为可选(DTO 里本来就是可选),并补副作用(发放 6 个内置角色 + 建管理员
     + 写 CREATE_TENANT 日志)。
   - §6.7 的响应示例原来写着 ocrCount=3105 / smsCount=420,**与实现恒 0 矛盾** —— 改成 0 并补齐
     契约没写的 notes 与 alerts.used/limit,再加三条口径:实时值 vs 周期、恒 0 的原因(编数字会让
     运营拿假数据去谈续费)、**只算这一个租户**(含"库里只有一所学校时看不出"这一点)。
   - **新增 §6.8 / §6.9**(PUT /tenants/{id} 与 GET /tenants/{id}):PUT 此前只在 §4.2 的总览表里
     出现过一次、全文**没有详情节**;GET 更是只登记在 §17.1 的"清单漏列"组里。§6.8 含字段表、
     "不传的字段不修改"、"tenantCode 不可修改"的理由,以及留痕对照表(status→0 是 DISABLE_TENANT,
     不是 UPDATE_TENANT)+ "这些日志行的 tenant_id 是 0,要用 target_type/target_id 查"的警告。
   - §13.4 的 Query 补 targetType(并说明为什么 targetType+targetId 才是最重要的查询方式:
     关键字全文匹配会把"改了它"和"顺带提到它"混在一起,而合规结论不能建立在这种匹配上);
     响应补 request_method / request_uri / extraJson;动作表由 9 行扩到**全集 27 个**并按来源分组
     (任务 / 名册 / 导出 / 角色 / 租户);写明 request_param / duration_ms 刻意不采集;
     并记下"一期只做查询、不做日志导出"这条**此前只写在 LogController 类注释里**的口径
     (导出等于把"谁在什么时候看了谁的身份证"这份名单落到本地磁盘,而导出行为本身也要留痕)。
   - 登录日志响应补 tenantId,并写明 userType 的权威枚举与 location 恒为 null。

2) docs/08-完成度盘点与问题清单.md:新增 **F-112~F-124** —— 用量越界(含"为什么本地完全看不出来")、
   PUT 明文写入加密列、两个枚举能建不能改、租户零留痕、expireBefore 差一天、AUTO_CLOSE 字面量、
   模块下拉里有死项、LogController 描述与事实相反、request_param/duration_ms 有列无写入点、
   schema-postgresql.sql 缺三张表、**"平台专属权限码 + 查带 tenant_id 的表"这个风险面**、
   登录日志缺租户列、userType 注释错位。F-112 标注为本轮最严重的一条。

3) docs/10-用户手册与客服话术.md:「三种角色,三个入口」→ **四种角色**(补平台运营,并写明它是
   唯一跨单位的角色、用"组织与账号/收集管理"这类单位内页面之前要先选"当前学校");
   §4 速查表的菜单名与路径同步(平台运维 → 日志,含两个页签;新增租户管理一行);
   附录的排查话术补 traceId("日志详情里能拿到,那是后端排查时最有用的一个线索")。

4) docs/交接摘要.md:新增 **§一·三十九**,九节 —— 一、口径变更(旧注释原文 + 推翻理由);
   二、用量越界那个 bug 的完整来龙去脉(权限码 → ignoreTable → 实测数据分布 → 修法与夹具);
   三、另外四条后端缺陷表;四、日志详情为什么不新增接口(轻字段 vs 全库 0 行有值的重字段);
   五、AUTO_CLOSE 与检查 ⑩ 的关系(**"这一族检查的价值取决于被检查的那一侧有没有被规范化"**);
   六、顺带修掉的五处;七、前端刻意用分段按钮而不是 NTabs 的两条原因;八、验证表 +
   "改了后端必须重新打包重启、判据是看返回体里有没有新字段"的提醒;九、约定同步。
   另把 E2E 断言总数 1595 → **1617**(四处)。

5) 根 AGENTS.md:接线检查说明补第 ⑩ 项,并写明做这一项时才发现 AUTO_CLOSE 是字符串字面量写的
   —— "提成常量"是让这个检查真正生效的前提。

验证:python scripts/verify-docs.py **22/22**;python scripts/check-wiring.py 未接线 **0 条**
(⑦ 11 个权限码 / ⑧ 0 处漏 import / ⑨ 3 个 SVG 全合法 / ⑩ 27 个动作码两端逐字一致);
python scripts/check-api-parity.py 契约 **119** / 已实现 118 / 缺口 1 —— **没变**,符合本轮
"不新增后端接口"的决定。
2026-10-13 14:04:59 +08:00
lovife 624a05aa66 feat(pc): 平台运营的租户管理(详情/用量/编辑/停用)与日志详情
需求:「设计开发平台运营人员对租户的管理功能,还有日志的详情查看功能。」

⚠️ **口径变更**(覆盖 2026-10 走查的旧结论):views/tenant/index.vue 原先写着「列表**没有操作列**:
不提供编辑 / 停用入口(客户 2026-10 走查确认的口径)」。本轮用户明确选了全量范围,所以列表现在
有操作列(详情 / 编辑 / 停用·启用)。旧注释是**改写而不是删掉**,并写明推翻理由 —— 运营的真实
诉求是:① 到期日变了要能自己改,而不是每次找开发;② 欠费 / 停止合作时要能当场停掉;
③ 每周核对"谁快到期了"时要能点进详情看用量。

1) 租户管理:操作列用 tenant:view 门控「详情」、tenant:manage 门控「编辑 / 停用 / 启用」,
   拿不到管理权限时整列显示"只读",而不是留一个点了报 403 的按钮。停用走 NPopconfirm 二次确认
   且文案写明后果("停用后该校所有学生与老师会立刻无法登录");只发 {status} 一个字段 ——
   PUT 是部分更新,回传整行会把列表里看不见的字段覆盖成空。

2) 详情抽屉 TenantDetailDrawer:打开时**同时**调 getTenantDetail 与 getTenantUsage ——
   列表接口的 contactPhone **恒为 null**(电话以密文入库,列表不能成为批量拉取联系方式的通道),
   脱敏值只有详情接口给,拿行数据凑详情会显示成"没有联系电话"。用量区写明"学生数与存储是**实时值**,
   周期只影响计费项",否则会以为"2026Q3 的学生数"是历史值;notes 如实全列(它解释"为什么这几项是 0")。
   统计周期只给本月 / 上月 / 本季三个固定选项,不做自由文本输入。

3) 编辑弹窗 TenantEditModal:类型 4 选项、状态 3 选项;数字下拉的校验规则必须带 type: 'number'
   (只写 required 会"看起来填了但校验不过",而且 #feedback 会把报错遮住);联系电话留空 = 不修改;
   租户编码只读展示并注明"出现在扫码落地页与对接文档里,开通后不可修改"。不做"清空到期日"
   (MyBatis-Plus 忽略 null 更新,清不掉就不在界面上承诺)。

4) 日志页:操作日志 / 登录日志两个模式,各自维护 list / total / pageNum / pageSize / loading /
   筛选条件,切换时重置到第 1 页。⚠️ 页签用工具条上的**分段按钮**而不是 NTabs / NTabPane:
   ① NTabPane 的内容要额外 flex 化才能维持 .page-card 的高度链,否则表格高度算成 0、
   **一行数据都看不见**(本仓踩过);② naive 2.45.3 的 tab="..." 属性写法本身也是坏的,
   必须用 #tab 插槽。

5) 日志详情 LogDetailDrawer:变更前后**完整不截断** + break-all + 各一个复制按钮;
   extraJson 格式化成 <pre>(解析失败原样展示);traceId 带复制;request_param / duration_ms
   如实写"本版本未采集"(这两列从来没有写入点),不编。抽屉**不再打接口** —— 列表 SELECT 已经
   把抽屉要的字段都带出来了。

6) 「单位」列只对平台显示(0 → 「平台」),两张表统一用「单位」这个词:这一列要装得下
   tenant_id = 0 的「平台」,表头写「租户」而格子里写「平台」是自相矛盾的;且与筛选栏两个下拉的
   占位符、租户管理页的「单位名称」一致。登录日志刻意**不要「归属地」列**(location 恒为 null,
   整列空白看起来像功能坏了)。

7) 日志页的模块下拉换成导出的 MODULE_OPTIONS / TARGET_TYPE_OPTIONS:原先那 4 项里 AUDIT / EXPORT
   **后端一个写入点都没有**,而真会写的 FILE(代传材料)与 ROLE(6 个角色动作)**不在里面** ——
   选中死项只会得到一张空表,让人以为"这段时间没有审核动作"。

8) 菜单显示名「操作日志」→「日志」(key 保持 'log' 不动:后端 BuiltinRole 的菜单键与
   check-wiring 第 ⑥ 项都按它核对,改 key 会让平台角色的菜单静默少一项)。

同步 frontend-pc/AGENTS.md 与 .agents/:「最容易写错的约定」由 7 条扩到 **9 条** —— 新增
第 8 条「详情用抽屉、不要为详情新开路由」(含"列表接口可能刻意不返回某个字段"的坑)与
第 9 条「页面内的页签用工具条分段按钮」(写明高度链的原因)。

验证:pnpm typecheck **0 错**、pnpm lint **0 错 0 警告**、pnpm build 通过。
浏览器实测(平台账号,5173 + 8080):租户页出现操作列且三档到期配色正确、卡片上内边距仍是 16px、
高度链未破(表格 570px / 日志表 528px);详情抽屉显示脱敏电话 138****8000 —— 而库里存的是
**历史明文** 13800138000,也就是说这条同时验了"读路径容错"(否则是永久 500);停用二次确认文案
正确;清空单位名称提交后弹窗不关且错误可见;登录日志同一张表里同时出现「示范职业学校」
(tenant_id=1)与「平台」(tenant_id=0)两种映射;**学校管理员视角两张表都没有「单位」列**。
2026-10-13 14:04:53 +08:00
lovife b516a7c47b fix(tenant,log)!: 平台身份下用量统计算成全平台 + 5 处租户/日志缺陷
做「平台运营的租户管理」时挖出来的,其中第 1 条与那个功能本身无关、但严重得多。

1) 🔴 GET /tenants/{id}/usage 一直算的是**全平台**的数。平台运营的 tenantId 是 0,而
   FillTenantLineHandler.ignoreTable() 对平台身份直接返回 true —— 整张表的租户过滤被关掉,
   于是 distinctStudents()(查 collect_roster)与存储量(查 collect_file)都不带租户条件,
   返回全平台数字却标着某一所学校的名字,运营会拿它去谈续费。而这个接口的调用者**一定是**
   平台运营(tenant:view 在 platformOnlyCodes 里),所以它从来只在"过滤被关掉"的上下文里跑过。
   ⚠️ 本地完全看不出来:实测 34 行 collect_roster、22 行 collect_file 全在 tenant_id=1,
   只有一所学校有数据时错误值与正确值**完全相等**。修法:统计段包进既有的 TenantScope.callAs,
   切走后 isPlatformAdmin() 变 false、过滤自动恢复;**没有**手写 tenant_id 条件 —— 那等于
   放弃拦截器的保护。

2) PUT /tenants/{id} 把手机号**明文**写进加密列(create 走 codec.encrypt,update 是裸赋值)。
   AesGcmCipher.decrypt 对非 Base64 直接抛(该类没有 dev 旁路)→ 存过一次电话,这所学校的
   详情页就永远打不开。库里 id=1 存的就是明文 13800138000。改为非空才加密写入(空 = 不修改),
   详情返回脱敏值;**读路径必须容错**(解密失败按明文处理 + WARN),否则故障形态是"永久 500",
   看起来像系统坏了、而不是"库里有一行旧数据"。历史明文行不做迁移(迁移里拿不到密钥)。

3) 租户两个枚举"能建不能改":tenantType 只放行 1~3(实体/DDL/契约/前端都是 4 值)、
   status 只放行 0/1(实体有 2 试用,而前端的状态筛选按钮里就有「试用」,选了永远是空列表)。
   已按真实枚举放开,报错文案与实体注释逐字对齐。

4) 开通/修改/停用租户原先一条日志都不写(只有给运维看的 log.warn/log.info)。新增
   MODULE_TENANT 与 CREATE_TENANT/UPDATE_TENANT/DISABLE_TENANT/ENABLE_TENANT —— 停用与启用
   不并进 UPDATE_TENANT:停用是这套系统里影响面最大的单个动作,出争议时第一个要看的就是这个
   区别。⚠️ 这些行的 tenant_id 是 0(写日志用的是**操作者**的上下文),查"这所学校被谁停过"
   要用 target_type=TENANT + target_id,**不能**用 tenantId 筛(反直觉,已写进常量注释)。

5) expireBefore 用 atStartOfDay() 与存进去的当天 23:59:59 比 → **差一天**,「今天到期」的租户
   查不出来(同型的坑在 LogController.parseTo 上修过一次,那次是"查 9 月 30 日查不到当天")。
   改为补到当天末尾。格式不对返回 40001 并说明格式 —— **刻意不学** parseTo 的"当没传":
   那侧是界面上的自由文本输入,静默忽略是为了"输错了也别让人看不到日志";这侧的值只由界面按
   当前日期算出来,静默忽略筛选会让人以为"我按到期日筛过了"。

6) 顺带:
   - AUTO_CLOSE(定时任务自动截止)在 TaskService 里是**字符串字面量**、不在常量表里 ——
     也就是说"后端常量 ↔ 前端文案"这类检查**本来就扫不到它**,而它正是量最大的那类任务日志,
     界面上会原样回显英文码。提成常量(连带 STATUS_CHANGE 兜底名)。
   - 日志列表 SELECT 补 request_method/request_uri/extra_json 三个**轻字段**(实测一页 20 行
     约 2 KB),日志详情因此不需要新开接口;真正重的 request_param/duration_ms 全库 1758 行里
     **0 行有值**(从来没有 setXxx 过),不采集。
   - LoginLogVO 补 tenantId:平台运营看到的登录日志是**跨单位**的,没有这一列就分不清一行是
     哪所学校的,而"某个账号什么时候登进来的"一定得连着"哪个单位"一起回答。
   - 修正 LoginLogVO.userType 的 Javadoc:原来写着「1 超管 / 2 管理员 / 3 教师 / 4 学生」,
     与权威口径(1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生)错位 —— **同一个坑第三次出现**,
     该错位枚举曾导致"把班主任当成社会人员拒掉"。

新增检查 scripts/check-wiring.py 第 ⑩ 项:后端会写的动作码 ↔ PC 端 OPERATION_ACTIONS 的文案,
逐字核对(缺一个文案,日志页就会原样回显英文码)。**已反向验证**:删掉一条文案 → 报红 + exit 1。
⚠️ 写这个检查时踩到的坑:同文件里 MODULE_OPTIONS / TARGET_TYPE_OPTIONS 也是 { value: 'TASK' }
的形状,整文件扫会把模块名当成动作码收进来 —— 所以只从 OPERATION_ACTIONS 那个数组里取。

验证:python scripts/verify-all.py --with-e2e → **9 项全过 / 0 失败 / 0 跳过**(37 脚本 /
1617 断言)。e2e-m8-orgfile.py **74/74** —— 其中"用量只算这一所学校"这条**必须造夹具**才
证伪得了:它往新建的 E2E 租户**直插一行 collect_roster**(该表 NOT NULL 且无默认值的列只有 5 个,
不需要走导入夹具),再断言"接口值 == 库里 where tenant_id=1 的去重数",并**额外断言
"全平台去重数 > 一号学校去重数"**,否则这条断言是空转的。e2e-m5-logs.py 44/44(回归)。
重打包重启后再验一次:/logs/logins 返回 tenantId、/tenants/1 返回 138****8000(而不是 500)——
判据是**看返回体里有没有新字段**,而不是"接口通了就算"。
2026-10-13 14:04:48 +08:00
lovife 9f6d26c650 fix(e2e,pc): 不再产生跨租户同名的登录账号
账号唯一性是 (tenant_id, username),跨租户重名本身是合法行为(登录靠租户码区分);但默认值与 e2e 残留会让它变成默认状态:不带租户码登录会撞上 40012(该账号存在于多个单位),平台侧看账号也分不清哪个 admin 属于哪所学校。三处一起收口 ——

1) scripts/e2e-m0.py 跑完不再留租户:以前每跑一次就留下一个 e2e###### 租户 + 里面叫 admin 的管理员 + demo 下 3 个测试组织,库里那两个孤儿租户就是这么来的。现在按本次跑出的 id 自清(崩溃路径也兜住),不越界成 LIKE 'e2e%' 全清。

2) scripts/e2e-m1-tenant-domain.py 自己造、自己清「另一个租户里也有 admin」的夹具:它原本靠第 1 条的残留凑出 40012 这个前提,残留一被清掉就会静默反转成 8 条 FAIL。顺带修掉同族的第二处隐式依赖 —— 它假定 t14.localhost 是个真实租户,而那个租户早就不存在了。

3) PC 端「开通租户」的登录账号默认值不再写死 admin,改为跟随租户编码生成 admin_<编码>;运营一旦手改就不再被编码覆盖。

验证:全量 python scripts/verify-all.py --with-e2e 通过(37 脚本 / 1597 断言 / 0 失败),跑完库里仍只有 1 个租户、0 处跨租户重名;PC 端 pnpm typecheck 0 错、pnpm lint 干净、check-wiring 未接线 0 条,浏览器实演默认值跟随与手改不回退。
2026-10-13 10:21:40 +08:00
29 changed files with 3287 additions and 237 deletions
+5 -2
View File
@@ -101,7 +101,7 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑
```
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(**37 个脚本 / 1595 条断言**,以最近一次
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(**37 个脚本 / 1617 条断言**,以最近一次
`verify-all --with-e2e` 的输出为准;2026-10 需求收敛删掉了 `e2e-m2-join.py` 与 `e2e-m7-cleanup.py`),
或一条命令 `python scripts/verify-all.py --with-e2e`
- 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` +
@@ -122,7 +122,10 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑
**模板里的 `<n-*>` 组件有没有漏 import**(第 ⑧ 项 —— 漏了会被当未知元素渲染,样式整块丢、
编译与 lint 都不红)、
**`.svg` 资产是不是合法 XML**(第 ⑨ 项 —— SVG 注释里出现连续两个减号就 not well-formed,
图标变裂图而控制台一句话都不打;2026-10 换品牌标时连踩两次))。
图标变裂图而控制台一句话都不打;2026-10 换品牌标时连踩两次)、
**后端会写的日志动作码 ↔ PC 端 `OPERATION_ACTIONS` 的文案**(第 ⑩ 项 —— 缺一个文案,
日志页会**原样回显英文码**;做这一项时才发现 `AUTO_CLOSE` 是字符串字面量写的,
所以"提成常量"是让这个检查真正生效的前提)。
⚠️ 这一族错**编译 / lint / review 都不会红**(类在、表在、常量在、注释还写得很清楚),
只有"从入口走一遍"才暴露 —— 所以把它固化成命令;有意不接线的写在脚本上方的例外表里(带理由)。
⚠️ **检查器自己也会失效**:PC 端重搭后权限码的写法从 `permissions.includes` / `v-hasPerm`
@@ -147,9 +147,8 @@ public class LogController {
@Operation(summary = "登录日志",
description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。"
+ "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。"
+ "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— "
+ "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。"
+ "所以「学生登录记录为空」是已知情况,不是查询条件写错了")
+ "登录方式 `loginType`:`PASSWORD`(PC 账号密码)/ `WECHAT`(学生从小程序、H5 走微信授权)。"
+ "⚠️ `location`(IP 归属地)一期**恒为 null** —— 没接 IP 库,返回 null 而不是编一个")
public ApiResult<PageResult<LogDtos.LoginLogVO>> logins(
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) Long userId,
@@ -49,6 +49,25 @@ public final class LogDtos {
private String userAgent;
/**
* 请求方法({@code GET} / {@code POST} / {@code PUT} / {@code DELETE})。
*
* <p>与 {@link #requestUri}、{@link #extraJson} 一起是「日志详情」要用的三列 ——
* 它们都是**轻字段**(实测最大 45 / 125 字节级),所以列表直接带上,
* 不需要为详情再开一个按 id 查的接口。
*/
private String requestMethod;
/** 请求路径(不含 query string)。 */
private String requestUri;
/**
* 补充上下文(JSON 串),如批量操作命中的 id 列表 / 停用组织时的 {@code deactivated}。
*
* <p>⚠️ 不是每一条都有(一期只有一部分动作带它)。
*/
private String extraJson;
private Integer bizCode;
private String traceId;
@@ -74,11 +93,28 @@ public final class LogDtos {
private Long id;
/**
* 登录者所属租户({@code 0} = 平台)。
*
* <p>平台运营看到的登录日志是**跨单位**的,没有这一列就分不清一行是哪所学校的 ——
* 而"某个账号什么时候登进来的"这个问题,在平台视角下**一定**要连着"哪个单位"一起回答。
* {@code sys_login_log.tenant_id} 本来就有(V2 建表时的 {@code NOT NULL DEFAULT 0}),
* 只是这个 VO 此前没把它带出来。
*/
private Long tenantId;
private Long userId;
private String userName;
/** 用户类型:1 超管 / 2 管理员 / 3 教师 / 4 学生 */
/**
* 用户类型:{@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生。
*
* <p>⚠️ 这里以前写的是「1 超管 / 2 管理员 / 3 教师 / 4 学生」,**是错的**。
* 权威来源是 {@code BuiltinRole.UserType} 与 {@code UserService.USER_TYPE_NAMES},
* 库列注释({@code sys_user.user_type})也与它们一致 ——
* 别按 `教师/学生` 那套去映射,本仓库曾因此把班主任当成社会人员拒掉过一次。
*/
private Integer userType;
/** {@code PASSWORD} / {@code WECHAT} / {@code PHONE} */
@@ -45,7 +45,7 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT l.id, l.user_id AS userId,
SELECT l.id, l.tenant_id AS tenantId, l.user_id AS userId,
COALESCE(u.real_name, l.username) AS userName,
u.user_type AS userType,
CASE l.login_type
@@ -63,6 +63,8 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
l.module, l.action, l.target_type AS targetType, l.target_id AS targetId,
l.before_val AS beforeVal, l.after_val AS afterVal,
l.ip, l.user_agent AS userAgent, l.biz_code AS bizCode, l.trace_id AS traceId,
l.request_method AS requestMethod, l.request_uri AS requestUri,
l.extra_json AS extraJson,
l.create_time AS createTime
FROM sys_operation_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
@@ -67,8 +67,16 @@ public class OperationLogService {
public static final String MODULE_JOIN = "JOIN";
public static final String MODULE_USER = "USER";
public static final String MODULE_FILE = "FILE";
/** 角色与授权(二期)。改授权等于改"谁能做什么",是这套系统里最敏感的一类动作。 */
/** 角色与授权。改授权等于改"谁能做什么",是这套系统里最敏感的一类动作。 */
public static final String MODULE_ROLE = "ROLE";
/**
* 租户(平台运营的「开通 / 修改 / 停用学校」)。
*
* <p>⚠️ 这类日志的 {@code tenant_id} 是 <b>0</b>(平台身份),不是被操作的学校 ——
* 因为写日志用的是<b>操作者</b>的上下文。查"这所学校被谁停过"要用
* {@code target_type=TENANT & target_id=<租户 id>},不能用 tenant_id 筛。
*/
public static final String MODULE_TENANT = "TENANT";
public static final String ACTION_UPDATE_IDCARD = "UPDATE_IDCARD";
public static final String ACTION_UPDATE_ROSTER_NAME = "UPDATE_ROSTER_NAME";
@@ -80,6 +88,20 @@ public class OperationLogService {
public static final String ACTION_RESUME_TASK = "RESUME_TASK";
/** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */
public static final String ACTION_CLOSE_TASK = "CLOSE_TASK";
/**
* 定时任务**自动**截止。
*
* <p>⚠️ 必须与 {@link #ACTION_CLOSE_TASK} 分开:出了争议时第一个要看的就是
* 「这个任务是到点自己关的,还是管理员手动关的」。
*/
public static final String ACTION_AUTO_CLOSE = "AUTO_CLOSE";
/**
* 任务状态变更(兜底动作名)。
*
* <p>只有在调用方**说不出**是哪个动作时才会用到;`patchStatus` 的注释解释了
* 为什么"发布"与"恢复"不能共用它(两者都是 →收集中,靠状态推会记成同一个动作)。
*/
public static final String ACTION_STATUS_CHANGE = "STATUS_CHANGE";
public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_TASK";
/** 删除已归档任务(2026-09-30 客户确认,§17.2 D-9):级联软删 + 尽力删文件,必须留痕。 */
public static final String ACTION_DELETE_TASK = "DELETE_TASK";
@@ -101,6 +123,17 @@ public class OperationLogService {
public static final String ACTION_UPDATE_ROLE_MENU = "UPDATE_ROLE_MENU";
/** 停用 / 启用角色。 */
public static final String ACTION_TOGGLE_ROLE = "TOGGLE_ROLE";
/** 开通租户(新建一所学校 + 它的内置角色与管理员账号)。 */
public static final String ACTION_CREATE_TENANT = "CREATE_TENANT";
/** 改租户信息与套餐(名称 / 类型 / 联系人 / 到期)。 */
public static final String ACTION_UPDATE_TENANT = "UPDATE_TENANT";
/**
* 停用租户 —— <b>该校所有学生与老师立刻登不进来</b>,
* 是这套系统里单个动作影响面最大的一类,所以单独一个动作码而不是并进 UPDATE。
*/
public static final String ACTION_DISABLE_TENANT = "DISABLE_TENANT";
/** 恢复启用租户。 */
public static final String ACTION_ENABLE_TENANT = "ENABLE_TENANT";
public static final String TARGET_ROSTER = "ROSTER";
public static final String TARGET_TASK = "TASK";
@@ -110,6 +143,8 @@ public class OperationLogService {
public static final String TARGET_USER = "USER";
/** 角色({@code sys_role.id})。 */
public static final String TARGET_ROLE = "ROLE";
/** 租户({@code sys_tenant.id})。平台级动作的 target,见 {@link #MODULE_TENANT}。 */
public static final String TARGET_TENANT = "TENANT";
/**
* 记一条操作日志(**不抛异常**)。
@@ -688,7 +688,8 @@ public class TaskService {
* **"系统到点关的"与"管理员手动关的"** —— 出了争议时这是第一个要看的信息。
*/
public void autoClose(Long taskId) {
patchStatus(taskId, CollectTask.STATUS_CLOSED, "AUTO_CLOSE");
patchStatus(taskId, CollectTask.STATUS_CLOSED,
cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_AUTO_CLOSE);
}
/**
@@ -718,7 +719,9 @@ public class TaskService {
if (from == status) {
return;
}
String act = action == null ? "STATUS_CHANGE" : action;
String act = action == null
? cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_STATUS_CHANGE
: action;
operationLog.record(cn.tianbaotong.fill.module.log.service.OperationLogService.MODULE_TASK,
act, cn.tianbaotong.fill.module.log.service.OperationLogService.TARGET_TASK, id,
String.valueOf(from), String.valueOf(status));
@@ -4,6 +4,7 @@ import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.api.PageResult;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.common.util.MaskUtil;
import cn.tianbaotong.fill.module.log.service.OperationLogService;
import cn.tianbaotong.fill.module.tenant.dto.TenantCreateReq;
import cn.tianbaotong.fill.module.tenant.dto.TenantVO;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
@@ -48,6 +49,7 @@ public class TenantService {
private final UserMapper userMapper;
private final RoleProvisioner roleProvisioner;
private final SensitiveCodec codec;
private final OperationLogService operationLog;
private final PasswordEncoder passwordEncoder;
// ── 列表 ──────────────────────────────────────
@@ -65,7 +67,10 @@ public class TenantService {
.orderByDesc(SysTenant::getId);
if (expireBefore != null && !expireBefore.isBlank()) {
query.le(SysTenant::getExpireTime, LocalDate.parse(expireBefore).atStartOfDay());
// ⚠️ **含当天**:expire_time 存的是当天 23:59:59(见 parseExpire),
// 曾经用 atStartOfDay() 比 —— 于是"今天到期"的租户查不出来(差一天)。
// 同型的坑在 LogController.parseTo 上已经修过一次。
query.le(SysTenant::getExpireTime, parseExpireBefore(expireBefore));
}
IPage<SysTenant> page = tenantMapper.selectPage(new Page<>(num, size), query);
@@ -120,19 +125,76 @@ public class TenantService {
log.info("已开通租户 {}(id={}),管理员账号 {}", code, tid, req.getAdminUsername());
// 留痕(F-1 点名「租户开通」是优先留痕点)。
// ⚠️ 这行的 tenant_id 是 **0**(平台身份,不是刚建的这所学校)—— 写日志用的是
// 操作者的上下文,而 TenantScope.runAs 已在 finally 里恢复了。
// 要查"这所学校是谁开通的",用 target_type=TENANT & target_id=tid。
operationLog.record(OperationLogService.MODULE_TENANT, OperationLogService.ACTION_CREATE_TENANT,
OperationLogService.TARGET_TENANT, tid, null,
"tenantCode=" + code + "; name=" + t.getTenantName() + "; type=" + t.getTenantType());
return new CreateResult(tid, initialPassword, "t" + tid);
}
/**
* 租户详情。
*
* <p>与列表的唯一区别:<b>这里返回联系电话的脱敏值</b>(列表恒为 {@code null},见 {@link #toVo})。
*
* <p>为什么给脱敏值而不是明文:这是全系统唯一一处「加密存储的联系方式」,
* 若在详情里可读且不留痕,就与「看证件号明文必须留痕」({@code VIEW_IDCARD_FULL})
* 这条既有规矩自相矛盾。脱敏号足够确认"档里有号",要改直接填新号 ——
* {@code PUT} 留空 = 不修改,所以界面不需要回填明文。
*/
public TenantVO detail(Long id) {
SysTenant t = tenantMapper.selectById(id);
if (t == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "租户不存在");
}
return toVo(t);
TenantVO vo = toVo(t);
String plain = plainContactPhone(t.getContactPhone());
vo.setContactPhone(plain == null ? null : MaskUtil.maskPhone(plain));
return vo;
}
// ── 内部 ──────────────────────────────────────
/**
* 解析 {@code expireBefore}({@code yyyy-MM-dd},**含当天**)。
*
* <p>⚠️ 格式不对时**明确报错**,不学 {@code LogController.parseTo} 的"当没传":
* 那两个是界面上的文本框自由输入,静默忽略是为了"输错了也别让人看不到日志";
* 而这里的值只由界面按当前日期算出来,非法值只可能来自手搓请求 ——
* 静默忽略筛选会让人以为"我按到期日筛过了",那正是本仓反复批评的模式。
*/
private static LocalDateTime parseExpireBefore(String s) {
try {
return LocalDate.parse(s.trim()).atTime(23, 59, 59);
} catch (Exception e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "到期日期格式不对,应为 2026-12-31");
}
}
/**
* 取联系电话明文(**只给详情用**)。
*
* <p>这一列是加密列,但库里存在历史明文行(本机 id=1 就是),直接 {@code decrypt}
* 会在 GCM 的 Base64 解码处抛 —— 而"解密失败"在这里不该变成 500:
* 那会让一所学校的详情页**永远打不开**。所以失败时按明文返回,
* 并记一条 WARN 让"还剩多少明文行"可见(历史行不做数据迁移:迁移里拿不到 AES 密钥)。
*/
private String plainContactPhone(String stored) {
if (stored == null || stored.isBlank()) {
return null;
}
try {
return codec.decrypt(stored);
} catch (RuntimeException e) {
log.warn("租户联系电话不是密文(历史明文行或密钥变更过),按明文返回:{}", e.toString());
return stored;
}
}
private static LocalDateTime parseExpire(String s) {
try {
// 允许 "2027-12-31" 或 "2027-12-31T23:59:59"
@@ -151,8 +213,8 @@ public class TenantService {
vo.setTenantName(t.getTenantName());
vo.setTenantType(t.getTenantType());
vo.setContactName(t.getContactName());
// 联系电话以密文入库,列表页要求脱敏展示时的处理在导出/详情里做,
// 这里不返回明文,避免列表接口成为批量拉取联系方式的通道
// 联系电话以密文入库:**列表恒不返回**(脱敏值也不行),
// 避免列表接口成为批量拉取联系方式的通道。脱敏值只有 detail() 给。
vo.setContactPhone(null);
vo.setPackageId(t.getPackageId());
vo.setExpireTime(t.getExpireTime());
@@ -4,10 +4,13 @@ import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.file.entity.CollectFile;
import cn.tianbaotong.fill.module.file.mapper.FileMapper;
import cn.tianbaotong.fill.module.log.service.OperationLogService;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
import cn.tianbaotong.fill.support.SensitiveCodec;
import cn.tianbaotong.fill.support.TenantScope;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -18,6 +21,7 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 租户用量统计(《03-接口设计》§6.7)。
@@ -45,6 +49,8 @@ public class TenantUsageService {
private final TenantMapper tenantMapper;
private final CollectRosterMapper rosterMapper;
private final FileMapper fileMapper;
private final SensitiveCodec codec;
private final OperationLogService operationLog;
/** 默认套餐额度(没有配套餐时的兜底)。 */
private static final long DEFAULT_STUDENT_LIMIT = 2000L;
@@ -64,15 +70,22 @@ public class TenantUsageService {
vo.setTenantName(t.getTenantName());
vo.setPeriod(p.text());
// 学生数:**按证件号去重**(同一个人可能在多个任务里各有一行名册)
vo.setStudentCount(distinctStudents());
// 存储:材料规范件的体积合计(原始件默认不保留,所以它才是真实占用)
Long bytes = fileMapper.selectList(Wrappers.<CollectFile>lambdaQuery()
.select(CollectFile::getFinalSize))
.stream().map(CollectFile::getFinalSize)
.filter(java.util.Objects::nonNull).mapToLong(Long::longValue).sum();
vo.setStorageMb(bytes / 1024 / 1024);
// ⚠️ 这两项必须**切到目标租户的上下文里**再查,不能直接用当前请求的身份查。
//
// 原因:本接口只有平台运营能调(@RequirePerm(TENANT_VIEW),那个码在
// PermissionDef.platformOnlyCodes() 里),而平台运营的 tenantId 是 0 ——
// FillTenantLineHandler.ignoreTable() 对平台身份直接返回 true,
// **整张表的租户过滤被关掉**。于是"学生数 / 存储量"会算成**全平台**的数,
// 却挂在某一所学校的名下:运营拿着它去谈续费,而数字是假的。
//
// 这个 bug 在本地看不见 —— 库里只有一所学校有数据时,它与正确值完全相等
// (ActingTenantResolver 的注释里描述的是同一个陷阱)。
// 切成目标租户之后 isPlatformAdmin() 变 false,租户过滤自动恢复,
// 不需要给这些查询手写 tenant_id 条件(那样等于放弃拦截器的保护)。
Scope scope = TenantScope.callAs(tenantId, 0L, Set.of(),
() -> new Scope(distinctStudents(), storageMb()));
vo.setStudentCount(scope.students());
vo.setStorageMb(scope.storageMb());
// OCR / 短信:一期没有通道,如实返回 0(见类注释)
vo.setOcrCount(0);
@@ -94,6 +107,19 @@ public class TenantUsageService {
return vo;
}
/** 一次上下文切换里取到的两个「本来就是租户内」的数字。 */
private record Scope(long students, long storageMb) {
}
/** 存储:材料规范件的体积合计(原始件默认不保留,所以它才是真实占用)。 */
private long storageMb() {
Long bytes = fileMapper.selectList(Wrappers.<CollectFile>lambdaQuery()
.select(CollectFile::getFinalSize))
.stream().map(CollectFile::getFinalSize)
.filter(java.util.Objects::nonNull).mapToLong(Long::longValue).sum();
return bytes / 1024 / 1024;
}
/** 学生数:按 `id_card_hash` 去重(同一个人在多任务里只算一次)。 */
private long distinctStudents() {
// 单独写一句 SQL 更省事,但这里刻意用 MyBatis-Plus 的写法:
@@ -152,6 +178,7 @@ public class TenantUsageService {
if (req == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请求体为空");
}
Integer oldStatus = exist.getStatus();
SysTenant upd = new SysTenant();
upd.setId(id);
if (req.getTenantName() != null) {
@@ -161,21 +188,34 @@ public class TenantUsageService {
upd.setTenantName(req.getTenantName().trim());
}
if (req.getTenantType() != null) {
if (req.getTenantType() < 1 || req.getTenantType() > 3) {
// 取值以 SysTenant.tenantType 的注释为准(1 学校 / 2 认定机构 / 3 报名点 / 4 企业)。
// ⚠️ 这里曾经只放行 1~3,而 DDL 默认值与实体注释都是 4 值 ——
// 于是"认定机构 / 报名点 / 企业"在前端下拉里选得到,保存却报错。
if (req.getTenantType() < 1 || req.getTenantType() > 4) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"租户类型只能是 1(学校)/ 2(机构)/ 3(企业)");
"租户类型只能是 1(学校)/ 2(认定机构)/ 3(报名点)/ 4(企业)");
}
upd.setTenantType(req.getTenantType());
}
upd.setContactName(req.getContactName());
upd.setContactPhone(req.getContactPhone());
// ⚠️ contact_phone 是**加密列**(见 V1__business_tables.sql 的列注释)。
// 这里曾经是 `upd.setContactPhone(req.getContactPhone())` —— 明文直接落库,
// 而读回来的路径会在 AesGcmCipher.decrypt 里 Base64 解不开直接抛,
// 表现是「保存成功,但详情页从此打不开」。空值 = **不修改**(不是清空)。
if (req.getContactPhone() != null && !req.getContactPhone().isBlank()) {
upd.setContactPhone(codec.encrypt(req.getContactPhone().trim()));
}
upd.setPackageId(req.getPackageId());
upd.setExpireTime(req.getExpireTime());
upd.setLogoUrl(req.getLogoUrl());
upd.setConfigJson(req.getConfigJson());
if (req.getStatus() != null) {
if (req.getStatus() != 0 && req.getStatus() != 1) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "状态只能是 0(停用)或 1(启用)");
// 取值以 SysTenant.status 的注释为准(0 停用 / 1 正常 / 2 试用)。
// ⚠️ 这里曾经只放行 0/1 —— 而前端的状态筛选按钮组与 TENANT_STATUS 都有"试用",
// 结果是"试用"建不出来也改不出来。
if (req.getStatus() != 0 && req.getStatus() != 1 && req.getStatus() != 2) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"状态只能是 0(停用)/ 1(正常)/ 2(试用)");
}
upd.setStatus(req.getStatus());
if (req.getStatus() == 0) {
@@ -187,7 +227,52 @@ public class TenantUsageService {
tenantMapper.updateById(upd);
log.info("修改租户 id={} 名称={} 状态={} 到期={}", id, upd.getTenantName(),
upd.getStatus(), upd.getExpireTime());
return tenantMapper.selectById(id);
SysTenant now = tenantMapper.selectById(id);
recordChange(id, oldStatus, exist, now, req);
return now;
}
/**
* 租户变更留痕。
*
* <p>《08-完成度盘点》的 F-1 点名「租户开通」是优先留痕点,而本模块此前
* <b>一条日志都不写</b> —— 开通一所学校、把到期日往后挪、停用一所学校,
* 都不留痕,这是整套系统里影响面最大的几类动作之一。
*/
private void recordChange(Long id, Integer oldStatus, SysTenant before, SysTenant after, SysTenant req) {
Integer newStatus = req.getStatus();
if (newStatus != null && !newStatus.equals(oldStatus)) {
String action = switch (newStatus) {
case 0 -> OperationLogService.ACTION_DISABLE_TENANT;
case 1 -> OperationLogService.ACTION_ENABLE_TENANT;
default -> OperationLogService.ACTION_UPDATE_TENANT;
};
operationLog.record(OperationLogService.MODULE_TENANT, action,
OperationLogService.TARGET_TENANT, id,
"status=" + oldStatus, "status=" + newStatus);
return;
}
// 手机号在库里是密文,摘要里只记「有没有设置」(写密文进日志既没用又难看),
// 所以"换了一个号"这件事比较不出来 —— 用请求里有没有带它来补一条。
boolean phoneChanged = req.getContactPhone() != null && !req.getContactPhone().isBlank();
String b = summary(before);
String a = summary(after) + (phoneChanged ? "; phone=已更新" : "");
if (!b.equals(a)) {
operationLog.record(OperationLogService.MODULE_TENANT, OperationLogService.ACTION_UPDATE_TENANT,
OperationLogService.TARGET_TENANT, id, b, a);
}
}
/** 变更摘要(`k=v; k=v`)。`OperationLogService.mask` 会再兜底脱敏一次并截断到列宽。 */
private static String summary(SysTenant t) {
return "name=" + t.getTenantName()
+ "; type=" + t.getTenantType()
+ "; contact=" + t.getContactName()
+ "; phone=" + (t.getContactPhone() == null || t.getContactPhone().isBlank() ? "-" : "已设置")
+ "; package=" + t.getPackageId()
+ "; expire=" + t.getExpireTime()
+ "; status=" + t.getStatus();
}
// ── 周期解析 ──
+129 -14
View File
@@ -1187,37 +1187,109 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
**权限**:运营 **Query**:`keyword`、`status`、`expireBefore`、分页
`expireBefore`(`yyyy-MM-dd`)**含当天** —— `expire_time` 存的是当天 `23:59:59`,
所以比较要补到当天末尾,否则"今天到期"的租户查不出来。格式不对返回 `40001`
(**不静默忽略筛选**:这个值只由界面按当前日期算出来,非法值只可能来自手搓请求)。
**响应**:`{ list: TenantVO[], total }`
⚠️ 列表里的 `contactPhone` **恒为 `null`** —— 联系电话以密文入库,列表接口不能成为
批量拉取联系方式的通道。脱敏值只有 6.9 给。
`packageName` 与内嵌 `usage{}`(§3.12 的简表里画了)**不实现**:200 行内嵌用量会变成 N+1 统计。
### 6.6 `POST /api/v1/tenants`
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `tenantName` | string | ✅ | — |
| `tenantCode` | string | ✅ | 全局唯一,如 `jxjd` |
| `tenantType` | int | ✅ | `1` 学校 / `2` 机构 / `3` 企业 |
| `contactName` / `contactPhone` | string | ✅ | 联系人 |
| `packageId` | int64 | ✅ | 套餐 |
| `expireTime` | string | ✅ | 到期时间 |
| `tenantCode` | string | ✅ | 全局唯一,如 `jxjd`;**入库时小写化** |
| `tenantType` | int | ✅ | `1` 学校 / `2` 认定机构 / `3` 报名点 / `4` 企业 |
| `contactName` / `contactPhone` | string | ✅ | 联系人;电话**加密入库** |
| `packageId` | int64 | | 套餐(可选,无外键) |
| `expireTime` | string | ✅ | 到期时间;只给日期时补成当天 `23:59:59` |
| `adminUsername` / `adminRealName` / `adminPhone` | string | ✅ | **同时创建首个管理员** |
**响应**:`{ "id": 1002, "adminInitialPassword": "Kp9#vR4t", "miniProgramScene": "t1002" }`
**副作用**:切到新租户的作用域发放 6 个内置角色、建管理员并绑定 `SCHOOL_ADMIN`;
写一条操作日志(动作 `CREATE_TENANT`,`target_type=TENANT`)。
### 6.7 `GET /api/v1/tenants/{id}/usage`
**Query**:`period`(`2026-09` 或 `2026Q3`)
**Query**:`period`(`2026-09` 或 `2026Q3`;不传 = 当月)
```json
{
"code": 0,
"data": {
"studentCount": 1240, "storageMb": 8420, "ocrCount": 3105, "smsCount": 420,
"tenantId": 1, "tenantName": "示范职业学校", "period": "2026-09",
"studentCount": 1240, "storageMb": 8420, "ocrCount": 0, "smsCount": 0,
"quota": { "studentLimit": 2000, "storageLimitMb": 20480, "ocrLimit": 10000, "smsLimit": 2000 },
"alerts": [ { "type": "STORAGE", "level": 1, "ratio": "41.1%" } ]
"alerts": [ { "type": "STORAGE", "level": 1, "ratio": "41.1%", "used": 8420, "limit": 20480 } ],
"notes": [
"ocrCount 恒为 0:M4 的识别通道还没接(等 OCR 选型),不是「没用量」",
"smsCount 恒为 0:短信通道还没有商务结论(降级链只实现到站内信)"
]
}
}
```
⚠️ 三条口径(都不是"实现细节",是**看数的人必须知道的**):
- `studentCount` / `storageMb` 是**实时**值(它们决定"还能不能再收人"),
`period` **只影响计费项**(`ocrCount` / `smsCount`)—— 界面必须写明,
否则会以为"2026Q3 的学生数"是历史值;
- `ocrCount` / `smsCount` 一期**恒为 0 并带 `notes` 说明**:
两条通道都还没接,编一个数字会让运营拿着假数据去谈续费;
- 统计**只算这一个租户**。这条不是废话:调用方一定是平台运营(`tenant:view` 是平台专属码),
而平台身份的 `tenantId` 是 `0`,租户拦截器对它**整张表关掉过滤** ——
实现里必须显式切到目标租户的上下文再统计,否则返回的是**全平台**的数
(库里只有一所学校有数据时,它与正确值完全相等,看不出来)。
### 6.8 `PUT /api/v1/tenants/{id}`
**权限**:运营
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `tenantName` | string | | 不能是空白串 |
| `tenantType` | int | | `1` 学校 / `2` 认定机构 / `3` 报名点 / `4` 企业 |
| `contactName` | string | | — |
| `contactPhone` | string | | **留空 = 不修改**;非空才写,且写入的是**密文** |
| `packageId` | int64 | | — |
| `expireTime` | string | | `2027-12-31` 或 `2027-12-31T23:59:59` |
| `status` | int | | `0` 停用 / `1` 正常 / `2` 试用 |
**不传的字段不修改**(不是"清空")。**`tenantCode` 不可修改**:它出现在小程序扫码
落地页、域名解析与对接文档里,改了会让已经发出去的东西失效,而我们通知不到客户那边。
**留痕**(本接口必须留痕,因为它是这套系统里影响面最大的一类动作):
| 变更 | 动作码 |
|---|---|
| `status` → `0` | `DISABLE_TENANT`(该校所有学生与老师**立刻登不进来**,单独一个码) |
| `status` → `1` | `ENABLE_TENANT` |
| `status` → `2` | `UPDATE_TENANT` |
| 其它字段有变化 | `UPDATE_TENANT`(`before` / `after` 是 `k=v; k=v` 摘要) |
⚠️ 这些日志行的 `tenant_id` 是 **`0`**(写日志用的是**操作者**的上下文,平台运营的租户号是 0),
不是被操作的那所学校。要查"这所学校被谁停过",用 `target_type=TENANT` + `target_id=<租户 id>`,
**不能用 `tenantId` 筛**。
**响应**:`TenantVO`(同 6.9)
### 6.9 `GET /api/v1/tenants/{id}`
**权限**:运营 **响应**:`TenantVO`,其中 `contactPhone` 是**脱敏值**(形如 `139****1111`)
这是**唯一**能拿到联系电话的接口。为什么不给明文:这是全系统唯一一处"加密存储的联系方式",
若在详情里可读且不留痕,就与「看证件号明文必须留痕」(`VIEW_IDCARD_FULL`)这条既有规矩
自相矛盾;而脱敏值足够确认"档里有号",要改直接填新号(6.8 留空 = 不修改)。
⚠️ 库里存在**历史明文行**(早期版本的写入路径没有加密)。服务端对解密失败
按明文处理并记一条 WARN —— 否则一所学校的详情页会**永远打不开**,
而那种故障看起来像"系统坏了",不像"数据里有一行是旧的"。
---
## §7 任务与配置
@@ -2477,7 +2549,13 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
#### `GET /api/v1/logs/operations`
**Query**:`tenantId`、`operatorId`、`module`、`action`、`targetId`、`from` / `to`、分页
**Query**:`tenantId`、`operatorId`、`module`、`action`、`targetType`、`targetId`、`from` / `to`、分页
> `targetType` + `targetId` 是**最重要的查询方式**:靠关键字全文匹配会把
> 「改了它」和「顺带提到它」混在一起,而合规结论不能建立在这种匹配上。
>
> `from` / `to` 只给 10 位日期时,`to` 会补成当天 `23:59:59` —— 不补的话
> 「查 9 月 30 日」查不到当天的记录。
**范围**(权限二选一,`@RequirePerm({log:viewTenant, log:viewAll})`):
@@ -2496,28 +2574,55 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
{ "id": 70001, "operatorId": 10086, "operatorName": "王老师", "module": "ROSTER",
"action": "UPDATE_IDCARD", "targetType": "ROSTER", "targetId": 9012,
"beforeVal": "3610292009******10", "afterVal": "3610292009******99",
"ip": "10.1.2.3", "userAgent": "Chrome/120", "createTime": "2026-09-28T16:20:00+08:00" }
"ip": "10.1.2.3", "userAgent": "Chrome/120",
"requestMethod": "PUT", "requestUri": "/api/v1/roster/9012",
"extraJson": "{\"orgId\":43,\"taskId\":487}",
"bizCode": 0, "traceId": "3f9c…", "createTime": "2026-09-28T16:20:00+08:00" }
],
"total": 1
}
}
```
**必记的敏感操作**(PRD FR-506 / FR-1202)
**动作码全集**(`action`,权威来源是后端 `OperationLogService` 的 `ACTION_*` 常量,
PC 端 `apis/xt/log.ts` 的 `OPERATION_ACTIONS` 是它的文案表 —— 两端由
`scripts/check-wiring.py` 第 ⑩ 项**逐字核对**,缺一个界面上就会原样回显英文码)
| `action` | 场景 |
|---|---|
| `UPDATE_IDCARD` | **改名册证件号**(留痕强制) |
| `UPDATE_ROSTER_NAME` | 改名 |
| `DELETE_ROSTER` | 删名册项 |
| `PUBLISH_TASK` / `PAUSE_TASK` / `ARCHIVE_TASK` | 任务状态流转 |
| `PUBLISH_TASK` | 发布任务 |
| `PAUSE_TASK` / `RESUME_TASK` | 暂停 / 恢复("恢复"意味着曾经暂停过,必须与"发布"分开记) |
| `CLOSE_TASK` / `AUTO_CLOSE` | 手动截止 / **定时任务自动截止**(出争议时第一个要看的就是这个区别) |
| `STATUS_CHANGE` | 任务状态变更(兜底名:调用方说不出是哪个动作时才用) |
| `ARCHIVE_TASK` / `DELETE_TASK` | 归档 / 删除已归档任务 |
| `REVOKE_AUDIT` | **撤回审核** |
| `EXPORT_CREATE` | 创建导出 |
| `EXPORT_CREATE` / `EXPORT_DOWNLOAD` | 创建导出 / 下载导出文件 |
| `PROXY_UPLOAD` | 代传材料 |
| `VIEW_IDCARD_FULL` | **查看完整身份证号**(单独留痕) |
| `CONFIG_COMPRESS` | 改压缩配置 |
| `CREATE_ROLE` / `UPDATE_ROLE` / `DELETE_ROLE` | 角色的增删改 |
| `GRANT_ROLE` / `UPDATE_ROLE_MENU` / `TOGGLE_ROLE` | **改授权** / 改可见菜单 / 停用启用角色 |
| `CREATE_TENANT` / `UPDATE_TENANT` | 开通租户 / 改租户信息·套餐 |
| `DISABLE_TENANT` / `ENABLE_TENANT` | **停用租户**(该校所有人立刻登不进来)/ 恢复启用 |
**模块 `module`**:`TASK` / `ROSTER` / `FILE` / `ROLE` / `TENANT`。
(`AUDIT` / `EXPORT` / `USER` / `JOIN` 四个常量**当前没有写入点** —— 界面上的模块下拉
刻意不列它们:选中只会得到一张空表,让人以为"这段时间没有审核动作"。)
**脱敏**:`beforeVal` / `afterVal` 中的证件号**一律脱敏存储**,即使日志泄露也不暴露明文。
服务层还会再兜底脱敏一次并截断到 512 字符(两层都做,是因为"忘记脱敏"的后果不可逆)。
**两列刻意不采集**:`request_param`(请求参数)与 `duration_ms`(耗时)**没有任何写入点**
(全库 0 行有值)。要采集得引入 Servlet 过滤器或 AOP 抓请求体并脱敏,
而合规留痕本来就不需要它们 —— 界面上不展示、也不做筛选。
> **一期只做查询,不做日志导出**(`backend/.../module/log/controller/LogController.java` 的类注释):
> 导出等于把「谁在什么时候看了谁的身份证」这份名单落到本地磁盘上,
> 而"有人导出过这份日志"本身也要留痕 —— 一个追着自己尾巴的循环。
> 这条口径**只写在代码注释里**过一段时间,所以在这里也记一笔。
#### `GET /api/v1/logs/logins`
@@ -2525,7 +2630,17 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
**范围**:与操作日志同一口径(`log:viewAll` 可跨租户,学校管理员恒为本租户)。
**响应**:`{ list: [{ id, userId, userName, userType, loginType, ip, location, device, result, failReason, loginTime }], total }`
**响应**:`{ list: [{ id, tenantId, userId, userName, userType, loginType, ip, location, device, result, failReason, loginTime }], total }`
⚠️ 三条口径:
- `tenantId`(`0` = 平台)**必须有**:平台运营看到的是跨单位的登录日志,
没有这一列就分不清一行是哪所学校的;
- `userType` 是 **`1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生**
(权威来源是 `BuiltinRole.UserType` 与 `UserService.USER_TYPE_NAMES`,库列注释也一致)
—— **不要**按某些模板里那套「1 教师 / 2 学生」去映射;
- `location`(IP 归属地)**一期恒为 `null`**:没接 IP 库,留 null 而不是编一个。
界面**不要显示这一列**(否则整列空白,看起来像功能坏了)。
| `loginType` | 含义 |
|---|---|
+14 -1
View File
@@ -17,7 +17,7 @@
> | PC 业务菜单 | 12 | **10** |
> | 小程序页面 | 10 | **9** |
> | 原型屏 | 32 | **29** |
> | E2E 脚本 | 39 | **37**(断言 1698 → **1595 条**,0 失败 0 跳过) |
> | E2E 脚本 | 39 | **37**(断言 1698 → **1617 条**,0 失败 0 跳过) |
>
> **删除的三块**:① 内置角色「副班主任 `DEPUTY_TEACHER`」「审核员 `AUDITOR`」;
> ② **历史整理器**(`module/cleanup`,5 接口 + `cleanup:run` + `collect_cleanup_job` 表 + PC 页 + 原型屏);
@@ -485,6 +485,19 @@
| F-109 | ⚠️ **`Map.of(...)` 拼模板变量 → 一条通知都没发**:`Map.of` **不接受 null 值**,而"这个学生查不到姓名"完全正常(学生账号是扫码核验时建的,`real_name` 可能还没填)→ 抛 NPE → 被外层"通知失败不影响业务"的 try/catch 吞成**一行 warn**,于是**驳回通知全丢,而驳回本身照样成功**。这是 F-25「被吞掉的异常」的第二次:**降级逻辑越完善,这类错越安静** | 新增 `NotifyRequest.vars(...)`(null 值直接跳过 —— 模板渲染对"没有值"的处理本来就是渲染成空);姓名改为**优先取名册上的名字**(那才是"他在这个任务里叫什么")。发现方式:`e2e-m5-notify-wiring.py` 第一次跑就红了(标题对、正文里姓名是空的) |
| F-110 | ⚠️ **一条断言写成了"全库计数",于是它会在几天后突然变红**:`e2e-m7-schedule.py` 的"刚提交的不该被提醒"数的是**全库**的「审核超时」消息,而超时扫描是**跨租户**的 —— 库里只要存在别的老填报单(几天前别的脚本留下的),它就会照样提醒。本轮真的红了,而**代码一行都没改**(那些单子的 `submit_time` 终于超过 48 小时了) | 断言**限定到本次夹具的收件人与任务**(`user_id` + 正文里的任务名)。**教训:跨租户扫描类功能的断言必须"限定到自己的夹具"** —— 否则它测的是"库里干不干净",而那是另一件事(与 F-42「cleanup 静默失败污染后续脚本」同一族) |
| F-111 | ⚠️ **`ON CONFLICT (id) DO UPDATE` 不更新用户名 → 看起来像密码错**:E2E 夹具按 id upsert 测试账号,只更新了 `real_name`,于是上一轮残留的行保留着**旧用户名**,而脚本按新用户名登录 → `40012 账号或密码错误`。第一反应会去查密码哈希(而它是对的) | 夹具改为**固定用户名 + `username = EXCLUDED.username`**(幂等);已写进 `e2e-m5-notify-wiring.py` 的注释 |
| F-112 | 🔴 **`GET /tenants/{id}/usage` 在平台账号下算的是「全平台」的数**(做租户管理时发现,是本轮最严重的一条)。平台运营的 `tenantId` 是 `0`,而 `MybatisPlusConfig.FillTenantLineHandler.ignoreTable()` 对平台身份**直接返回 true —— 整张表的租户过滤被关掉**。于是 `TenantUsageService.distinctStudents()`(查 `collect_roster`)与存储量统计(查 `collect_file`)都**不带租户条件**,返回的是全平台的学生数与存储占用,却标着某一所学校的名字 —— 运营会拿着它去谈续费。⚠️ **而这个接口的调用者一定是平台运营**(`tenant:view` 在 `PermissionDef.platformOnlyCodes()` 里),所以它**从来只在"过滤被关掉"的上下文里跑过**。⚠️ 更麻烦的是它**在本地完全看不出来**:实测库里 `collect_roster` 34 行、`collect_file` 22 行**全在 `tenant_id=1`**,只有一所学校有数据时,错误值与正确值**完全相等**(`ActingTenantResolver` 的类注释描述的正是这个陷阱) | 已修:统计段包进 `TenantScope.callAs(tenantId, 0L, Set.of(), ...)` —— 切走之后 `isPlatformAdmin()` 变 false,租户过滤自动恢复(本仓既有机制,`OpenDataService` / `OpenPushService` 在用)。**没有**给查询手写 `tenant_id` 条件(那等于放弃拦截器的保护)。E2E 新增夹具(往另一个租户直插一行 `collect_roster`)+ 断言"接口值 == 库里 `where tenant_id=1` 的去重数",`e2e-m8-orgfile.py` 74/74 |
| F-113 | 🔴 **`PUT /tenants/{id}` 把手机号明文写进加密列 → 详情接口必然 500**:`create` 走 `codec.encrypt(...)`,而 `update` 是 `upd.setContactPhone(req.getContactPhone())`;DB 列注释写着「联系电话(AES加密存储)」。`AesGcmCipher.decrypt` 对非 Base64 输入直接抛(该类**没有 dev 旁路**),于是**存过一次电话,这所学校的详情页就永远打不开**。实测 `sys_tenant.id=1` 存的就是明文 `13800138000`(来源不可考:全仓库没有任何脚本 PUT 过租户电话,3 处 `contactPhone` 都是 `POST /tenants`) | 已修:非空才 `codec.encrypt(...)` 写入(**空 = 不修改**,不是清空);详情返回**脱敏值**(不给明文:这是全系统唯一一处加密存储的联系方式,可读且不留痕会与「看证件号明文必须留痕」自相矛盾)。**读路径必须容错**:解密失败按明文处理 + `log.warn`(历史行不做数据迁移 —— 迁移里拿不到 AES 密钥),否则详情页是"永久 500"这种看起来像系统坏了的故障 |
| F-114 | ⚠️ **租户的两个枚举值"能建不能改"**:`tenantType` 在 `update()` 里只放行 `1~3`,而 DDL / 实体注释 / 契约 / 前端下拉都是**4 值**(`1` 学校 `2` 认定机构 `3` 报名点 `4` 企业);`status` 只放行 `0/1`,而实体与 DDL 是 `0` 停用 / `1` 正常 / **`2` 试用** —— 前端的状态筛选按钮组里就有「试用」,选了永远是空列表 | 已修:`tenantType` 1~4、`status` 0/1/2,报错文案与实体注释逐字对齐;E2E 断言 `status=2` 与 `tenantType=4` 都能改 |
| F-115 | ⚠️ **租户的开通 / 修改 / 停用原先一条日志都不写**:`update()` 只有 `log.warn` / `log.info`(那是给运维看系统日志的,不是合规留痕)。F-1 的修改建议里明确点了「租户开通」是优先留痕点 —— 停用一所学校是这套系统里**影响面最大的单个动作** | 已修:新增模块 `TENANT`、动作 `CREATE_TENANT` / `UPDATE_TENANT` / `DISABLE_TENANT` / `ENABLE_TENANT`(停用与启用不并进 `UPDATE_TENANT`:出争议时第一个要看的就是这个区别);`before/after` 是 `k=v` 摘要(手机号只记"有没有设置"—— 密文写进日志既没用又难看,所以"换了一个号"改用请求里带没带它来补一条)。⚠️ 这些行的 `tenant_id` 是 **0**(平台身份),查"这所学校被谁停过"要用 `target_type=TENANT` + `target_id`,**不能用 `tenantId` 筛** —— 已写进 `MODULE_TENANT` 常量注释与契约 §6.8 |
| F-116 | ⚠️ **`expireBefore` 差一天**:`LocalDate.parse(...).atStartOfDay()` 与存进去的当天 `23:59:59` 比 → 「今天到期」的租户**查不出来**。同型的坑在 `LogController.parseTo` 上已经修过一次(那次是"查 9 月 30 日查不到当天")。另外格式不对时原先直接抛 50000 | 已修:`atTime(23,59,59)`(含当天);格式不对返回 `40001` 并说明格式 —— **刻意不学** `LogController.parseTo` 的"当没传":那两个是界面上的自由文本输入,静默忽略是为了"输错了也别让人看不到日志";而这个值只由界面按当前日期算出来,静默忽略筛选会让人以为"我按到期日筛过了" |
| F-117 | ⚠️ **`AUTO_CLOSE`(定时任务自动截止)是字符串字面量,不在常量表里**:`TaskService.autoClose()` 直接传 `"AUTO_CLOSE"`。所以"后端 `ACTION_*` 常量 ↔ 前端文案"这种检查**本来就扫不到它** —— 而它正是量最大的那类任务日志(库里 TASK 1548 行里的大头),界面上会**原样回显这个英文码**。(`STATUS_CHANGE` 兜底名同样是字面量) | 已修:提成 `OperationLogService.ACTION_AUTO_CLOSE` / `ACTION_STATUS_CHANGE` 并在 `TaskService` 用常量;新增检查 `check-wiring.py` 第 ⑩ 项(27 个动作码两端逐字核对),**已反向验证**(删掉前端一条文案 → 报红并 exit 1)。**教训**:这一族检查的价值取决于被检查的那一侧有没有被规范化 |
| F-118 | ⚠️ **日志页的「模块」下拉里有两个永远筛不到的项**:`moduleOptions` 只有 4 项(ROSTER/TASK/AUDIT/EXPORT),其中 `AUDIT` / `EXPORT` **后端一个写入点都没有**,而真正会写的 `FILE`(代传材料)与 `ROLE`(6 个角色动作)**不在下拉里** —— 选中死项只会得到空表,让人以为"这段时间没有审核动作" | 已修:`apis/xt/log.ts` 导出 `MODULE_OPTIONS`(TASK/ROSTER/FILE/ROLE/TENANT)与 `TARGET_TYPE_OPTIONS`,口径写明"只列后端真有写入点的模块"。⚠️ 顺带登记:`MODULE_AUDIT` / `MODULE_EXPORT` / `MODULE_USER` / `MODULE_JOIN` 四个常量当前**零调用点**,其中 `MODULE_JOIN` 是需求收敛后遗留的死常量 |
| F-119 | ⚠️ **`LogController` 的 `/logins` 描述与事实相反**:`@Operation` 里写着「一期的登录日志只有 PC 账号密码登录…目前不写这张表(见 F-31)」,而 **F-31 已闭环**(`WechatLoginLogger` 会写 `login_type=2`)。这是"注释从正确的当时留到了不正确之后"那类问题 | 已修:描述改为按 `loginType`(`PASSWORD` / `WECHAT`)说明,并写明 `location` **恒为 null**(没接 IP 库) |
| F-120 | 📌 **`request_param` / `duration_ms` 有列、无写入点**:全库 1758 行里两列都是 **0 行有值**(`max(length(extra_json))=45`、`max(length(user_agent))=125`,而这两列从来没被 `setXxx` 过)。它们也不在 `OperationLogMapper.listOperations` 的 SELECT 里 | **不做**(已写进契约 §13.4):要采集得引入 Servlet 过滤器或 AOP 抓请求体并脱敏,而 `OperationLogService` 的类注释已论证过"合规留痕不用 AOP";界面上不展示、也不做筛选。本轮只把**轻字段** `request_method` / `request_uri` / `extra_json` 补进列表 SELECT(实测一页 20 行约 2 KB),因此**不需要**为详情新开接口 |
| F-121 | 📌 **`docs/db/schema-postgresql.sql` 缺三张表**:只含 `sys_tenant`,没有 `sys_operation_log` / `sys_login_log` / `sys_tenant_usage_snapshot`(这三张只在 Flyway 迁移 `V2` / `V9` / `V17` 里)。按它建库会缺日志与用量快照 | **本轮不动**(登记待办):Flyway 才是权威,那份 DDL 是给 DBA 看的快照,补齐它属独立一件事 |
| F-122 | 📌 **只在平台身份下可达、且查业务表的接口 = 租户越界的风险面**:F-112 暴露的是一个**模式**,不是一处笔误 —— 凡是"平台专属权限码 + 查带 `tenant_id` 的表",都会因为 `ignoreTable()` 对平台返回 true 而**静默跨租户** | 本轮把 `tenant` 模块逐接口过了一遍(`list` / `detail` / `create` / `update` / `usage`),**只有 `usage` 统计业务表**,已修;`logs` 模块自己显式处理了范围(`logScopeTenantId`)。**留下的排查线索**:以后新增平台专属接口时,先问一句"它查的是不是带 tenant_id 的表" |
| F-123 | ⚠️ **登录日志没有租户列 → 平台视角下这些行"不属于任何学校"**:`LoginLogVO` 11 个字段里没有 `tenantId`,而平台运营看到的日志是**跨单位**的(`log:viewAll`)。于是"某个账号什么时候登进来的"这个问题答不完整 —— 筛得了单位,却看不出每行属于哪个单位。`sys_login_log.tenant_id` 从 V2 建表起就有(`NOT NULL DEFAULT 0`),只是 VO 没带出来 | 已修:`LoginLogVO` 加 `tenantId`、`LoginLogMapper.listLogins` 的 SELECT 加 `l.tenant_id AS tenantId`、契约 §13.4 同步;界面上登录日志表在「时间」后加「单位」列(`0` → 平台),**仅平台身份显示**(学校管理员自己就是那个单位,加了是噪音) |
| F-124 | ⚠️ **`LoginLogVO.userType` 的 Javadoc 与真实枚举错位**:写着「1 超管 / 2 管理员 / 3 教师 / 4 学生」,而权威口径(`BuiltinRole.UserType`、`UserService.USER_TYPE_NAMES`、库列注释、《03》§2.7)是 **1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生**。实测库里 `user_type=1` 的行就是 `platform` 账号。这是**同一个坑的第三次出现**(该错位枚举曾导致"把班主任当成社会人员拒掉") | 已修:Javadoc 改为正确枚举 + 写明"权威来源是 `BuiltinRole.UserType`,别按模板那套映射"。前端本来就用对了(复用 `USER_TYPE_NAMES`,没按评审意见里那句错的口径写),所以这处只影响读代码的人 |
---
+10 -3
View File
@@ -8,17 +8,21 @@
---
## §1 三种角色,三个入口
## §1 四种角色,四个入口
| 角色 | 入口 | 设备 | 主要做什么 |
|---|---|---|---|
| **学生** | 扫学校/班主任发的**二维码** | 手机(微信小程序 / H5) | 核对身份 → 填表 → 上传材料 → 提交 |
| **班主任** | 微信小程序(同一套码,登录后进教师端) | 手机 | 看本班进度、审本班材料、代传、催报 |
| **学校管理员** | 电脑浏览器打开管理端 | PC | 建任务、配表单与材料、导名册、终审、导出交付包 |
| **平台运营** | 电脑浏览器打开管理端(账号**不用填单位编码**) | PC | 开通学校、改到期与联系人、停用学校、查全平台日志 |
**⚠️ 学生与老师的小程序是同一个**,靠账号角色区分。学生第一次要"核验身份"(填姓名+证件号),
这一步就是**门禁**:名册里没有的人进不去(这是产品铁律,见 §5.1)。
**⚠️ 平台运营是唯一跨单位的角色**:它看的是**所有学校**的数据,所以在"组织与账号""收集管理"
这类本来就是单位内的页面上,它必须先选"**当前学校**"(顶栏那个切换),选之前看到的是混在一起的。
---
## §2 学生操作手册
@@ -199,7 +203,9 @@
| 改学校/院系/班级结构 | 组织与账号 → 组织架构(也支持 Excel 导入) |
| 加一个下拉选项(如多了种学历写法) | 配置 → 字典管理(**内置项不可改名**,要加就追加自定义项) |
| 给某类人配一套能力范围 | 组织与账号 → 角色与授权(见 §4.5) |
| 查"谁在什么时候看了证件号/导出了什么" | 平台运维 → 操作日志 |
| 查"谁在什么时候看了证件号/导出了什么" | 平台运维 → 日志(「操作日志」页签,点某一行看详情) |
| 查"某个账号什么时候登进来的、从哪登的" | 平台运维 → 日志 → 「登录日志」页签 |
| 开通一所新学校 / 改到期日 / 停用学校 | 平台运维 → 租户管理(**仅平台运营可见**;停用前会二次确认) |
| 查"通知到底发出去没有" | 收集管理 → 通知记录 |
| 看某个任务的分班进度 | 工作台 → 数据看板(选一个任务) |
@@ -261,7 +267,8 @@ A:系统上限是 3 次(防反复折腾)。到上限后由班主任/管理
3. **屏幕上的完整提示文字**(照抄,不要转述 —— 提示文案与错误码是一一对应的);
4. **发生时间**(精确到分钟)。
有了这四样,技术同学可以直接去「操作日志」与「通知记录」里定位。
有了这四样,技术同学可以直接去「日志」(平台运维菜单,含操作日志与登录日志两个页签)
与「通知记录」里定位 —— 日志详情里能拿到 `traceId`,那是后端排查时最有用的一个线索。
---
+145 -2
View File
@@ -2847,7 +2847,7 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
| 手段 | 结果 |
|---|---|
| `scripts/e2e-m10-role.py`(新增) | **77/77**:改授权后**同一个 token** 立刻生效 / 停用立刻失效 / 内置角色只读 / 越权与自锁 / 删除守卫 `40045` / 内置角色权限以代码为准 + 同步回填幂等 / 跨租户 |
| `python scripts/verify-all.py --with-e2e` | **9 项全过 / 0 失败 / 0 跳过**;**37 个脚本 / 1595 条断言 / 0 失败**(2026-10 收敛删掉 2 个脚本后已重跑填数) |
| `python scripts/verify-all.py --with-e2e` | **9 项全过 / 0 失败 / 0 跳过**;**37 个脚本 / 1617 条断言 / 0 失败**(2026-10 收敛删掉 2 个脚本后已重跑填数) |
| 浏览器实测(真点) | 新建自定义角色 → 账号页角色候选出现它(且平台角色已不在候选里)→ 改权限落库(`sys_role_permission` 逐字核对)→ 删除(软删 + 授权/菜单行一并软删);截图留证 |
| 机械校验 | `check-api-parity`(契约 **119** / 已实现 118 / 缺口 1)、`verify-docs` 22/22(原型 **29** 屏)、`check-wiring` 未接线 0、`check-entity-columns` 28 实体一致、`check-manual` 0 错 |
| 前端 | `pnpm typecheck` 0 错、`eslint` 0 错(`views/role/**`、`apis/xt/role.ts` 与改动过的账号页) |
@@ -2892,7 +2892,7 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
| PC 业务菜单 | 12 | **10** |
| 小程序页面 | 10 | **9** |
| 原型屏 | 32 | **29**(学生 10 / 班主任 9 / 管理端 10) |
| E2E 脚本 | 39 | **37**(删 `e2e-m2-join.py`、`e2e-m7-cleanup.py`);断言 1698 → **1595 条** |
| E2E 脚本 | 39 | **37**(删 `e2e-m2-join.py`、`e2e-m7-cleanup.py`);断言 1698 → **1617 条** |
> ⚠️ **两个容易算错的数**(都踩过):
> ① **契约是 119,不是 116**。116 是"删 13 个**代码端点**"(cleanup 8 + join 5),
@@ -3183,4 +3183,147 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
根 `AGENTS.md` 的接线检查说明补上第 ⑨ 项。
## §一·三十九 平台运营的租户管理 + 日志详情(2026-10)
**起因**:用户要求「设计开发平台运营人员对租户的管理功能,还有日志的详情查看功能」。
### 一、口径变更(**这一节最先要看的东西**)
`frontend-pc/src/views/tenant/index.vue` 原先头部写着:
> ⚠️ 列表**没有操作列**:不提供编辑 / 停用入口(客户 2026-10 走查确认的口径)
**这条口径本轮被用户推翻**(选的是「全量:详情 + 用量 + 编辑 + 停用/启用」)。
所以列表现在有操作列(详情 / 编辑 / 停用·启用),并且新增了详情抽屉与编辑弹窗。
⚠️ **变更理由要一起记下来,否则下一个人读到旧注释会以为是 rebase 丢的**:
这条旧口径的动机是"少一个误操作入口",而运营真正需要的是**改到期日**(续费)
与**停用学校**(欠费 / 退租)—— 这两个动作没有入口时,只能改数据库。
另外三条已确认的口径(都是"不做什么",比做什么更容易被顺手破坏):
**套餐只读**(全库没有 `sys_package` 表,`package_id` 是无外键裸 bigint)、
**不补原型屏**(`docs/prototype/admin-web.html` 保持 10 屏,`合计屏数 : 29` 不动)、
**不新增后端接口**(契约 119 不变)。
### 二、本轮最关键的一件事:用量接口算的是「全平台」的数(F-112)
**这不是顺手发现的,是审查计划时逐条对照代码挖出来的**,而且它比原计划里所有 UI 改动都严重:
- 调用方一定是平台运营(`/tenants/{id}/usage` 挂 `tenant:view`,而它在
`PermissionDef.platformOnlyCodes()` 里);平台运营的 `tenantId` 是 **0**;
- 而 `MybatisPlusConfig.FillTenantLineHandler.ignoreTable()` 对平台身份**直接返回 true**
—— **整张表的租户过滤被关掉**;
- 于是 `TenantUsageService.distinctStudents()`(查 `collect_roster`)与存储量统计
(查 `collect_file`)都**不带租户条件** → 返回全平台的学生数与存储占用,
却标着某一所学校的名字。
**⚠️ 它在本地完全看不出来**:实测库里 `collect_roster` 34 行、`collect_file` 22 行
**全在 `tenant_id=1`** —— 只有一所学校有数据时,错误值与正确值**完全相等**。
`ActingTenantResolver` 的类注释描述的正是这个陷阱("当只有一所学校有数据时,
它和学校管理员看到的一模一样")。
**修法与验证**:
- 统计段包进 `TenantScope.callAs(tenantId, 0L, Set.of(), ...)`(本仓既有机制,
`OpenDataService` / `OpenPushService` 在用)。**没有**手写 `tenant_id` 条件 ——
那等于放弃拦截器的保护;切走上下文之后 `isPlatformAdmin()` 变 false,过滤自动恢复。
- E2E **必须有夹具**才证伪得了:`e2e-m8-orgfile.py` 往新建的 E2E 租户**直插一行
`collect_roster`**(该表 NOT NULL 且无默认值的列只有 5 个,不需要走导入夹具),
再断言"接口值 == 库里 `where tenant_id=1` 的去重数",并**额外断言
`全平台去重数 > 一号学校去重数`** —— 否则这条断言是空转的(这次实测两值确实不等)。
结果 **74/74**。
### 三、另外四条后端缺陷(都在同一次改动里修掉)
| # | 缺陷 | 后果 |
|---|---|---|
| F-113 | `PUT /tenants/{id}` 把手机号**明文**写进加密列(`create` 是加密的) | `AesGcmCipher.decrypt` 解不开 Base64 直接抛 → **存过一次电话,这所学校详情页永远打不开**。实测 `id=1` 存的就是明文 `13800138000` |
| F-114 | `tenantType` 只放行 1~3(实体/DDL/契约/前端都是 **4 值**);`status` 只放行 0/1(实体有 **2 试用**,前端筛选按钮里就有) | 这两类值**能建不能改**,选了「试用」永远是空列表 |
| F-115 | 开通 / 修改 / 停用租户**一条日志都不写**(只有给运维看的 `log.warn`) | 停用一所学校是这套系统里影响面最大的单个动作,却查不到是谁干的 |
| F-116 | `expireBefore` 用 `atStartOfDay()` 比 → **差一天**,「今天到期」查不出来 | 续费提醒那类用法正好踩中 |
对 F-113 的读路径要特别说一句:**必须容错**(解密失败按明文处理 + `log.warn`),
否则故障形态是"详情页永久 500",看起来像系统坏了而不是"库里有一行旧数据"。
**历史明文行不做数据迁移** —— 迁移里拿不到 AES 密钥。
留痕口径(**容易记错**):新增 `MODULE_TENANT` 与 4 个动作码,
`DISABLE_TENANT` / `ENABLE_TENANT` **不并进** `UPDATE_TENANT`(出争议时第一个要看的就是这个区别)。
⚠️ 这些日志行的 `tenant_id` 是 **0**(写日志用的是**操作者**的上下文),
查"这所学校被谁停过"要用 `target_type=TENANT` + `target_id`,
**不能用 `tenantId` 筛** —— 这条已经写进常量注释与契约 §6.8,因为反直觉。
### 四、日志详情:为什么不新增接口
详情要的 `request_method` / `request_uri` / `extra_json` 都是**轻字段**
(实测 `max(length(extra_json))=45`、`max(length(request_uri))=31`、`max(length(user_agent))=125`,
一页 20 行约 2 KB),所以直接把三列补进 `listOperations` 的 SELECT 就行。
⚠️ **"别让重字段驮在列表上"这个理由在这里不成立**:真正重的 `request_param` /
`duration_ms` **全库 1758 行里 0 行有值**(从来没有 `setXxx` 过)。
新增 `GET /logs/operations/{id}` 反而要改契约 119 与 4 份文档。**所以没加。**
### 五、`AUTO_CLOSE` 与新的检查 ⑩(**这一族检查的价值取决于被检查的那一侧**)
日志页的 `actionText()` **查不到就原样回显那个码**。而 `AUTO_CLOSE`(定时任务自动截止)
**根本不在常量表里** —— 它在 `TaskService.autoClose()` 里是**字符串字面量**写的。
也就是说"后端常量 ↔ 前端清单"这种检查**本来就扫不到它**,而它正是量最大的那类任务日志。
所以这件事要做两步:① 把字面量提成常量(连带 `STATUS_CHANGE` 兜底名);
② 新增 `check-wiring.py` 第 ⑩ 项(27 个动作码两端逐字核对)。
**已反向验证**:删掉前端一条文案 → 报红 + exit 1。
⚠️ 写这个检查时踩到的坑:同文件里 `MODULE_OPTIONS` / `TARGET_TYPE_OPTIONS` 也是
`{ value: 'TASK' }` 的形状,**整文件扫会把模块名当成动作码收进来** —— 所以只从
`OPERATION_ACTIONS` 这个数组里取。
### 六、顺带修掉的五处
- **F-117**:上面的 `AUTO_CLOSE` 字面量。
- **F-118**:日志页「模块」下拉里 `AUDIT` / `EXPORT` **后端一个写入点都没有**,
而真会写的 `FILE` / `ROLE` **不在下拉里** —— 换成了导出的 `MODULE_OPTIONS`
(口径:**只列后端真有写入点的**,否则选中死项只会得到空表,让人以为"这段时间没有审核动作")。
- **F-119**:`LogController` 的 `/logins` 描述与事实相反(还写着"目前不写这张表(见 F-31)",
而 F-31 已闭环)。
- **F-123**:**登录日志没有租户列** —— 平台运营看到的日志是跨单位的(`log:viewAll`),
而 `LoginLogVO` 里没有 `tenantId`,于是筛得了单位却看不出每行属于哪个单位。
`sys_login_log.tenant_id` 从 V2 建表起就有,只是 VO 没带出来。
已修(VO + mapper SELECT + 契约 + 界面加「单位」列,**仅平台身份显示**)。
- **F-124**:`LoginLogVO.userType` 的 Javadoc 写着「1 超管 / 2 管理员 / 3 教师 / 4 学生」,
与权威口径(`1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生)错位 ——
**这是同一个坑的第三次出现**(该错位枚举曾导致"把班主任当成社会人员拒掉")。
已修,并在注释里写明权威来源。⚠️ 前端当时**没有**按评审意见里那句错的口径写、
而是复用了 `USER_TYPE_NAMES` —— 这处只影响读代码的人。
### 七、前端一处刻意的选择:页面内 Tab 用**分段按钮**而不是 `NTabs`
日志页要做「操作日志 / 登录日志」两个页签,选了工具条上的分段按钮组,原因有两条:
① `NTabPane` 的内容要额外 flex 化才能维持 `.page-card` 的高度链,
否则表格高度算成 0、**一行都看不见**(本仓踩过);
② naive 2.45.3 的 `NTabs` 用 `tab="..."` prop 只渲染注释节点、必须用 `#tab` 插槽。
按钮组在视觉上就是两个页签,且不动高度链。**这条已写进 `frontend-pc/AGENTS.md`。**
### 八、验证
| 手段 | 结果 |
|---|---|
| `e2e-m8-orgfile.py` | **74/74**(租户口径断言现共 74 条:脱敏 / 密文 / 留痕 / 状态流转 / 4 值类型 / 到期日边界 / 用量越界) |
| `e2e-m5-logs.py` | **44/44**(回归:日志留痕、脱敏、`targetType`+`targetId`、`from`/`to` 当天边界、越权 403) |
| `check-wiring.py` | ⑦⑧⑨⑩ 全绿;**⑩ 反向验证过**(删一条文案 → 红 + exit 1) |
| `check-api-parity.py` | 契约 119 / 已实现 118 / 缺口 1(**没变**,符合"不加接口"的决定) |
| `verify-docs.py` | 22/22 |
| `pnpm typecheck` / `pnpm lint` | 0 错 |
⚠️ **后端改了必须重新打包重启再验**:本轮有两次"改完忘了重打包"的空转风险
(`LoginLogVO` 加字段那次、以及 `AUTO_CLOSE` 提常量那次)。
判据是**看返回体里有没有新字段**,而不是"接口通了就算"——
`/logs/logins` 返回 `tenantId`、`/tenants/1` 返回 `138****8000`(而不是 500),
这两条同时成立才说明跑的是新 jar。
### 九、约定同步
`frontend-pc/AGENTS.md` 与 `.agents/skills/code-vue/SKILL.md` 新增两条:
「详情用 `NDrawer` + `NDescriptions`,**不要为详情新开路由**」、
「页面内页签用工具条分段按钮,写明高度链的原因」;
根 `AGENTS.md` 的接线检查说明补第 ⑩ 项。
+1 -1
View File
@@ -17,7 +17,7 @@
├── code-ts/SKILL.md # TS 命名 / 导入 / 显式 import
├── code-vue/SKILL.md # SFC 写法与模板约定
├── code-css/SKILL.md # SCSS 与样式边界
├── project-guide/SKILL.md # 技术栈 / 目录 / 七条最容易写错的约定
├── project-guide/SKILL.md # 技术栈 / 目录 / 九条最容易写错的约定
├── project-crud/SKILL.md # 列表 + 表单的标准写法
├── project-api/SKILL.md # 接口调用(没有 Mock)
└── git-commit/SKILL.md # 提交规范(与框架无关)
@@ -63,6 +63,29 @@ naive-ui 2.45.3 里,`tab="文字"` **属性**写法渲染不出任何文字(
原因:属性值走 `normalizeVNode(() => render(mergedTab ?? name))`,而这个 thunk 返回
Text VNode,被当成块 `createBlock(vnode)` 处理;插槽那条分支返回数组,所以正常。
### 页面内的「页签」优先用工具条上的分段按钮
如果只是在一屏里切换两张表(例:日志页的「操作日志 / 登录日志」),
**用两个 `NButton` 做分段控件,不要用 `NTabs`**:
- `NTabPane` 的内容要额外 flex 化才能维持 `.page-card` 的高度链,
否则表格高度算成 0、**一行数据都看不见**(本仓踩过这个坑);
- 上面那个 `tab="..."` 的属性坑也在同一条路上,能绕开就绕开。
两个按钮在视觉上就是页签,而且不动高度链。各页签的 `list` / `total` / `pageNum` /
`loading` / 筛选条件要**分开维护**,切换时重置到第 1 页。
## 详情用抽屉,不要为详情新开路由
`NDrawer` + `NDrawerContent` + `NDescriptions` / `NDescriptionsItem`,
模板抄 `src/views/audit/AuditDetailDrawer.vue`。列表里的入口是**操作列**里的一个
`text` 按钮(`size="small"`)。
⚠️ 抽屉打开时该调哪个接口要看清楚:**列表接口可能刻意不返回某个字段**。
例:`/api/v1/tenants` 的 `contactPhone` **恒为 `null`**(联系电话以密文入库,
列表不能成为批量拉取联系方式的通道),脱敏值只有 `/api/v1/tenants/{id}` 给 ——
拿行数据凑详情,界面上会显示成"没有联系电话",而这种错看起来像数据问题,不像代码问题。
## 权限与状态色
```ts
@@ -1,6 +1,6 @@
---
name: project-guide
description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及七条最容易写错的约定。开始任何 PC 端改动前加载本技能。
description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及九条最容易写错的约定。开始任何 PC 端改动前加载本技能。
---
# 项目总览(PC 管理端)
@@ -42,7 +42,7 @@ src/
- 平台运营要**先选学校**(`stores/acting-tenant.ts` → 请求头 `X-Tenant-Id`);
不选就不发请求,否则会把各租户数据混在一起且看起来正常。
## 七条最容易写错的约定
## 九条最容易写错的约定
1. **没有 auto-import**:组件与 API 全显式 import,标签写 **PascalCase**。
漏 import 是**静默故障**(未知自定义元素 → 样式整块丢掉,typecheck/lint 全绿);
@@ -60,6 +60,13 @@ src/
**不要换回文字字形标**;页签用 `public/favicon.svg`(加粗版),改一个要同步另一个。
⚠️ SVG 注释里**不能出现连续两个减号**(XML 规范禁止)→ 整个文件 not well-formed,
图片裂开而控制台一句话都不报;`python scripts/check-wiring.py` 第 ⑨ 项会核对。
8. **详情用抽屉、不新开路由**:`NDrawer` + `NDrawerContent` + `NDescriptions`,
抄 `src/views/audit/AuditDetailDrawer.vue`;入口是操作列里的 `text` 按钮(`size="small"`)。
⚠️ 先确认**列表接口给没给详情要的字段** —— 例:`/tenants` 的 `contactPhone` 恒为 `null`,
脱敏值只有 `/tenants/{id}` 给;拿行数据凑详情会显示成"没有联系电话"。
9. **页面内的页签用工具条分段按钮,不要 `NTabs`**:`NTabPane` 要额外 flex 化才能维持
`.page-card` 高度链,否则表格高度算成 0、**一行都看不见**(本仓踩过);
且第 5 条那个 `tab="..."` 的坑也在这条路上。各页签的 list/page 状态分开维护。
## 开发
+13 -1
View File
@@ -43,7 +43,7 @@ pnpm lint # ESLint(@antfu/eslint-config)
- 接口模块在 `src/apis/xt/`(按业务域分文件),统一从 `@/apis/xt` 或 `@/apis/xt/<域>` 引
- 演示账号:`admin / Admin@123` + 租户码 `demo`(⚠️ **不是** Gi 模板 README 里的 `admin/123456`)
## 七条最容易写错的约定
## 九条最容易写错的约定
1. **没有 auto-import**:naive-ui 组件、Vue API、工具函数**全部显式 `import`**,
标签统一写 **PascalCase**(`<NButton>`)。
@@ -70,3 +70,15 @@ pnpm lint # ESLint(@antfu/eslint-config)
⚠️ **SVG 的注释里不能出现连续两个减号**(XML 规范禁止):会让整个文件 not well-formed,
表现是图片裂开、**控制台一句话都不报**,typecheck/lint 也全绿。
`python scripts/check-wiring.py` 第 ⑨ 项会核对这件事。
8. **详情用抽屉,不要为详情新开路由**:`NDrawer` + `NDrawerContent` + `NDescriptions`,
模板抄 `src/views/audit/AuditDetailDrawer.vue`。列表页的"详情"入口是操作列里的一个
`text` 按钮(`size="small"`,见第 6 条的行内按钮约定)。
⚠️ 抽屉打开时该调哪个接口要看清楚:**列表接口可能刻意不返回某个字段**
(例:`/tenants` 的 `contactPhone` 恒为 `null`,脱敏值只有 `/tenants/{id}` 给),
拿行数据凑详情会显示成"没有联系电话"。
9. **页面内的页签用工具条上的分段按钮,不要用 `NTabs` / `NTabPane`**:
① `NTabPane` 的内容要额外 flex 化才能维持 `.page-card` 的高度链,
否则表格高度算成 0、**一行数据都看不见**(本仓踩过);
② 见第 5 条,naive 2.45.3 的 `tab="..."` 属性写法本身也是坏的。
两个按钮做的分段控件在视觉上就是页签,且不动高度链。
多个页签各自的 `list` / `total` / `pageNum` / `loading` 要分开维护,切换时重置到第 1 页。
+65 -5
View File
@@ -15,9 +15,11 @@
*
* ## ⚠️ 一期的已知边界(界面上要说明,不能让人以为系统坏了)
*
* - 登录日志**只有 PC 账号密码登录**;学生从微信进来目前不写这张表;
* - `location`(IP 归属地)**恒为 null**:没接 IP 库,宁可留空也不编一个;
* - IP 取的是代理链第一跳,**可以伪造** —— 用于排查,不作合规证据。
* 真正的证据是"哪个账号在什么时候做了什么"。
* - `request_param` / `duration_ms` 两列**没有任何写入点**(全库 0 行有值),
* 所以界面上不展示、也不做筛选,别为它们写查询。
*/
import http from '@/utils/http'
@@ -35,6 +37,12 @@ export interface OperationLog {
afterVal?: string
ip?: string
userAgent?: string
/** 请求方法 GET / POST / PUT / DELETE —— 详情里展示 */
requestMethod?: string
/** 请求路径(不含 query string) */
requestUri?: string
/** 补充上下文(JSON 串);不是每条都有,可能是合法的 `null` */
extraJson?: string
bizCode?: number
traceId?: string
createTime?: string
@@ -47,6 +55,13 @@ export interface OperationLogPage {
export interface LoginLog {
id: number
/**
* ⚠️ `0` = **平台**(不是 null)。
*
* 平台运营看到的是**跨单位**的登录日志,没有它分不清一行是哪所学校/单位的
* (《03-接口设计》§13.4 `GET /logs/logins`:`tenantId`(`0` = 平台)**必须有**)。
*/
tenantId?: number
userId?: number
userName?: string
userType?: number
@@ -66,6 +81,8 @@ export interface LoginLogPage {
}
export function getOperationLogs(params: {
/** ⚠️ 只有 `log:viewAll`(平台运营)能指定别的租户;学校管理员传别人的租户号会 40301 */
tenantId?: number
operatorId?: number
module?: string
action?: string
@@ -80,6 +97,7 @@ export function getOperationLogs(params: {
}
export function getLoginLogs(params: {
tenantId?: number
userId?: number
userType?: number
result?: boolean
@@ -91,7 +109,12 @@ export function getLoginLogs(params: {
return http.get<LoginLogPage>('/logs/logins', params)
}
/** 敏感动作 → 文案。**与后端 OperationLogService 的常量一致**。 */
/**
* 动作码 → 文案。
*
* ⚠️ **必须与后端 `OperationLogService` 的 `ACTION_*` 常量逐字一致**(`check-wiring.py` 第 ⑩ 项双向核对)。
* 缺一个不会报错 —— `actionText` 会原样回显那个码,看起来像"系统没翻译"。
*/
export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
{ value: 'UPDATE_IDCARD', label: '改名册证件号' },
{ value: 'UPDATE_ROSTER_NAME', label: '改名册姓名' },
@@ -100,9 +123,13 @@ export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
{ value: 'PAUSE_TASK', label: '暂停任务' },
{ value: 'RESUME_TASK', label: '恢复任务' },
{ value: 'CLOSE_TASK', label: '截止任务' },
{ value: 'AUTO_CLOSE', label: '自动截止任务' },
{ value: 'STATUS_CHANGE', label: '改任务状态' },
{ value: 'ARCHIVE_TASK', label: '归档任务' },
{ value: 'DELETE_TASK', label: '删除已归档任务' },
{ value: 'REVOKE_AUDIT', label: '撤回审核' },
{ value: 'EXPORT_CREATE', label: '创建导出' },
{ value: 'EXPORT_DOWNLOAD', label: '下载导出文件' },
{ value: 'PROXY_UPLOAD', label: '代传材料' },
{ value: 'VIEW_IDCARD_FULL', label: '查看完整证件号' },
{ value: 'CONFIG_COMPRESS', label: '改压缩配置' },
@@ -113,18 +140,51 @@ export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
{ value: 'DELETE_ROLE', label: '删除角色' },
{ value: 'GRANT_ROLE', label: '改角色授权' },
{ value: 'UPDATE_ROLE_MENU', label: '改角色菜单' },
{ value: 'TOGGLE_ROLE', label: '停用/启用角色' }
{ value: 'TOGGLE_ROLE', label: '停用/启用角色' },
{ value: 'CREATE_TENANT', label: '开通学校' },
{ value: 'UPDATE_TENANT', label: '修改学校' },
{ value: 'DISABLE_TENANT', label: '停用学校' },
{ value: 'ENABLE_TENANT', label: '启用学校' }
]
export function actionText(v?: string) {
return OPERATION_ACTIONS.find((a) => a.value === v)?.label || v || '—'
}
/**
* 模块筛选的可选项。
*
* ⚠️ **只列后端真的会写的模块**(有 `OperationLogService.MODULE_*` 调用点)。
* `MODULE_AUDIT` / `MODULE_EXPORT` / `MODULE_USER` / `MODULE_JOIN` 四个常量**一个调用点都没有** ——
* 把它们列进下拉,选中后只会得到一张空表,让人以为"这段时间没有审核动作"。
* (加模块时先确认后端有写入口,再往这里加。)
*/
export const MODULE_OPTIONS: Array<{ value: string, label: string }> = [
{ value: 'TASK', label: '任务' },
{ value: 'ROSTER', label: '名册' },
{ value: 'FILE', label: '材料' },
{ value: 'ROLE', label: '角色与授权' },
{ value: 'TENANT', label: '租户(平台)' }
]
/** 对象类型筛选的可选项。口径同 `MODULE_OPTIONS`:只列真有写入点的。 */
export const TARGET_TYPE_OPTIONS: Array<{ value: string, label: string }> = [
{ value: 'TASK', label: '任务' },
{ value: 'ROSTER', label: '名册项' },
{ value: 'ROLE', label: '角色' },
{ value: 'TENANT', label: '租户' }
]
export const LOGIN_TYPES: Record<string, string> = {
PASSWORD: '账号密码',
WECHAT: '微信授权',
PHONE: '手机号快捷'
}
/** 需要重点看的动作(改证件号、看明文证件号)——列表里高亮。 */
export const SENSITIVE_ACTIONS = ['UPDATE_IDCARD', 'VIEW_IDCARD_FULL', 'DELETE_ROSTER']
/**
* 需要重点看的动作 —— 列表里高亮。
*
* `DISABLE_TENANT`:停用一所学校 = 该校所有学生与老师**立刻登不进来**,
* 是单个动作里影响面最大的一类,必须一眼看见。
*/
export const SENSITIVE_ACTIONS = ['UPDATE_IDCARD', 'VIEW_IDCARD_FULL', 'DELETE_ROSTER', 'DISABLE_TENANT']
+60
View File
@@ -59,6 +59,66 @@ export function getTenantDetail(id: number) {
return http.get<TenantVO>(`/tenants/${id}`)
}
/**
* 修改租户(§6.8 的 `PUT /tenants/{id}`)。
*
* ⚠️ 三个「只在这里能看出来的口径」:
* - `tenantCode` **不可修改**(不在请求体里):它出现在小程序扫码落地页与对接文档里;
* - `contactPhone` **留空 = 不修改**(后端非空才写,且写进去的是密文);
* - `status` 可以给 `2`(试用)—— 与 `TENANT_STATUS` 的三个值一致。
*/
export interface TenantUpdateReq {
tenantName?: string
tenantType?: number
contactName?: string
/** 留空 = 不修改 */
contactPhone?: string
packageId?: number
expireTime?: string
/** 0 已停用 / 1 正常 / 2 试用 */
status?: number
}
export function updateTenant(id: number, data: TenantUpdateReq) {
return http.put<TenantVO>(`/tenants/${id}`, data)
}
/** 用量统计(§6.7)。`ocrCount` / `smsCount` 一期**恒为 0**,见响应里的 `notes`。 */
export interface TenantUsage {
tenantId: number
tenantName: string
/** 统计周期,回显用:`2026-09` 或 `2026Q3` */
period: string
/** 实时值:按证件号去重的人数 */
studentCount: number
/** 实时值:材料规范件的体积合计(MB) */
storageMb: number
ocrCount: number
smsCount: number
quota: {
studentLimit: number
storageLimitMb: number
ocrLimit: number
smsLimit: number
}
alerts: Array<{ type: string, level: number, ratio: string, used: number, limit: number }>
/** 某几项为什么是 0(避免把"没通道"当成"没用量")—— 界面要如实展示 */
notes: string[]
}
/**
* 用量统计。
*
* ⚠️ `studentCount` / `storageMb` 是**实时**算的,`period` 只影响计费项(OCR / 短信)
* 的统计周期标签 —— 界面上必须写清楚,否则会以为「2026Q3 的学生数」是历史值。
*
* @param id 租户 id
* @param period `2026-09`(月)或 `2026Q3`(季);不传 = 当月
*/
export function getTenantUsage(id: number, period?: string) {
return http.get<TenantUsage>(`/tenants/${id}/usage`, period ? { period } : {})
}
/**
* 开通租户:一次请求完成「建租户 + 发放 6 个内置角色 + 创建首个管理员」。
* 返回的 `adminInitialPassword` 只在这一次响应里,界面必须让用户当场记下。
+4 -1
View File
@@ -142,7 +142,10 @@ export const XT_MENUS: XtMenu[] = [
},
{
key: 'log',
title: '操作日志',
// ⚠️ `key` 保持 'log' 不动(后端 BuiltinRole 的菜单键与 check-wiring.py 第 ⑥ 项都按它核对,
// 改 key 会让平台角色的菜单静默少一项);改的只是**显示名** ——
// 这一页现在有「操作日志 / 登录日志」两个页签,叫「操作日志」会名不副实。
title: '日志',
icon: 'log',
path: '/platform/log',
component: 'log/index',
@@ -0,0 +1,385 @@
<script setup lang="ts">
import type { OperationLog } from '@/apis/xt/log'
import {
NButton,
NDescriptions,
NDescriptionsItem,
NDrawer,
NDrawerContent,
NTag,
useMessage
} from 'naive-ui'
import { computed, ref } from 'vue'
import { actionText, MODULE_OPTIONS, SENSITIVE_ACTIONS } from '@/apis/xt/log'
/**
* 操作日志详情。
*
* 输入是**一整行 `OperationLog`**(列表已经把它全查回来了),所以这里**不再打接口** ——
* 详情只是"把列表里被截断/没展示的字段摊开",多点一次网络请求没有任何信息增益。
*
* ## 为什么几处刻意"不截断"
*
* 这一屏是排查用的:
* - `userAgent` 要能整条读完 —— 兼容性问题全在尾巴上(内核版本、各家小程序的 webview 标识),
* 截成省略号等于把最有用的一段藏掉;
* - `beforeVal → afterVal` 要能整段读完 —— 列表里那是单行摘要,这里再截一次就白点进来了。
* 两者统一 `word-break: break-all`。
*
* ## 为什么灰字写「请求参数 / 耗时:本版本未采集」
*
* 库里 `request_param` / `duration_ms` 两列**没有任何写入点**(全库 0 行有值,见 `apis/xt/log.ts` 的说明)。
* 空着会让人以为"这条日志恰好没采到",从而一直等一个永远不会出现的字段 —— 如实说没有,比留白有用。
*/
defineOptions({ name: 'LogDetailDrawer' })
const props = withDefaults(defineProps<{
/**
* 租户 id → 单位名。
*
* 列表页(平台身份)已经查过一次,这里复用同一个映射,**不为一个名字再打一次 `/tenants`**;
* 拿不到时退回 `#id` —— 详情页显示 `#3` 不好看,但比编一个名字安全。
*/
tenantNames?: Record<number, string>
}>(), { tenantNames: () => ({}) })
const message = useMessage()
const visible = ref(false)
const log = ref<OperationLog | null>(null)
/** 动作标签:敏感动作(改证件号 / 看完整证件号 / 删名册项 / 停用学校)在列表与详情里**同一套口径** */
const actTag = computed<{ text: string, type: 'info' | 'error' }>(() => {
const action = log.value?.action
return {
text: actionText(action),
type: action && SENSITIVE_ACTIONS.includes(action) ? 'error' : 'info'
}
})
/**
* 单位(列表里那一列的口径,与筛选下拉同词)。
*
* ⚠️ 平台自己的动作(开通/停用学校这类)写的是 `tenant_id = 0`,**不是 null** ——
* 直接显示会变成莫名其妙的 `#0`。
*/
const tenantText = computed(() => {
const id = log.value?.tenantId
if (id === 0) {
return '平台'
}
if (id == null) {
return '—'
}
return props.tenantNames[id] ?? `#${id}`
})
/** 操作人:详情里**带上 `#id`**(与列表相反)—— 排查时要能对上库里那一行 */
const operatorText = computed(() => {
const row = log.value
if (!row) {
return '—'
}
const id = row.operatorId != null ? `#${row.operatorId}` : ''
return [row.operatorName, id].filter(Boolean).join(' ') || '—'
})
/** 模块:查不到就**原样回显那个码**,不编中文名(否则和后端日志里的码对不上) */
const moduleText = computed(() => {
const m = log.value?.module
if (!m) {
return '—'
}
return MODULE_OPTIONS.find((o) => o.value === m)?.label ?? m
})
const targetText = computed(() => {
const row = log.value
if (!row?.targetType) {
return '—'
}
return `${row.targetType} #${row.targetId ?? '—'}`
})
/**
* `extraJson` 美化。
*
* 解析失败就**原样展示**:日志里最忌讳"这一段我帮你藏起来了" ——
* 后端将来多写一种格式时,原样展示至少还能被人肉读出来。
*/
const extraPretty = computed(() => {
const raw = log.value?.extraJson
if (!raw) {
return ''
}
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
})
/** 时间到**秒**:排查时"12:00"与"12:00:31"是两件事 */
function fmtTime(v?: string): string {
return v ? v.replace('T', ' ').slice(0, 19) : '—'
}
async function copy(text: string | undefined, label: string): Promise<void> {
if (!text) {
message.warning(`${label}为空,没有可复制的内容`)
return
}
try {
await navigator.clipboard.writeText(text)
message.success(`已复制${label}`)
} catch {
message.warning('浏览器不允许自动复制,请手动选中后复制')
}
}
function open(row: OperationLog): void {
log.value = row
visible.value = true
}
defineExpose({ open })
</script>
<template>
<NDrawer v-model:show="visible" :width="760" placement="right">
<NDrawerContent :native-scrollbar="false" closable>
<template #header>
<span>日志详情</span>
<NTag v-if="log" class="logd__head-tag" size="small" :bordered="false" :type="actTag.type">
{{ actTag.text }}
</NTag>
<span v-if="log" class="logd__head-sub">{{ fmtTime(log.createTime) }}</span>
</template>
<template v-if="log">
<div class="section-title logd__sec">
基本信息
</div>
<NDescriptions :column="2" size="small" bordered>
<NDescriptionsItem label="时间">
{{ fmtTime(log.createTime) }}
</NDescriptionsItem>
<NDescriptionsItem label="日志 ID">
{{ log.id }}
</NDescriptionsItem>
<NDescriptionsItem label="单位">
{{ tenantText }}
</NDescriptionsItem>
<NDescriptionsItem label="操作人">
{{ operatorText }}
</NDescriptionsItem>
<NDescriptionsItem label="模块">
{{ moduleText }}
</NDescriptionsItem>
<NDescriptionsItem label="动作">
<NTag size="small" :bordered="false" :type="actTag.type">
{{ actTag.text }}
</NTag>
</NDescriptionsItem>
<NDescriptionsItem label="对象" :span="2">
{{ targetText }}
</NDescriptionsItem>
</NDescriptions>
<div class="section-title logd__sec">
变更前后
<span class="section-title__note">
证件号/手机号已在服务端脱敏;超过 512 字符的值在服务端就被截断了
</span>
</div>
<div class="logd__diff">
<div class="logd__col">
<div class="logd__col-head">
<span class="logd__col-label">变更前</span>
<NButton text size="small" :disabled="!log.beforeVal" @click="copy(log.beforeVal, '变更前')">
复制
</NButton>
</div>
<div class="logd__val" :class="{ 'logd__val--empty': !log.beforeVal }">
{{ log.beforeVal || '—' }}
</div>
</div>
<div class="logd__col">
<div class="logd__col-head">
<span class="logd__col-label">变更后</span>
<NButton text size="small" :disabled="!log.afterVal" @click="copy(log.afterVal, '变更后')">
复制
</NButton>
</div>
<div class="logd__val" :class="{ 'logd__val--empty': !log.afterVal }">
{{ log.afterVal || '—' }}
</div>
</div>
</div>
<div class="section-title logd__sec">
请求
</div>
<div class="logd__req">
<span class="logd__method">{{ log.requestMethod || '—' }}</span>
<span class="logd__uri">{{ log.requestUri || '—' }}</span>
</div>
<!-- 如实说没采集:空着会被当成"这条恰好没采到",然后一直等一个不存在的字段 -->
<div class="logd__note">
请求参数 / 耗时:本版本未采集
</div>
<div class="section-title logd__sec">
来源
</div>
<NDescriptions :column="1" size="small" bordered>
<NDescriptionsItem label="IP">
{{ log.ip || '—' }}
</NDescriptionsItem>
<NDescriptionsItem label="User-Agent">
<!-- 整条展示、不截断:兼容性问题全在 UA 的尾巴上 -->
<span class="logd__ua">{{ log.userAgent || '—' }}</span>
</NDescriptionsItem>
</NDescriptions>
<div class="section-title logd__sec">
排查信息
</div>
<NDescriptions :column="2" size="small" bordered>
<NDescriptionsItem label="traceId">
<span class="logd__code">{{ log.traceId || '—' }}</span>
<NButton v-if="log.traceId" text size="small" class="logd__copy" @click="copy(log.traceId, 'traceId')">
复制
</NButton>
</NDescriptionsItem>
<NDescriptionsItem label="bizCode">
{{ log.bizCode ?? '—' }}
</NDescriptionsItem>
</NDescriptions>
<!-- 没有 extraJson 就整块不显示:一个空标题只会让人以为"内容加载失败" -->
<template v-if="extraPretty">
<div class="section-title logd__sec">
补充上下文
</div>
<pre class="logd__json">{{ extraPretty }}</pre>
</template>
</template>
</NDrawerContent>
</NDrawer>
</template>
<style scoped>
.logd__head-tag {
margin-left: 8px;
}
.logd__head-sub {
margin-left: 12px;
font-size: 12px;
font-weight: 400;
color: var(--xt-text-3);
}
/* 全局 `.section-title` 只有下间距,抽屉里的分区会与上一块内容贴在一起 */
.logd__sec {
margin-top: 18px;
}
/* 变更前后:两块并排,各占一半(窄屏下会自动变窄,值本身换行不截断) */
.logd__diff {
display: flex;
gap: 12px;
}
.logd__col {
flex: 1;
min-width: 0;
padding: 10px 12px;
background: var(--xt-band-bg);
border: 1px solid var(--xt-card-border-weak);
border-radius: 8px;
}
.logd__col-head {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.logd__col-label {
font-size: 12px;
color: var(--xt-text-2);
}
.logd__val {
font-size: 13px;
line-height: 1.6;
/* 不截断:整段展示,长串靠 break-all 折行 */
word-break: break-all;
white-space: pre-wrap;
}
.logd__val--empty {
color: var(--xt-text-3);
}
.logd__req {
display: flex;
gap: 8px;
align-items: baseline;
}
.logd__method {
font-family: ui-monospace, sfmono-regular, menlo, monospace;
font-size: 12px;
font-weight: 600;
color: var(--xt-primary, #0052d9);
}
.logd__uri {
font-family: ui-monospace, sfmono-regular, menlo, monospace;
font-size: 13px;
word-break: break-all;
}
.logd__note {
margin-top: 6px;
font-size: 12px;
color: var(--xt-text-3);
}
.logd__ua {
font-size: 12px;
line-height: 1.6;
word-break: break-all;
}
.logd__code {
font-family: ui-monospace, sfmono-regular, menlo, monospace;
font-size: 12px;
word-break: break-all;
}
.logd__copy {
margin-left: 8px;
}
/* 补充上下文:等宽 + 可滚动(值可能很长,撑高抽屉不如让它自己滚) */
.logd__json {
max-height: 320px;
padding: 12px;
margin: 0;
overflow: auto;
font-family: ui-monospace, sfmono-regular, menlo, monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
background: var(--xt-band-bg);
border: 1px solid var(--xt-card-border-weak);
border-radius: 8px;
}
</style>
+535 -150
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { DataTableColumns } from 'naive-ui'
import type { OperationLog } from '@/apis/xt/log'
import type { LoginLog, OperationLog } from '@/apis/xt/log'
import {
NButton,
NCard,
@@ -11,54 +11,77 @@ import {
NTag,
useMessage
} from 'naive-ui'
import { computed, h, onMounted, reactive, ref } from 'vue'
import { computed, h, onMounted, reactive, ref, useTemplateRef } from 'vue'
import {
actionText,
getLoginLogs,
getOperationLogs,
LOGIN_TYPES,
MODULE_OPTIONS,
OPERATION_ACTIONS,
SENSITIVE_ACTIONS
SENSITIVE_ACTIONS,
TARGET_TYPE_OPTIONS
} from '@/apis/xt/log'
import { getTenantList } from '@/apis/xt/tenant'
import { USER_TYPE_NAMES } from '@/apis/xt/user'
import { listPagination } from '@/composables/pagination'
import AppIcon from '@/icons/AppIcon.vue'
import { useUserStore } from '@/stores/user'
import LogDetailDrawer from './LogDetailDrawer.vue'
/**
* 操作日志。
* 日志(操作日志 / 登录日志)。
*
* ## 为什么把「对象 ID」放在筛选栏最左边
* ## 为什么模式切换用两个按钮,而不是 `NTabs`
*
* 因为这是这一屏**唯一不可替代**的查询方式。合规问题问的是
* 「这张名册项的证件号被谁从什么改成了什么」,答案只能靠 `target_type + target_id` 定位;
* 而"搜关键字"会把「改了它」和「顺带提到它」混在一起 —— 那种匹配撑不起合规结论。
* 这是本仓踩过的两个坑叠在一起,刻意绕开:
* ① 页签内容需要额外 flex 化才能维持 `.page-card` 的高度链,否则表体高度算成 **0** ——
* 行在 DOM 里、数据也查得到,屏幕上一行都看不见;
* ② naive 2.45.3 的 `NTabs` 用 `tab="文字"` **属性**只渲染一个空注释节点(必须用 `#tab` 插槽)。
* 两个按钮在视觉上就是分段控件,且完全不动高度链(与「租户」页的状态筛选同一个写法)。
*
* ## 「变更前 → 变更后」单独一列
* ## 两个模式的状态**完全独立**
*
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于每次查看多一次点击,
* 而合规核查往往是几十条连着看的。
* 各自的 `list/total/pageNum/pageSize/loading/筛选条件`,切换时把要进入的那个模式重置到第 1 页 ——
* 否则会出现"分页器停在第 5 页、而这张表只有 2 页"的空表,看起来就像"这个模式没数据"。
*
* ## 范围:平台运营看**全量**,学校管理员看**本租户**
*
* 由服务端按 `log:viewAll` / `log:viewTenant` 决定。⚠️ 平台看到的每条日志属于哪个单位,
* 靠「租户」这一列 —— 它**只对平台显示**(学校管理员永远是本租户,列没有意义)。
* 靠两张表里那一列「单位」(`0` → 「平台」)—— 它**只对平台显示**:
* 学校管理员永远是本租户,整列都是同一个值,加了是噪音。
*
* 两张表的这一列**用同一个词**(「单位」),与筛选栏那两个下拉的占位符一致。
* 不叫「租户」是因为它要能装下 `tenantId = 0` 的「平台」—— 平台不是租户,
* 表头写「租户」而格子里写着「平台」,读起来是矛盾的。
*/
defineOptions({ name: 'OperationLogs' })
defineOptions({ name: 'Logs' })
type LogMode = 'operation' | 'login'
const message = useMessage()
const user = useUserStore()
const detailRef = useTemplateRef('detailRef')
/** 平台运营:看全量日志,因此要显示「租户」列并把 id 翻成单位名 */
/** 平台运营:看全量日志,因此要显示「单位」列并把 id 翻成单位名 */
const isPlatform = computed(() => user.isPlatform)
/** 平台运营的权限码口径(与后端 `logScopeTenantId` 同一个码):多一个「单位」筛选 */
const canViewAll = computed(() => user.has('log:viewAll'))
const tenantNames = ref<Record<number, string>>({})
const tenantOptions = ref<Array<{ label: string, value: number }>>([])
const loading = ref(false)
const list = ref<OperationLog[]>([])
const total = ref(0)
const mode = ref<LogMode>('operation')
const fromDate = ref<string | null>(null)
const toDate = ref<string | null>(null)
// ── 操作日志 ─────────────────────────────────────────
const opLoading = ref(false)
const opList = ref<OperationLog[]>([])
const opTotal = ref(0)
const opFrom = ref<string | null>(null)
const opTo = ref<string | null>(null)
const query = reactive<{
const opQuery = reactive<{
tenantId: number | null
targetId: number | null
targetType: string | null
action: string | null
@@ -66,6 +89,7 @@ const query = reactive<{
pageNum: number
pageSize: number
}>({
tenantId: null,
targetId: null,
targetType: null,
action: null,
@@ -74,24 +98,45 @@ const query = reactive<{
pageSize: 20
})
const targetTypeOptions = [
{ label: '名册项', value: 'ROSTER' },
{ label: '任务', value: 'TASK' },
{ label: '填报单', value: 'SUBMISSION' },
{ label: '导出', value: 'EXPORT' },
{ label: '账号', value: 'USER' }
]
// ── 登录日志 ─────────────────────────────────────────
const loginLoading = ref(false)
const loginList = ref<LoginLog[]>([])
const loginTotal = ref(0)
const loginFrom = ref<string | null>(null)
const loginTo = ref<string | null>(null)
const loginQuery = reactive<{
tenantId: number | null
userType: number | null
result: number | null
pageNum: number
pageSize: number
}>({
tenantId: null,
userType: null,
result: null,
pageNum: 1,
pageSize: 20
})
const actionOptions = OPERATION_ACTIONS.map((a) => ({ label: a.label, value: a.value }))
/** 加入申请(JOIN)已随需求收敛下线,这里不再列它 */
const moduleOptions = [
{ label: '名册', value: 'ROSTER' },
{ label: '任务', value: 'TASK' },
{ label: '审核', value: 'AUDIT' },
{ label: '导出', value: 'EXPORT' }
/**
* 登录结果的筛选值用 `1 / 0`,**不用 `true / false`** ——
* naive 的 `SelectOption.value` 只接受 `string | number`,传布尔会在 `vue-tsc` 里报类型错;
* 翻成后端要的 `Boolean` 只在调接口那一处做一次。
*/
const resultOptions = [
{ label: '成功', value: 1 },
{ label: '失败', value: 0 }
]
/** 用户类型映射复用 `@/apis/xt/user`(1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生) */
const userTypeOptions = Object.entries(USER_TYPE_NAMES).map(([code, name]) => ({
label: name,
value: Number(code)
}))
function fmt(v?: string): string {
return v ? v.replace('T', ' ').slice(0, 19) : '—'
}
@@ -100,16 +145,36 @@ function isSensitive(action?: string): boolean {
return !!action && SENSITIVE_ACTIONS.includes(action)
}
const columns = computed<DataTableColumns<OperationLog>>(() => {
/**
* 单位显示(操作日志、登录日志两张表共用同一个函数,口径必须一致)。
*
* ⚠️ 平台自己的动作(开通/停用学校)写的是 `tenant_id = 0`,**不是 null** ——
* 照直渲染就是 `#0`,看不出"这是平台自己干的"。
*/
function tenantLabel(id?: number): string {
if (id == null) {
return '—'
}
if (id === 0) {
return '平台'
}
return tenantNames.value[id] ?? `#${id}`
}
function openDetail(row: OperationLog): void {
detailRef.value?.open(row)
}
const operationColumns = computed<DataTableColumns<OperationLog>>(() => {
const cols: DataTableColumns<OperationLog> = []
// 「租户」列只给平台运营看
// 「单位」列只给平台运营看
if (isPlatform.value) {
cols.push({
title: '租户',
title: '单位',
key: 'tenantId',
width: 150,
render: (row) => h('span', { class: 'log__muted' }, tenantNames.value[row.tenantId ?? 0] ?? (row.tenantId != null ? `#${row.tenantId}` : '—'))
render: (row) => h('span', { class: 'log__muted' }, tenantLabel(row.tenantId))
})
}
@@ -125,7 +190,8 @@ const columns = computed<DataTableColumns<OperationLog>>(() => {
key: 'operatorName',
width: 130,
// ⚠️ 只显示账号/姓名,不显示用户 ID(客户走查要求):
// 数字 ID 对看日志的人没有意义,他要知道的是"哪个账号做的"
// 数字 ID 对看日志的人没有意义,他要知道的是"哪个账号做的"。
// 需要 ID 时点「详情」—— 那里带 `#id`,与库里那一行对得上
render: (row) => row.operatorName || '—'
},
{
@@ -167,6 +233,105 @@ const columns = computed<DataTableColumns<OperationLog>>(() => {
h('div', { class: 'log__muted' }, row.ip || '—'),
h('div', { class: 'log__ua', title: row.userAgent || '' }, row.userAgent || '')
])
},
{
title: '操作',
key: 'ops',
width: 90,
align: 'center',
// 固定在右侧:列宽合计超过容器时,横向滚动不该把「详情」滚出可视区
fixed: 'right',
render: (row) => h(
NButton,
{ text: true, size: 'small', onClick: () => openDetail(row) },
{ default: () => '详情' }
)
}
)
return cols
})
const loginColumns = computed<DataTableColumns<LoginLog>>(() => {
const cols: DataTableColumns<LoginLog> = []
cols.push({
title: '时间',
key: 'loginTime',
width: 160,
render: (row) => h('span', { class: 'log__muted' }, fmt(row.loginTime))
})
// 「单位」列(与操作日志同款、同一个判断):平台看的是跨单位的登录日志,
// 少了它分不清一行是哪所学校的;学校管理员恒为本租户,整列一个值,不加
if (isPlatform.value) {
cols.push({
title: '单位',
key: 'tenantId',
width: 150,
// ⚠️ 后端本次才把这个字段加进 `LoginLogVO`,旧 jar 下这里是 undefined → 显示「—」
render: (row) => h('span', { class: 'log__muted' }, tenantLabel(row.tenantId))
})
}
cols.push(
{
title: '用户',
key: 'userName',
width: 150,
render: (row) => h('div', null, [
h('div', null, row.userName || '—'),
// 用户 ID 放在姓名下面:账号重名、学生用家长的手机登录时,靠它才能确认是哪个人
h('div', { class: 'log__muted' }, row.userId != null ? `#${row.userId}` : '—')
])
},
{
title: '用户类型',
key: 'userType',
width: 110,
render: (row) => USER_TYPE_NAMES[row.userType ?? 0] ?? '—'
},
{
title: '登录方式',
key: 'loginType',
width: 120,
// 认不出就原样回显那个码(后端将来加登录方式时,界面不会显示成空白)
render: (row) => LOGIN_TYPES[row.loginType ?? ''] ?? row.loginType ?? '—'
},
{
title: '结果',
key: 'result',
width: 90,
render: (row) => {
if (row.result !== true && row.result !== false) {
return h('span', { class: 'log__muted' }, '—')
}
return h(
NTag,
{ size: 'small', bordered: false, type: row.result ? 'success' : 'error' },
{ default: () => (row.result ? '成功' : '失败') }
)
}
},
{
title: 'IP',
key: 'ip',
width: 140,
render: (row) => h('span', { class: 'log__muted' }, row.ip || '—')
},
{
title: '设备',
key: 'device',
width: 180,
// 与操作日志的 UA 列同款:单行 + 省略号,完整内容放 `title`(鼠标悬停可看)
render: (row) => h('span', { class: 'log__ua', title: row.device || '' }, row.device || '—')
},
{
title: '失败原因',
key: 'failReason',
width: 200,
// 登录成功的那一行本来就没有失败原因,留空比整列铺满「—」好读
render: (row) => (row.failReason ? h('span', null, row.failReason) : '')
}
)
@@ -180,84 +345,189 @@ const columns = computed<DataTableColumns<OperationLog>>(() => {
* 同时处理 `@update:page` 与 `@update:page-size`(改每页条数一律回到第 1 页重新取数,
* 否则会出现"分页器说 50 条/页、表里还是上一次那 20 条")。
*/
const pagination = computed(() => listPagination({
page: query.pageNum,
pageSize: query.pageSize,
itemCount: total.value,
const opPagination = computed(() => listPagination({
page: opQuery.pageNum,
pageSize: opQuery.pageSize,
itemCount: opTotal.value,
pageSizes: [20, 50, 100]
}))
function onPage(page: number): void {
query.pageNum = page
void load()
}
const loginPagination = computed(() => listPagination({
page: loginQuery.pageNum,
pageSize: loginQuery.pageSize,
itemCount: loginTotal.value,
pageSizes: [20, 50, 100]
}))
function onPageSize(size: number): void {
query.pageSize = size
query.pageNum = 1
void load()
}
async function load(): Promise<void> {
loading.value = true
async function loadOperations(): Promise<void> {
opLoading.value = true
try {
const page = await getOperationLogs({
targetId: query.targetId ?? undefined,
targetType: query.targetType ?? undefined,
action: query.action ?? undefined,
module: query.module ?? undefined,
// ⚠️ `?? undefined` 而不是 `||`:0 是「平台」这个**有效值**,用 `||` 会被当成"没选"
tenantId: opQuery.tenantId ?? undefined,
targetId: opQuery.targetId ?? undefined,
targetType: opQuery.targetType ?? undefined,
action: opQuery.action ?? undefined,
module: opQuery.module ?? undefined,
// 后端 `to` 会补到当天 23:59:59,所以这里只给日期就够
from: fromDate.value || undefined,
to: toDate.value || undefined,
pageNum: query.pageNum,
pageSize: query.pageSize
from: opFrom.value || undefined,
to: opTo.value || undefined,
pageNum: opQuery.pageNum,
pageSize: opQuery.pageSize
})
list.value = page.list ?? []
total.value = page.total ?? 0
opList.value = page.list ?? []
opTotal.value = page.total ?? 0
} finally {
loading.value = false
opLoading.value = false
}
}
function search(): void {
query.pageNum = 1
void load()
async function loadLogins(): Promise<void> {
loginLoading.value = true
try {
const page = await getLoginLogs({
tenantId: loginQuery.tenantId ?? undefined,
userType: loginQuery.userType ?? undefined,
// 筛选值是 1/0(见 `resultOptions`),接口要的是 Boolean
result: loginQuery.result == null ? undefined : loginQuery.result === 1,
from: loginFrom.value || undefined,
to: loginTo.value || undefined,
pageNum: loginQuery.pageNum,
pageSize: loginQuery.pageSize
})
loginList.value = page.list ?? []
loginTotal.value = page.total ?? 0
} finally {
loginLoading.value = false
}
}
function reset(): void {
query.targetId = null
query.targetType = null
query.action = null
query.module = null
fromDate.value = null
toDate.value = null
search()
/**
* 切换模式。
*
* ⚠️ 进入哪个模式,就把它**自己**的页码重置到第 1 页:
* 两个模式各自记着上次翻到第几页,不重置就会出现"切过来是第 5 页、而这张表只有 2 页"的空表 ——
* 那看起来就是"登录日志一条都没有"。筛选条件保留(切回来还是刚才那批条件),只有页码归零。
*/
function switchMode(next: LogMode): void {
if (next === mode.value) {
return
}
mode.value = next
if (next === 'operation') {
opQuery.pageNum = 1
void loadOperations()
} else {
loginQuery.pageNum = 1
void loadLogins()
}
}
function searchOperations(): void {
opQuery.pageNum = 1
void loadOperations()
}
function searchLogins(): void {
loginQuery.pageNum = 1
void loadLogins()
}
function resetFilters(): void {
if (mode.value === 'operation') {
opQuery.tenantId = null
opQuery.targetId = null
opQuery.targetType = null
opQuery.action = null
opQuery.module = null
opFrom.value = null
opTo.value = null
searchOperations()
} else {
loginQuery.tenantId = null
loginQuery.userType = null
loginQuery.result = null
loginFrom.value = null
loginTo.value = null
searchLogins()
}
}
function refresh(): void {
if (mode.value === 'operation') {
void loadOperations()
} else {
void loadLogins()
}
}
function onOperationPage(page: number): void {
opQuery.pageNum = page
void loadOperations()
}
function onOperationPageSize(size: number): void {
opQuery.pageSize = size
opQuery.pageNum = 1
void loadOperations()
}
function onLoginPage(page: number): void {
loginQuery.pageNum = page
void loadLogins()
}
function onLoginPageSize(size: number): void {
loginQuery.pageSize = size
loginQuery.pageNum = 1
void loadLogins()
}
/**
* 单位下拉的选项。
*
* ⚠️ 除了租户列表,必须**额外加一项「平台」(`tenantId = 0`)**:
* 平台自己的动作(开通/停用学校)写的是 `tenant_id = 0`,而租户表里没有 0 这一行 ——
* 少了这一项,「平台做了哪些事」就永远筛不出来。放在最前面,因为它最常用。
*/
async function loadTenants(): Promise<void> {
if (!canViewAll.value) {
return
}
try {
const page = await getTenantList({ pageNum: 1, pageSize: 200 })
const tenants = page.list ?? []
tenantNames.value = Object.fromEntries(tenants.map((t) => [t.id, t.tenantName]))
tenantOptions.value = [
{ label: '平台', value: 0 },
...tenants.map((t) => ({ label: t.tenantName, value: t.id }))
]
} catch {
// 取不到也不影响看日志:租户列退回显示 #id,下拉至少还能按「平台」筛
tenantNames.value = {}
tenantOptions.value = [{ label: '平台', value: 0 }]
message.warning('单位名称没取到,租户列先显示编号')
}
}
onMounted(async () => {
// 平台运营才需要"租户 id → 单位名"。取不到也不影响看日志:列里退回显示 #id
if (isPlatform.value) {
try {
const page = await getTenantList({ pageNum: 1, pageSize: 200 })
tenantNames.value = Object.fromEntries((page.list ?? []).map((t) => [t.id, t.tenantName]))
} catch {
tenantNames.value = {}
message.warning('单位名称没取到,租户列先显示编号')
}
}
await load()
await loadTenants()
await loadOperations()
})
</script>
<template>
<NCard class="page-card log" :bordered="false" size="small">
<template #header>
操作日志
日志
</template>
<template #header-extra>
<span class="log__hint">
<span v-if="mode === 'operation'" class="log__hint">
按「对象」查是这个页面最重要的用法:填对象类型 + ID,就能看到它被谁改过
</span>
<span v-else class="log__hint">
登录日志用来回答「谁在什么时候从哪里登了进来」;IP 归属地一期没有采集,所以没有这一列
</span>
</template>
<!--
@@ -269,62 +539,136 @@ onMounted(async () => {
否则换行后每行会被"两端对齐"抻开,看起来像随机的空档。
-->
<div class="toolbar log__filters">
<!-- 对象 ID 放最左:合规问题只能靠 target_type + target_id 定位 -->
<NInputNumber
v-model:value="query.targetId"
class="log__num"
:min="1"
clearable
placeholder="对象 ID"
@update:value="search"
/>
<NSelect
v-model:value="query.targetType"
class="log__sel"
clearable
placeholder="对象类型"
:options="targetTypeOptions"
@update:value="search"
/>
<NSelect
v-model:value="query.action"
class="log__sel2"
clearable
placeholder="动作"
:options="actionOptions"
@update:value="search"
/>
<NSelect
v-model:value="query.module"
class="log__sel"
clearable
placeholder="模块"
:options="moduleOptions"
@update:value="search"
/>
<!-- 起止日期用两个独立日期框,不用 range:两个框各自能单独清空,核查时更好用 -->
<NDatePicker
v-model:formatted-value="fromDate"
type="date"
value-format="yyyy-MM-dd"
clearable
placeholder="开始日期"
class="log__date"
@update:formatted-value="search"
/>
<NDatePicker
v-model:formatted-value="toDate"
type="date"
value-format="yyyy-MM-dd"
clearable
placeholder="结束日期"
class="log__date"
@update:formatted-value="search"
/>
<NButton @click="reset">
<!--
模式切换:用两个按钮做的分段控件(刻意不用 NTabs,原因见文件头注释)。
右侧那条竖线把它与「筛选条件」分开 —— 一个是"看哪张表",一个是"表里筛什么"。
-->
<div class="log__seg">
<NButton :type="mode === 'operation' ? 'primary' : 'default'" @click="switchMode('operation')">
操作日志
</NButton>
<NButton :type="mode === 'login' ? 'primary' : 'default'" @click="switchMode('login')">
登录日志
</NButton>
</div>
<!-- 操作日志的筛选条件 -->
<template v-if="mode === 'operation'">
<!-- 单位:只有平台能跨租户查(学校管理员恒为本租户,服务端会 40301) -->
<NSelect
v-if="canViewAll"
v-model:value="opQuery.tenantId"
class="log__sel"
clearable
placeholder="单位"
:options="tenantOptions"
@update:value="searchOperations"
/>
<!-- 对象 ID 放最左:合规问题只能靠 target_type + target_id 定位 -->
<NInputNumber
v-model:value="opQuery.targetId"
class="log__num"
:min="1"
clearable
placeholder="对象 ID"
@update:value="searchOperations"
/>
<NSelect
v-model:value="opQuery.targetType"
class="log__sel"
clearable
placeholder="对象类型"
:options="TARGET_TYPE_OPTIONS"
@update:value="searchOperations"
/>
<NSelect
v-model:value="opQuery.action"
class="log__sel2"
clearable
placeholder="动作"
:options="actionOptions"
@update:value="searchOperations"
/>
<NSelect
v-model:value="opQuery.module"
class="log__sel"
clearable
placeholder="模块"
:options="MODULE_OPTIONS"
@update:value="searchOperations"
/>
<!-- 起止日期用两个独立日期框,不用 range:两个框各自能单独清空,核查时更好用 -->
<NDatePicker
v-model:formatted-value="opFrom"
type="date"
value-format="yyyy-MM-dd"
clearable
placeholder="开始日期"
class="log__date"
@update:formatted-value="searchOperations"
/>
<NDatePicker
v-model:formatted-value="opTo"
type="date"
value-format="yyyy-MM-dd"
clearable
placeholder="结束日期"
class="log__date"
@update:formatted-value="searchOperations"
/>
</template>
<!-- 登录日志的筛选条件:**没有**「归属地」—— 一期 location 恒为 null,不给一个永远筛不出东西的框 -->
<template v-else>
<NSelect
v-if="canViewAll"
v-model:value="loginQuery.tenantId"
class="log__sel"
clearable
placeholder="单位"
:options="tenantOptions"
@update:value="searchLogins"
/>
<NSelect
v-model:value="loginQuery.userType"
class="log__sel"
clearable
placeholder="用户类型"
:options="userTypeOptions"
@update:value="searchLogins"
/>
<NSelect
v-model:value="loginQuery.result"
class="log__sel"
clearable
placeholder="结果"
:options="resultOptions"
@update:value="searchLogins"
/>
<NDatePicker
v-model:formatted-value="loginFrom"
type="date"
value-format="yyyy-MM-dd"
clearable
placeholder="开始日期"
class="log__date"
@update:formatted-value="searchLogins"
/>
<NDatePicker
v-model:formatted-value="loginTo"
type="date"
value-format="yyyy-MM-dd"
clearable
placeholder="结束日期"
class="log__date"
@update:formatted-value="searchLogins"
/>
</template>
<NButton @click="resetFilters">
重置
</NButton>
<NButton @click="load">
<NButton @click="refresh">
<template #icon>
<AppIcon name="refresh" :size="16" />
</template>
@@ -332,23 +676,52 @@ onMounted(async () => {
</NButton>
</div>
<!--
高度链:`.page-card__body` → `n-data-table[flex-height]`(见 styles/index.scss 的 .page-card 注释)。
两个表格用 `v-if / v-else` 而不是同时挂着 —— 只有一张表是 DOM 里的直接子元素,
`.page-card__body > .n-data-table` 那条全局规则才不会失效(失效的表现就是"一行都看不见")。
-->
<div class="page-card__body">
<!--
`:scroll-x` 是**列宽合计**:横向滚动条按它算。
两张表都是"平台比学校管理员多一列「单位」(150)",所以两个数各有一对;
操作日志末尾还含固定的「操作」列 90(登录日志没有这一列)。
-->
<NDataTable
v-if="mode === 'operation'"
flex-height
style="height: 100%"
remote
:columns="columns"
:data="list"
:loading="loading"
:columns="operationColumns"
:data="opList"
:loading="opLoading"
:row-key="row => row.id"
:scroll-x="isPlatform ? 1240 : 1090"
:pagination="pagination"
:scroll-x="isPlatform ? 1330 : 1180"
:pagination="opPagination"
size="small"
@update:page="onPage"
@update:page-size="onPageSize"
@update:page="onOperationPage"
@update:page-size="onOperationPageSize"
/>
<NDataTable
v-else
flex-height
style="height: 100%"
remote
:columns="loginColumns"
:data="loginList"
:loading="loginLoading"
:row-key="row => row.id"
:scroll-x="isPlatform ? 1300 : 1150"
:pagination="loginPagination"
size="small"
@update:page="onLoginPage"
@update:page-size="onLoginPageSize"
/>
</div>
</NCard>
<!-- 详情不发请求:整行数据已经查回来了,抽屉只负责把被截断/没展示的字段摊开 -->
<LogDetailDrawer ref="detailRef" :tenant-names="tenantNames" />
</template>
<style scoped>
@@ -384,6 +757,18 @@ onMounted(async () => {
justify-content: flex-start;
}
/**
* 模式切换(分段控件)。
* 只放 2 个按钮,所以它自己不需要换行 —— 后面那条竖线用来与"筛选条件"分区。
*/
.log__seg {
display: flex;
gap: 8px;
padding-right: 12px;
margin-right: 4px;
border-right: 1px solid var(--xt-card-border-weak);
}
.log__num {
width: 130px;
}
@@ -0,0 +1,585 @@
<script setup lang="ts">
import type { SelectOption } from 'naive-ui'
import type { TenantUsage, TenantVO } from '@/apis/xt/tenant'
import {
NAlert,
NButton,
NDescriptions,
NDescriptionsItem,
NDrawer,
NDrawerContent,
NEmpty,
NSelect,
NSpin,
NTag,
useMessage
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
import * as tenantApi from '@/apis/xt/tenant'
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
import { fmtTime } from '@/utils/format'
import { tagOf } from '@/utils/tag'
/**
* 租户详情抽屉(平台运营)。
*
* ## 为什么打开时要**同时调两个接口**
*
* `getTenantDetail(id)` **必须**调,不是"顺手也拉一下":
* 列表接口的 `contactPhone` 恒为 `null`(见 `apis/xt/tenant.ts` 的注释)——
* **脱敏后的联系电话只有详情接口会给**。少了它,抽屉里的"联系电话"永远是空的,
* 运营会以为客户没留电话。
*
* ## 用量区必须写清"哪一项是实时值"
*
* 服务端口径(`TenantUsageService` 的类注释):`studentCount` / `storageMb` 是**实时**算的
* (它们决定"还能不能再收人"),`ocrCount` / `smsCount` 才是**按期**算的(计费项)。
* 不写这句话,运营会以为"我选了 2026Q3,所以看到的是三季度末的学生数"——
* 而它其实是"此刻"。周期选择器只影响计费项。
*/
defineOptions({ name: 'TenantDetailDrawer' })
const props = defineProps<{
/** 用 `v-model:show` 与父页面同步 */
show: boolean
tenantId: number | null
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
}>()
const message = useMessage()
const loading = ref(false)
const usageLoading = ref(false)
/** 错误态:http 层已经弹过提示,但抽屉里不能只剩一片空白(用户会以为"这个单位没用量") */
const loadError = ref(false)
const usageError = ref(false)
const detail = ref<TenantVO | null>(null)
const usage = ref<TenantUsage | null>(null)
/**
* "当前这份详情属于哪个租户"的账。
*
* 父页面点「详情」时是先改 `tenant-id` 再开 `show`(同一批更新里两个都变),
* 下面「开抽屉」与「换租户」两个 watch 会**各触发一次** ——
* 没有这道判断就会白拉两轮接口。
*/
let drawId: number | null = null
/** 小程序码参数:孩子在手机上扫的那个码里就是这一串(`t{id}`,与开通成功弹窗给的是同一个值) */
const scene = computed(() => (props.tenantId === null ? '—' : `t${props.tenantId}`))
const statusTag = computed(() => tagOf(TENANT_STATUS, detail.value?.status))
const typeText = computed(() => {
const t = detail.value?.tenantType
return t === null || t === undefined ? '—' : (TENANT_TYPE_NAMES[t] ?? '—')
})
/**
* 到期提示。
*
* `expireTime` 是**日期**(不含时间),所以"还剩几天"按天算;
* 已过期 / 30 天内到期分别给红 / 橙 —— 与列表里到期列的颜色是同一套口径
* (列表只有 120px 宽,放不下文字,只能靠颜色)。
*/
const expireState = computed<'expired' | 'soon' | 'ok' | 'none'>(() => {
const t = detail.value?.expireTime
if (!t) {
return 'none'
}
const end = Date.parse(`${t.slice(0, 10)}T23:59:59`)
if (Number.isNaN(end)) {
return 'none'
}
const days = (end - Date.now()) / 86400000
if (days < 0) {
return 'expired'
}
return days <= 30 ? 'soon' : 'ok'
})
// ── 周期选择:只给三个固定选项 ──
interface PeriodOption extends SelectOption {
label: string
value: string
}
function pad(n: number): string {
return String(n).padStart(2, '0')
}
/** 当月的接口口径(`2026-09`)。不传就是当月,但显式传更好排查 */
function thisMonth(): string {
const d = new Date()
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`
}
function lastMonth(): string {
const d = new Date()
// `new Date(y, m - 1, 1)` 会自动跨年(1 月 → 上一年 12 月),不用自己判
const prev = new Date(d.getFullYear(), d.getMonth() - 1, 1)
return `${prev.getFullYear()}-${pad(prev.getMonth() + 1)}`
}
/** 本季(`2026Q3`)。客户的结算周期是季度,所以这个必须给 */
function thisQuarter(): string {
const d = new Date()
return `${d.getFullYear()}Q${Math.floor(d.getMonth() / 3) + 1}`
}
/** ⚠️ 只给这三个固定选项,**不做自由文本输入**:手输的格式错一次,接口就回一句业务错误 */
const periodOptions: PeriodOption[] = [
{ label: '本月', value: thisMonth() },
{ label: '上月', value: lastMonth() },
{ label: '本季', value: thisQuarter() }
]
const period = ref<string | number | null>(thisMonth())
const periodText = computed(() => periodOptions.find((o) => o.value === period.value)?.label ?? '')
// ── 占用比例(学生数 / 存储)──
interface QuotaRow {
used: number
limit: number
percent: number
/** 超 80% 标橙、超 100% 标红 —— 与服务端 `alerts` 的阈值(0.8 / 1.0)同一套账 */
level: 0 | 1 | 2
}
function quotaRow(used: number, limit: number): QuotaRow {
if (limit <= 0) {
return { used, limit, percent: 0, level: 0 }
}
const ratio = used / limit
const percent = Math.min(Math.round(ratio * 1000) / 10, 100)
const level: 0 | 1 | 2 = ratio >= 1 ? 2 : ratio >= 0.8 ? 1 : 0
return { used, limit, percent, level }
}
const studentQuota = computed(() => quotaRow(usage.value?.studentCount ?? 0, usage.value?.quota?.studentLimit ?? 0))
const storageQuota = computed(() => quotaRow(usage.value?.storageMb ?? 0, usage.value?.quota?.storageLimitMb ?? 0))
function quotaClass(row: QuotaRow): string {
if (row.level === 2) {
return 'tdraw__quota--over'
}
return row.level === 1 ? 'tdraw__quota--warn' : ''
}
function loadDetail(): void {
const id = props.tenantId
if (id === null) {
detail.value = null
return
}
drawId = id
loading.value = true
loadError.value = false
// 先清空:避免上一次看的那所学校的联系人 / 电话留在抽屉里被误读成这一所的
detail.value = null
void tenantApi.getTenantDetail(id)
.then((vo) => {
detail.value = vo
})
.catch(() => {
detail.value = null
loadError.value = true
})
.finally(() => {
loading.value = false
})
}
function loadUsage(): void {
const id = props.tenantId
if (id === null) {
usage.value = null
return
}
usageLoading.value = true
usageError.value = false
usage.value = null
const p = typeof period.value === 'string' ? period.value : undefined
void tenantApi.getTenantUsage(id, p)
.then((vo) => {
usage.value = vo
})
.catch(() => {
usage.value = null
usageError.value = true
})
.finally(() => {
usageLoading.value = false
})
}
async function copyScene(): Promise<void> {
try {
await navigator.clipboard.writeText(scene.value)
message.success('已复制到剪贴板')
} catch {
// 与开通成功弹窗同一处理:http 之外的权限问题,别静默失败
message.warning('浏览器不允许自动复制,请手动选中后复制')
}
}
function onPeriodChange(v: string | number | null): void {
period.value = v
loadUsage()
}
// 打开时同时拉两个接口;换一个租户时周期回到"本月"(否则会带着上一所学校的选项)
watch(() => props.show, (v) => {
if (!v) {
return
}
period.value = thisMonth()
loadDetail()
loadUsage()
})
/**
* 父页面在抽屉开着时点了另一行的「详情」——两个接口都要重拉。
* 与上面共用 `drawId` 那道账,避免同一次更新里拉两遍。
*/
watch(() => props.tenantId, (id) => {
if (props.show && id !== drawId) {
loadDetail()
loadUsage()
}
})
</script>
<template>
<NDrawer
:show="props.show"
:width="900"
placement="right"
:mask-closable="false"
@update:show="(v: boolean) => emit('update:show', v)"
>
<NDrawerContent :native-scrollbar="false" closable>
<template #header>
<span>{{ detail?.tenantName || '租户详情' }}</span>
<span v-if="detail" class="tdraw__head-id">#{{ detail.id }}</span>
</template>
<NSpin :show="loading">
<!-- 空态 / 错误态:不要只留一片空白(用户会读成"这个单位什么都没有") -->
<NAlert v-if="!loading && loadError" type="error" :show-icon="false" class="tdraw__alert">
没有拿到这个租户的信息,请关闭后重试。
</NAlert>
<NEmpty v-else-if="!loading && !detail" description="没有拿到这个租户的信息" class="tdraw__empty" />
<template v-else-if="detail">
<!--
顶部告警按"最严重的先说"排序:已停用 → 已过期 → 30 天内到期。
停用与过期是两件事(停用的租户就算没过期也登不进来),所以并列显示而不是 else-if。
-->
<NAlert
v-if="detail.status === 0"
type="warning"
:show-icon="false"
class="tdraw__alert"
>
该单位<strong>已停用</strong>:所有学生与老师<strong>立刻无法登录</strong>。
恢复要在「编辑」里把状态改回正常或试用。
</NAlert>
<NAlert
v-if="expireState === 'expired'"
type="error"
:show-icon="false"
class="tdraw__alert"
>
该单位<strong>已过期</strong>(到期时间 {{ fmtTime(detail.expireTime, 'YYYY-MM-DD') }})。
过期后账号将无法登录,请联系客户续期。
</NAlert>
<NAlert
v-else-if="expireState === 'soon'"
type="warning"
:show-icon="false"
class="tdraw__alert"
>
该单位将在 <strong>30 天内到期</strong>({{ fmtTime(detail.expireTime, 'YYYY-MM-DD') }}),
可以提前联系客户续期。
</NAlert>
<NDescriptions :column="2" size="small" label-placement="left" bordered>
<NDescriptionsItem label="单位名称">{{ detail.tenantName }}</NDescriptionsItem>
<NDescriptionsItem label="租户 ID">{{ detail.id }}</NDescriptionsItem>
<NDescriptionsItem label="租户编码">{{ detail.tenantCode }}</NDescriptionsItem>
<NDescriptionsItem label="类型">{{ typeText }}</NDescriptionsItem>
<NDescriptionsItem label="状态">
<NTag :type="statusTag.type" size="small" :bordered="false">
{{ statusTag.text }}
</NTag>
</NDescriptionsItem>
<NDescriptionsItem label="到期时间">{{ fmtTime(detail.expireTime, 'YYYY-MM-DD') }}</NDescriptionsItem>
<NDescriptionsItem label="联系人">{{ detail.contactName || '—' }}</NDescriptionsItem>
<!--
脱敏后的联系电话**只有详情接口会给**(列表接口恒为 null),
所以这一行能不能有值,取决于上面有没有真的调 getTenantDetail。
-->
<NDescriptionsItem label="联系电话(脱敏)">
{{ detail.contactPhone || '未登记' }}
</NDescriptionsItem>
<NDescriptionsItem label="套餐 ID">
{{ detail.packageId === null ? '未配套餐' : detail.packageId }}
</NDescriptionsItem>
<NDescriptionsItem label="创建时间">{{ fmtTime(detail.createTime) }}</NDescriptionsItem>
<NDescriptionsItem label="小程序码参数" :span="2">
<span class="tdraw__scene">{{ scene }}</span>
<NButton text size="small" type="primary" class="tdraw__copy" @click="copyScene">
复制
</NButton>
</NDescriptionsItem>
</NDescriptions>
<!-- ── 用量 ── -->
<div class="section-title tdraw__usage-title">
用量
<span class="section-title__note">学生数与存储是实时值,周期只影响计费项</span>
</div>
<div class="tdraw__band">
<div class="tdraw__period">
<span class="tdraw__period-label">统计周期</span>
<NSelect
:value="period"
:options="periodOptions"
class="tdraw__period-select"
@update:value="onPeriodChange"
/>
<span class="tdraw__period-hint">
只影响计费项;学生数与存储永远是"此刻"的值
</span>
</div>
<NSpin :show="usageLoading">
<NAlert v-if="!usageLoading && usageError" type="error" :show-icon="false">
没有拿到用量数据,换个周期或稍后重试。
</NAlert>
<NEmpty v-else-if="!usageLoading && !usage" description="没有拿到用量数据" class="tdraw__empty" />
<template v-else-if="usage">
<div class="tdraw__quotas">
<div class="tdraw__quota">
<div class="tdraw__quota-label">
学生数
</div>
<div class="tdraw__quota-value" :class="quotaClass(studentQuota)">
{{ studentQuota.used }} / {{ studentQuota.limit }} 人
<span class="tdraw__quota-pct">{{ studentQuota.percent }}%</span>
</div>
<div class="tdraw__quota-note">
实时值(按身份证去重),不限周期
</div>
</div>
<div class="tdraw__quota">
<div class="tdraw__quota-label">
存储
</div>
<div class="tdraw__quota-value" :class="quotaClass(storageQuota)">
{{ storageQuota.used }} / {{ storageQuota.limit }} MB
<span class="tdraw__quota-pct">{{ storageQuota.percent }}%</span>
</div>
<div class="tdraw__quota-note">
实时值(材料规范件合计),不限周期
</div>
</div>
</div>
<div class="tdraw__counts">
<span>OCR 识别 {{ usage.ocrCount }} 次</span>
<span>短信 {{ usage.smsCount }} 条</span>
<span class="tdraw__counts-period">({{ periodText }} {{ usage.period }})</span>
</div>
<!--
告警逐条展示。level 2 = 已超限(红),1 = 接近上限(橙)——
与服务端 `addAlert` 的阈值(0.8 / 1.0)对应。
-->
<ul v-if="usage.alerts.length" class="tdraw__alerts">
<li
v-for="(a, i) in usage.alerts"
:key="`${a.type}-${i}`"
:class="a.level === 2 ? 'tdraw__alert-item--over' : 'tdraw__alert-item--warn'"
>
<strong>{{ a.type === 'STUDENT' ? '学生数' : '存储' }}</strong>
已占用 {{ a.ratio }}({{ a.used }} / {{ a.limit }}),
{{ a.level === 2 ? '已超出套餐额度' : '接近套餐额度' }}
</li>
</ul>
<!--
notes 如实全部展示(灰色小字),不做折叠、不吞掉:
它解释的是"为什么 ocrCount / smsCount 是 0"——
藏起来运营就会以为那是"真的没用",然后拿假数据去谈续费。
-->
<ul v-if="usage.notes.length" class="tdraw__notes">
<li v-for="(n, i) in usage.notes" :key="i">
{{ n }}
</li>
</ul>
</template>
</NSpin>
</div>
</template>
</NSpin>
</NDrawerContent>
</NDrawer>
</template>
<style scoped>
.tdraw__head-id {
margin-left: 8px;
font-size: 12px;
color: var(--xt-text-3);
}
.tdraw__alert {
margin-bottom: 12px;
}
.tdraw__empty {
padding: 24px 0;
}
.tdraw__scene {
padding: 2px 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
background: #fff;
border: 1px solid var(--xt-card-border-weak);
border-radius: 4px;
}
.tdraw__copy {
margin-left: 8px;
}
.tdraw__usage-title {
margin-top: 20px;
}
/**
* 用量区的外框。
*
* ⚠️ 这里**刻意不用 `NCard`**:抽屉里再套一张卡会出现两层"浮起 + 浅灰头",
* 比不分组还乱(与开通弹窗的「首个管理员」块同一套写法)。
*/
.tdraw__band {
padding: 14px 16px 16px;
background: var(--xt-band-bg);
border: 1px solid var(--xt-card-border-weak);
border-radius: 8px;
}
.tdraw__period {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 14px;
}
.tdraw__period-label {
font-size: 12px;
color: var(--xt-text-3);
}
.tdraw__period-select {
width: 120px;
}
.tdraw__period-hint {
font-size: 12px;
color: var(--xt-text-3);
}
.tdraw__quotas {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.tdraw__quota {
padding: 10px 12px;
background: #fff;
border: 1px solid var(--xt-card-border-weak);
border-radius: 8px;
}
.tdraw__quota-label {
font-size: 12px;
color: var(--xt-text-3);
}
.tdraw__quota-value {
margin-top: 4px;
font-size: 15px;
font-weight: 600;
}
/* 接近上限(≥80%)标橙、超限(≥100%)标红 —— 与列表到期列的配色同一套口径 */
.tdraw__quota--warn {
color: #d97706;
}
.tdraw__quota--over {
color: #d03050;
}
.tdraw__quota-pct {
margin-left: 6px;
font-size: 12px;
font-weight: 400;
}
.tdraw__quota-note {
margin-top: 4px;
font-size: 12px;
color: var(--xt-text-3);
}
.tdraw__counts {
margin-top: 12px;
font-size: 13px;
color: var(--xt-text-2);
}
.tdraw__counts span + span {
margin-left: 14px;
}
.tdraw__counts-period {
color: var(--xt-text-3);
}
.tdraw__alerts,
.tdraw__notes {
padding-left: 18px;
margin: 10px 0 0;
font-size: 12px;
}
.tdraw__alerts li,
.tdraw__notes li {
line-height: 1.7;
}
.tdraw__alert-item--over {
color: #d03050;
}
.tdraw__alert-item--warn {
color: #d97706;
}
.tdraw__notes {
color: var(--xt-text-3);
}
</style>
@@ -0,0 +1,310 @@
<script setup lang="ts">
import type { FormRules } from 'naive-ui'
import type { TenantUpdateReq, TenantVO } from '@/apis/xt/tenant'
import { NButton, NDatePicker, NForm, NFormItem, NInput, NModal, NSelect, NSpace, useMessage } from 'naive-ui'
import { reactive, ref, watch } from 'vue'
import * as tenantApi from '@/apis/xt/tenant'
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
import { fmtDate } from '@/utils/format'
/**
* 租户编辑(平台运营)。
*
* ## 三个"不显然"的地方
*
* 1. **联系电话留空 = 不修改,不是清空**。库里的 `contact_phone` 是 AES 密文列,
* 而**列表接口返回的 `contactPhone` 恒为 null**(脱敏值只有详情接口给)——
* 所以这里既不能回填、也不能把"看起来是空的"提交上去:
* 一旦提交空白,服务端虽然按"空 = 不修改"跳过,但界面上必须说清,
* 否则运营会以为自己在"清空电话"。要改就整条重填。
* 2. **租户编码不可修改**,只能只读展示。它出现在小程序扫码落地页与对接文档里,
* 改了会让已经发出去的文档和配好的域名全部失效 —— 所以连输入框都不给,
* 而不是"给个禁用输入框"(禁用输入框会让人以为"申请一下就能解锁")。
* 3. **状态能改成"试用"(2)**。后端 `TenantUsageService.update` 里放行 0/1/2,
* 少给一个选项运营就没法把客户从试用转正,也没法退回试用。
*/
defineOptions({ name: 'TenantEditModal' })
const props = defineProps<{
/** 用 `v-model:show` 与父页面同步;父页面负责传当前行的租户 */
show: boolean
/** 要编辑的租户(列表行即可 —— `expireTime` / `status` 列表接口都给) */
tenant: TenantVO | null
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'saved'): void
}>()
const message = useMessage()
const formRef = ref<InstanceType<typeof NForm> | null>(null)
const submitting = ref(false)
/**
* 表单模型。
*
* `expireTime` 声明成 `string | null`:`n-date-picker` 清空时回传的是 `null`,
* 声明成 `string` 运行期没事、但 vue-tsc 会在 `v-model` 上报类型不兼容。
*/
interface TenantEditForm {
tenantName: string
tenantType: number
contactName: string
contactPhone: string
expireTime: string | null
status: number
}
function emptyForm(): TenantEditForm {
return {
tenantName: '',
tenantType: 1,
contactName: '',
contactPhone: '',
expireTime: null,
status: 1
}
}
const form = reactive<TenantEditForm>(emptyForm())
/**
* ⚠️ 类型 / 状态这两个数字下拉的规则**必须**写 `type: 'number'`。
*
* 只写 `required` 时 async-validator 会把 `rule.type` 默认成 `'string'`
* (`Schema.validate` 里的 `rule.type = this.getType(rule)`),接着按
* `typeof value !== rule.type` 判错 —— 而选项的 value 是数字。
* 结果是"看起来填了、就是提交不出去",且错误文案被 `#feedback` 插槽挡着看不见。
*
* `contactPhone` **刻意没有** required:它留空的含义是"不修改",
* 加了必填就等于逼运营重填一遍电话(那是密文,我们拿不到明文回填)。
*/
const rules: FormRules = {
tenantName: [{ required: true, message: '请填写单位名称', trigger: ['blur', 'input'] }],
tenantType: [{ type: 'number', required: true, message: '请选择类型', trigger: ['change'] }],
status: [{ type: 'number', required: true, message: '请选择状态', trigger: ['change'] }]
}
const typeOptions = Object.entries(TENANT_TYPE_NAMES).map(([code, name]) => ({
label: name,
value: Number(code)
}))
/** 状态三项:0 停用 / 1 正常 / 2 试用(与后端放行的取值一字不差) */
const statusOptions = Object.entries(TENANT_STATUS).map(([code, item]) => ({
label: item.text,
value: Number(code)
}))
/** 把列表行的值灌进表单(打开时调用) */
function fillFrom(row: TenantVO): void {
Object.assign(form, emptyForm())
form.tenantName = row.tenantName
form.tenantType = row.tenantType
form.contactName = row.contactName ?? ''
// 刻意不回填:列表接口的 contactPhone 恒为 null(脱敏值只在详情接口给),
// 回填一个空串会让人以为"这个单位没留电话",而其实是"我们看不到明文"
form.contactPhone = ''
form.expireTime = row.expireTime ? fmtDate(row.expireTime) : null
form.status = row.status
}
watch(() => props.show, (v) => {
if (v && props.tenant) {
fillFrom(props.tenant)
}
})
/**
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
* (插槽存在时 naive 只渲染插槽内容,不再渲染错误文案)。
* 所以校验失败必须在这里自己把第一条错误弹出来 —— 否则界面上只剩一个红框,
* 用户看到的是"点了保存没反应"。
*/
function firstError(errors: unknown): string | null {
if (!Array.isArray(errors) || !errors.length) {
return null
}
const first = errors[0] as { message?: unknown } | undefined
return typeof first?.message === 'string' ? first.message : null
}
async function onSubmit(): Promise<void> {
try {
await formRef.value?.validate()
} catch (errors) {
message.error(firstError(errors) ?? '请检查表单填写')
return
}
const tenant = props.tenant
if (!tenant) {
return
}
/**
* 请求体按"只传真的改了的语义"组装:
* · `contactPhone` 留空就**不带这个字段**(后端"非空才写",带空串等于没传,但显式省略更清楚);
* · `expireTime` 也只在有值时才带 —— 日期控件清空时为 null,
* 传 `''` 后端解析会炸,传 null 又会被 MyBatis-Plus 的默认更新策略忽略(清不掉)。
* 所以"清空到期日"这件事本轮做不到,界面上也不承诺能清。
*/
const payload: TenantUpdateReq = {
tenantName: form.tenantName.trim(),
tenantType: form.tenantType,
contactName: form.contactName.trim(),
status: form.status
}
const phone = form.contactPhone.trim()
if (phone) {
payload.contactPhone = phone
}
if (form.expireTime) {
payload.expireTime = fmtDate(form.expireTime)
}
submitting.value = true
try {
await tenantApi.updateTenant(tenant.id, payload)
message.success('已保存')
// 先让父页面重新拉列表,再关弹窗:否则关掉的一瞬间列表还是旧值
emit('saved')
emit('update:show', false)
} catch {
// 提示已由 http 层给出(如"状态只能是 0(停用)/ 1(正常)/ 2(试用)")
} finally {
submitting.value = false
}
}
</script>
<template>
<NModal
:show="props.show"
preset="card"
title="编辑租户"
:style="{ width: 'min(620px, calc(100vw - 32px))' }"
:mask-closable="false"
@update:show="(v: boolean) => emit('update:show', v)"
>
<NForm ref="formRef" :model="form" :rules="rules" label-placement="top">
<div class="tedit__grid">
<NFormItem label="单位名称" path="tenantName">
<NInput v-model:value="form.tenantName" placeholder="如 江西机电职业技术学院" clearable />
</NFormItem>
<NFormItem label="类型" path="tenantType">
<NSelect v-model:value="form.tenantType" :options="typeOptions" />
</NFormItem>
<NFormItem label="联系人" path="contactName">
<NInput v-model:value="form.contactName" clearable />
</NFormItem>
<NFormItem label="联系电话" path="contactPhone">
<NInput v-model:value="form.contactPhone" placeholder="留空则不修改" clearable />
<!--
⚠️ 用了 `#feedback` 之后,naive **不再渲染校验错误文案**
(插槽存在就以插槽为准)—— 所以 `onSubmit` 的 catch 里必须自己
`message.error(...)` 把第一条错误弹出来,否则校验失败就是"点了没反应"。
-->
<template #feedback>
<span class="tedit__fb">留空 = 不修改;填了就整条替换(库里存的是密文,看不到原文)</span>
</template>
</NFormItem>
<NFormItem label="到期时间" path="expireTime">
<NDatePicker
v-model:formatted-value="form.expireTime"
type="date"
value-format="yyyy-MM-dd"
class="tedit__full"
clearable
/>
</NFormItem>
<NFormItem label="状态" path="status">
<NSelect v-model:value="form.status" :options="statusOptions" />
</NFormItem>
</div>
<!--
租户编码只读展示。刻意用 NDescriptions 而不是禁用的 NInput:
禁用输入框会让人以为"权限够了就能改",而这条编码是**永远不能改**的 ——
它出现在小程序扫码落地页与对接文档里,改了会让已经发出去的文档、
配好的域名全部失效,而那是客户那边的事,我们无法逐个通知到。
-->
<div class="tedit__readonly">
<div class="tedit__readonly-label">
租户编码
</div>
<div class="tedit__readonly-value">
{{ props.tenant?.tenantCode || '—' }}
</div>
<div class="tedit__readonly-note">
编码出现在小程序扫码落地页与对接文档里,开通后不可修改。
</div>
</div>
</NForm>
<div class="tedit__footer">
<NSpace justify="end">
<NButton @click="emit('update:show', false)">
取消
</NButton>
<NButton type="primary" :loading="submitting" @click="onSubmit">
保存
</NButton>
</NSpace>
</div>
</NModal>
</template>
<style scoped>
.tedit__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 16px;
}
.tedit__full {
width: 100%;
}
.tedit__fb {
font-size: 12px;
color: var(--xt-text-3);
}
/**
* 只读的租户编码块。
*
* ⚠️ 这里**刻意不用 `NCard`**:弹窗本身就是一张卡片,卡里套卡会出现两层"浮起 + 浅灰头"。
* 改用浅灰底 + 弱描边的小格子(与开通弹窗的「首个管理员」块同一套写法)。
*/
.tedit__readonly {
padding: 12px 14px;
background: var(--xt-band-bg);
border: 1px solid var(--xt-card-border-weak);
border-radius: 8px;
}
.tedit__readonly-label {
margin-bottom: 4px;
font-size: 12px;
color: var(--xt-text-3);
}
.tedit__readonly-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.04em;
}
.tedit__readonly-note {
margin-top: 6px;
font-size: 12px;
color: var(--xt-text-3);
}
.tedit__footer {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+303 -10
View File
@@ -13,18 +13,22 @@ import {
NFormItem,
NInput,
NModal,
NPopconfirm,
NSelect,
NSpace,
NTag,
useMessage
} from 'naive-ui'
import { computed, h, onMounted, reactive, ref } from 'vue'
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
import * as tenantApi from '@/apis/xt/tenant'
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
import { listPagination } from '@/composables/pagination'
import AppIcon from '@/icons/AppIcon.vue'
import { useUserStore } from '@/stores/user'
import { fmtDate } from '@/utils/format'
import { tagOf } from '@/utils/tag'
import TenantDetailDrawer from './TenantDetailDrawer.vue'
import TenantEditModal from './TenantEditModal.vue'
/**
* 租户管理(仅平台运营)。
@@ -35,11 +39,29 @@ import { tagOf } from '@/utils/tag'
* 2. **租户列表是跨租户的**(`sys_tenant` 没有 `tenant_id`),权限把关全在服务端;
* 学校管理员调它会拿到 40301,左侧菜单也不会给他这一项。
*
* ⚠️ 列表**没有操作列**:不提供编辑 / 停用入口(客户 2026-10 走查确认的口径)。
* ⚠️ **口径变更(本轮,覆盖 2026-10 走查的旧结论)**:列表**有操作列**了 ——
* 详情 / 编辑 / 停用·启用 三个入口。
* **旧口径**是"列表不提供编辑 / 停用入口";推翻它的原因是运营的真实诉求:
* ① 学校到期日变了,运营要能自己改,而不是每次找开发;
* ② 客户欠费 / 停止合作时要能**当场停掉**(停用会立刻挡住这所学校的
* 学生与老师登录,服务端还会为此单独记一条 WARN 日志);
* ③ 每周核对"谁快到期了"时,光看列表不够,要能点进详情看用量。
* 所以旧注释不是 rebase 丢的,是**被本轮需求明确改掉的**。
* 权限门控:`tenant:view` 管「详情」,`tenant:manage` 管「编辑 / 停用 / 启用 / 开通」;
* 拿不到 `tenant:manage` 的人操作列显示"只读",而不是留一个点了报 403 的按钮。
*/
defineOptions({ name: 'Tenant' })
const message = useMessage()
const userStore = useUserStore()
/**
* 权限门控(**不是**权限码的"定义",字符串必须与后端 `PermissionDef` 逐字一致)。
*
* 没有 `v-hasPerm` 指令,判断就是一次函数调用;`computed` 是为了模板里别每次渲染都算。
*/
const canView = computed(() => userStore.has('tenant:view'))
const canManage = computed(() => userStore.has('tenant:manage'))
const loading = ref(false)
const submitting = ref(false)
@@ -48,6 +70,28 @@ const total = ref(0)
const statusFilter = ref<number | undefined>(undefined)
const keyword = ref('')
/**
* 「即将到期」筛选(默认 30 天内)。
*
* ⚠️ 它与状态筛选是**互斥的两个维度**:同时下发 `status` 与 `expireBefore`
* 会让结果集小到运营以为"这批学校都停用了",而且界面上两个按钮同时高亮,
* 谁也说不清到底在按什么筛。所以点一个就清掉另一个,并在工具条右侧
* 用一行小字把"当前按什么筛的"写出来。
*
* 布尔量而不是 `expireBefore` 字符串:判断"是不是在按到期筛"只需要一个开关,
* 存日期字符串反而要在多处比较是否等于"今天+30 天",第二天就失效。
*/
const expireOnly = ref(false)
/** `yyyy-MM-dd`(**含当天**:服务端 `parseExpireBefore` 的语义是 ≤ 这个日期) */
function todayPlus30(): string {
const d = new Date()
d.setDate(d.getDate() + 30)
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${day}`
}
const query = reactive({ pageNum: 1, pageSize: 10 })
const visible = ref(false)
@@ -55,6 +99,32 @@ const created = ref<TenantCreateResult | null>(null)
const createdName = ref('')
const formRef = ref<FormInst | null>(null)
// 详情抽屉与编辑弹窗各自的状态(都靠 `v-model:show` 与子组件同步)
const detailVisible = ref(false)
const detailId = ref<number | null>(null)
const editVisible = ref(false)
const editing = ref<TenantVO | null>(null)
/**
* 「首个管理员的登录账号」默认值**按租户编码生成**(`admin_jxjd`),不是写死的 `admin`。
*
* ⚠️ 为什么不能用固定默认值:账号唯一性是 `(tenant_id, username)`,即"跨租户重名"
* 本身是被允许的(登录时靠租户码区分)。但**默认值**固定成 `admin` 的后果是
* "运营不动手,每开一所学校就多一个 `admin`" —— 于是不带租户码登录会撞上
* `40012 该账号存在于多个单位`,平台侧看账号也分不清是哪所学校的。
* 默认值跟着编码走,开一所学校天然不重名;运营仍可以改成自己想要的账号名。
*/
const ADMIN_USERNAME_PREFIX = 'admin_'
/** 人有没有手动改过登录账号:改过就不再被编码覆盖(否则会把人填的值冲掉) */
const adminUsernameTouched = ref(false)
/** 上一次**自动填出来**的值。用它区分"人改的"与"我们填的"(两者改的是同一个字段) */
let autoUsername = ''
function deriveAdminUsername(code: string): string {
const c = code.trim().toLowerCase()
return c ? `${ADMIN_USERNAME_PREFIX}${c}` : ''
}
function emptyForm() {
return {
tenantName: '',
@@ -65,7 +135,7 @@ function emptyForm() {
// 用 null 而不是 '':n-date-picker 清空时会回传 null,
// 声明成 string 会在 v-model 上产生类型打架(运行期没事,但 vue-tsc 会红)
expireTime: null as string | null,
adminUsername: 'admin',
adminUsername: '',
adminRealName: '',
adminPhone: ''
}
@@ -114,6 +184,27 @@ const statusChips = computed(() => [
{ label: '已停用', value: 0 }
])
/**
* 「即将到期」的阈值与状态筛选的 30 天必须是同一个数:
* 列表里的橙色与按钮筛出来的行要对得上,否则会出现"筛出来的行一半不是橙的"。
*/
const EXPIRE_WARN_DAYS = 30
/**
* 工具条右侧那行小字:把"当前在按什么筛"写出来。
*
* 为什么必须有:状态筛选与「即将到期」是互斥的两个维度,靠按钮高亮区分不够 ——
* 两组按钮并排放着,用户很容易以为"我全选上了"。
*/
const filterHint = computed(() => {
if (expireOnly.value) {
// 把具体的截止日写出来:只说"即将到期",运营不知道到底包到哪一天
return `当前:${todayPlus30()} 前到期(含当天)`
}
const chip = statusChips.value.find((c) => c.value === statusFilter.value)
return statusFilter.value === undefined ? '' : `当前:${chip?.label ?? ''}`
})
const columns: DataTableColumns<TenantVO> = [
{
title: '单位名称',
@@ -134,8 +225,16 @@ const columns: DataTableColumns<TenantVO> = [
{
title: '到期',
key: 'expireTime',
width: 120,
render: (row) => fmtDate(row.expireTime)
width: 130,
/**
* 到期列的颜色提示:已过期(红)/ 30 天内到期(橙)/ 正常(灰)。
*
* 为什么只上色、不加文字:这一列只有 130px,塞进"还有 12 天"会把编码列挤走;
* 而"快到期了"这件事运营是扫一眼列表找的,颜色足够,细节在详情抽屉里。
* ⚠️ 阈值必须与工具条「即将到期」的 30 天一致,否则会出现
* "按钮筛出来的行里有一半不是橙的"这种自相矛盾的画面。
*/
render: (row) => h('span', { class: expireClass(row.expireTime) }, fmtDate(row.expireTime))
},
{
title: '状态',
@@ -145,9 +244,87 @@ const columns: DataTableColumns<TenantVO> = [
const s = tagOf(TENANT_STATUS, row.status)
return h(NTag, { size: 'small', bordered: false, type: s.type }, { default: () => s.text })
}
},
{
title: '操作',
key: 'actions',
width: 200,
render: actionCell
}
]
/** 一行里可用的操作数最多 3 个(详情/编辑/停用),横排间距小一点不折行 */
const S_ACTIONS = 'display: flex; flex-wrap: wrap; gap: 4px 10px; justify-content: center'
const S_MUTED = 'font-size: 12px; color: #8a8a8a'
/**
* 到期列的样式类。
*
* 空值走 `expire--none`(与"正常"同一个灰)—— 没填到期日的租户不是"已过期",
* 把它标红会让运营去追一个根本不存在的续费问题。
*/
function expireClass(expireTime: string | null | undefined): string {
if (!expireTime) {
return 'tenant__expire--none'
}
const end = Date.parse(`${expireTime.slice(0, 10)}T23:59:59`)
if (Number.isNaN(end)) {
return 'tenant__expire--none'
}
const days = (end - Date.now()) / 86400000
if (days < 0) {
return 'tenant__expire--over'
}
return days <= EXPIRE_WARN_DAYS ? 'tenant__expire--soon' : 'tenant__expire--none'
}
/**
* 行内动作:`详情` / `编辑` / `停用`或`启用`。
*
* 三个刻意的地方:
* · 按钮用 `text` + `size="small"`(本仓行内动作的统一写法,见「收集任务」页);
* · 停用 / 启用走 `NPopconfirm` 二次确认,**停用的文案要写清后果** ——
* 它一下挡住的是这所学校所有学生与老师的登录,不是一次普通的"改状态";
* · 没有 `tenant:manage` 时显示"只读"而不是干脆不给这一列:
* 平台运营里有只读账号(`tenant:view`),"什么都没有"会让人以为是页面坏了。
*/
function actionCell(row: TenantVO) {
const items = []
if (canView.value) {
items.push(h(NButton, {
text: true,
size: 'small',
type: 'primary',
onClick: () => onDetail(row)
}, { default: () => '详情' }))
}
if (canManage.value) {
items.push(h(NButton, {
text: true,
size: 'small',
type: 'primary',
onClick: () => onEdit(row)
}, { default: () => '编辑' }))
// 已停用显示「启用」(主色),正常/试用显示「停用」(警示色)
items.push(h(NPopconfirm, {
onPositiveClick: () => onToggleStatus(row)
}, {
trigger: () => h(NButton, {
text: true,
size: 'small',
type: row.status === 0 ? 'primary' : 'warning'
}, { default: () => (row.status === 0 ? '启用' : '停用') }),
default: () => (row.status === 0
? '恢复这所学校的登录?学生与老师马上就能重新登录。'
: '停用后该校所有学生与老师会立刻无法登录,确认停用?')
}))
}
if (!items.length) {
return h('span', { style: S_MUTED }, '只读')
}
return h('div', { style: S_ACTIONS }, items)
}
/**
* 分页器统一走 `listPagination`(见 `@/composables/pagination`)——
* 手写 `prefix` 的类型最容易写错(naive 给的 `itemCount` 是 `number | undefined`)。
@@ -178,7 +355,8 @@ async function load(): Promise<void> {
pageNum: query.pageNum,
pageSize: query.pageSize,
keyword: keyword.value.trim() || undefined,
status: statusFilter.value
status: statusFilter.value,
expireBefore: expireOnly.value ? todayPlus30() : undefined
})
list.value = page.list ?? []
total.value = page.total ?? 0
@@ -192,13 +370,61 @@ function search(): void {
void load()
}
/** 点状态筛选:清掉「即将到期」,否则两个维度叠加后结果集小得让人以为数据丢了 */
function setStatus(v: number | undefined): void {
expireOnly.value = false
statusFilter.value = v
search()
}
/** 点「即将到期」:这是"切换",再点一次取消;同时清掉状态筛选(互斥的另一半) */
function toggleExpireOnly(): void {
expireOnly.value = !expireOnly.value
if (expireOnly.value) {
statusFilter.value = undefined
}
search()
}
function onDetail(row: TenantVO): void {
detailId.value = row.id
detailVisible.value = true
}
function onEdit(row: TenantVO): void {
editing.value = row
editVisible.value = true
}
/**
* 停用 / 启用。
*
* 只发 `status` 一个字段:`PUT /tenants/{id}` 是**部分更新**(非 null 才写),
* 所以不需要把整行原样回传 —— 回传反而会把 `contactName` 这类
* "列表里是空、库里其实有值"的字段覆盖成空。
* 也**不能**用 `updateTenant` 去改 `expireTime`:那是编辑弹窗的事。
*/
async function onToggleStatus(row: TenantVO): Promise<void> {
const next = row.status === 0 ? 1 : 0
try {
await tenantApi.updateTenant(row.id, { status: next })
message.success(next === 0 ? '已停用,该校用户已无法登录' : '已启用')
await load()
} catch {
// 提示已由 http 层给出
}
}
/** 编辑保存成功:重新拉列表(到期日 / 状态都可能变了) */
function onEdited(): void {
void load()
}
function onAdd(): void {
Object.assign(form, emptyForm())
// 每次打开弹窗都重新开始"跟着编码走"的状态,否则第二次打开时人会发现自己改不动账号名
adminUsernameTouched.value = false
autoUsername = ''
created.value = null
visible.value = true
}
@@ -251,6 +477,23 @@ function closeCreated(): void {
visible.value = false
}
// ── 登录账号跟着租户编码走(默认值,人一改就交还给填写者) ──
watch(() => form.tenantCode, (code) => {
if (adminUsernameTouched.value) {
return
}
autoUsername = deriveAdminUsername(code)
form.adminUsername = autoUsername
})
watch(() => form.adminUsername, (v) => {
// 只有"值不是我们刚填进去的那个"才算人改的 —— 少了这一层,
// 自动填充本身就会把字段标成"已手改",编码再变一次就再也不跟了
if (v !== autoUsername) {
adminUsernameTouched.value = true
}
})
onMounted(load)
</script>
@@ -267,22 +510,33 @@ onMounted(load)
工具条:主操作在左、筛选在右(与收集任务页同一口径)。
同一行里所有控件统一**默认尺寸** —— 全是 28px 的小控件挤在 52px 的一行里,
白底小按钮贴在白底卡片上几乎看不出是按钮。
「即将到期」与状态按钮组是**互斥的两个筛选维度**(点一个清另一个),
所以它们之间用一条竖线分开,右侧再用一行小字说明"当前在按什么筛"——
光靠按钮高亮区分不够,用户会以为两组是叠加的。
-->
<div class="toolbar">
<NSpace :size="8">
<NButton type="primary" @click="onAdd">
<NButton v-if="canManage" type="primary" @click="onAdd">
<template #icon>
<AppIcon name="plus" :size="16" />
</template>
开通租户
</NButton>
<NButton
:type="expireOnly ? 'primary' : 'default'"
@click="toggleExpireOnly"
>
即将到期
</NButton>
</NSpace>
<NSpace :size="8" align="center">
<span v-if="filterHint" class="tenant__filter-hint">{{ filterHint }}</span>
<NButton
v-for="c in statusChips"
:key="String(c.value)"
:type="statusFilter === c.value ? 'primary' : 'default'"
:type="!expireOnly && statusFilter === c.value ? 'primary' : 'default'"
@click="setStatus(c.value)"
>
{{ c.label }}
@@ -302,6 +556,8 @@ onMounted(load)
<!--
⚠️ Naive 的表格**没有** `scroll` prop(那是 Arco 的 API):
横向用 `:scroll-x`(数值),纵向靠 `flex-height` + 高度链。
加了「操作」列(200px)后总宽从 800 涨到 1040,`:scroll-x` 要跟着改 ——
不改的话窄屏下按钮会被压成竖排的两三个字。
-->
<NDataTable
flex-height
@@ -311,7 +567,7 @@ onMounted(load)
:data="list"
:loading="loading"
:row-key="row => row.id"
:scroll-x="800"
:scroll-x="1040"
:pagination="pagination"
size="small"
@update:page="onPage"
@@ -319,6 +575,15 @@ onMounted(load)
/>
</div>
<!--
详情抽屉 / 编辑弹窗。
两个都用 `v-model:show`(而不是像审核详情那样的 `defineExpose({ open })` 命令式打开):
详情要跟着"点的是哪一行"走,`tenant-id` 一变就重拉 —— 命令式在那个场景下
要么多一个 open(row) 的参数,要么在父组件里手工把 id 与开关对齐,更容易错。
-->
<TenantDetailDrawer v-model:show="detailVisible" :tenant-id="detailId" />
<TenantEditModal v-model:show="editVisible" :tenant="editing" @saved="onEdited" />
<!-- 开通租户:成功态与表单态共用一个弹窗,弱化"关掉就看不到密码了"的风险 -->
<NModal
v-model:show="visible"
@@ -399,7 +664,11 @@ onMounted(load)
</div>
<div class="tenant__grid tenant__grid--3">
<NFormItem label="登录账号" path="adminUsername">
<NInput v-model:value="form.adminUsername" clearable />
<NInput
v-model:value="form.adminUsername"
placeholder="按租户编码自动生成,如 admin_jxjd"
clearable
/>
</NFormItem>
<NFormItem label="姓名" path="adminRealName">
<NInput v-model:value="form.adminRealName" clearable />
@@ -456,6 +725,30 @@ onMounted(load)
width: 220px;
}
/* 「当前按什么筛」那行小字。放在按钮组左边,读起来像"这批数据的条件" */
.tenant__filter-hint {
font-size: 12px;
color: var(--xt-text-3);
}
/**
* 到期列的三档颜色。
*
* 用 `:deep()` 是因为它们在表格 render 出来的节点上(`scoped` 的属性选择器
* 加不到 `h()` 生成的元素上 —— 这是本仓列表页的通用坑,不写就静默不上色)。
*/
:deep(.tenant__expire--over) {
color: #d03050;
}
:deep(.tenant__expire--soon) {
color: #d97706;
}
:deep(.tenant__expire--none) {
color: var(--xt-text-2);
}
.tenant__id,
:deep(.tenant__id) {
font-size: 12px;
+71 -1
View File
@@ -21,7 +21,7 @@
逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表,
而不是靠下次再想一遍)。
## 查九件事
## 查十件事
| # | 查什么 | 没接上的后果 |
|:--:|---|---|
@@ -34,6 +34,7 @@
| 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) |
| 8 | PC 端模板里的 `<n-*>` 组件有没有对应 `import` | 被当未知元素渲染:不报错,样式整块丢(见下) |
| 9 | **`.svg` 资产是不是合法 XML** | 图标裂开,控制台一句话都不报(见下) |
| 10 | **后端会写的日志动作码 ↔ PC 端 `OPERATION_ACTIONS` 的文案** | 日志页原样回显英文码(见下) |
### 第 7 项是 2026-10-09 补的(自己踩了一次)
@@ -58,6 +59,20 @@
后两项严格说不是"声明没接线",而是**同一族:坏了但没有任何工具会红**。
放这里是因为它想要的就是"一条命令把这一族扫一遍"。
### 第 10 项是 2026-10 补的(做租户管理与日志详情时)
日志页的 `actionText()` **查不到就原样回显那个码**:界面上出现 `AUTO_CLOSE`
这种没翻译的英文,编译 / lint / typecheck / review 全都不红。
加这一项时顺带发现了一件更值得记的事:`AUTO_CLOSE` **根本不在常量表里** ——
它在 `TaskService.autoClose()` 里是用字符串字面量写的。也就是说,
"后端常量 ↔ 前端清单"这种检查**本来就扫不到它**;写这个检查的同时
得先把字面量提成常量(`ACTION_AUTO_CLOSE`),检查才真的有覆盖。
**这一族检查的价值,取决于被检查的那一侧有没有被规范化。**
⚠️ 与第 7 项同一个教训:**加完必须反向验证**(删掉前端一条文案,它得红),
否则又是一个"扫不到却判为通过"。
## 它**不查**什么(免得被当成"全绿就没事")
@@ -108,6 +123,10 @@ PERM_NO_ROLE = {}
# PC 端引用的权限码:PermissionDef 里没有的(**应当保持为空** ——
# 写错一个码的后果是"那个判断恒为 false",整块界面静默消失,见文件头第 7 项)
FE_PERM_EXCEPTION = {}
# 第 ⑩ 项:后端会写的日志动作码,却**有意**不在 PC 端给中文文案的。
# 现在没有例外 —— 少一个文案就会在日志页上原样回显英文码,没有"故意不翻译"的理由。
ACTION_TEXT_EXCEPTION = {}
ERROR_CODE_UNUSED = {
'ID_CARD_CHECKSUM_FAILED': 'M4 识别与校验(等 OCR 选型,见 docs/01 D1)',
'ID_CARD_MISMATCH_ROSTER': '同上(M4)',
@@ -642,6 +661,57 @@ def main():
if not svg_bad:
say(' ✓ 全部能被解析为合法 XML')
# ── ⑩ 后端会写的日志动作码 ↔ PC 端的中文文案 ──────────────
#
# ⚠️ 为什么属于这一族:PC 端的 `actionText()` **查不到就原样回显那个码**
# (不是空白,也不报错)。于是日志页上会出现一个没翻译的英文码,
# 而编译 / lint / typecheck / review **全都不红** —— 只有人盯着那一列才看得出来。
#
# ⚠️ 加这一项时顺带发现的:`AUTO_CLOSE`(定时任务自动截止)**根本不在常量表里**,
# 是字符串字面量写的 —— 所以"常量 ↔ 前端清单"这种检查本来就扫不到它。
# 已提成 `OperationLogService.ACTION_AUTO_CLOSE`,这条检查才真的有覆盖。
#
# ⚠️ 刻意**只从 `OPERATION_ACTIONS` 这个数组里取**前端清单:同文件里
# `MODULE_OPTIONS` / `TARGET_TYPE_OPTIONS` 也是 `{ value: 'TASK' }` 的形状,
# 整文件扫会把 `FILE` / `TENANT` 这些模块名当成动作码收进来(假阳性)。
LOG_SERVICE = ('backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/'
'service/OperationLogService.java')
PC_LOG_API = 'frontend-pc/src/apis/xt/log.ts'
be_actions = set(re.findall(r'public static final String ACTION_\w+ = "([A-Z_]+)";',
strip_java_comments(read(LOG_SERVICE))))
blk = re.search(r'export const OPERATION_ACTIONS[^=]*=\s*\[(.*?)\n\]',
strip_js_comments(read(PC_LOG_API)), re.S)
fe_actions = set(re.findall(r"value:\s*'([A-Z_]+)'", blk.group(1) if blk else ''))
say('\n【⑩】日志动作码:后端常量 %d 个 / PC 端文案 %d 个'
% (len(be_actions), len(fe_actions)))
if not be_actions or not fe_actions:
# 解析失败必须**报红**,不能静默通过 —— 第 ⑦ 项就是栽在这里
# (重搭后写法变了,它"一个码都扫不到却判为通过")。
problems.append(('日志动作码', '解析失败',
'后端解析出 %d 个 / PC 端解析出 %d 个 —— 0 就是检查器自己失效了,'
'不是"没问题"' % (len(be_actions), len(fe_actions))))
say(' ✗ 解析失败(后端 %d / PC %d):0 表示检查器失效,先修检查器'
% (len(be_actions), len(fe_actions)))
else:
for code in sorted(be_actions - fe_actions):
if code in ACTION_TEXT_EXCEPTION:
notes.append('日志动作码 %s —— 已知例外:%s' % (code, ACTION_TEXT_EXCEPTION[code]))
say(' — %-18s %s' % (code, ACTION_TEXT_EXCEPTION[code]))
else:
problems.append(('日志动作码', code,
'后端会写它,但 PC 端 OPERATION_ACTIONS 没有文案'
'(日志页会原样显示这个英文码)'))
say(' ✗ %-18s 后端会写,PC 端没文案 → 界面回显英文码' % code)
for code in sorted(fe_actions - be_actions):
problems.append(('日志动作码', code,
'PC 端有文案,但后端没有任何 ACTION_* 常量等于它'
'(选中它永远筛不到东西)'))
say(' ✗ %-18s PC 端有文案,后端没这个码 → 选了永远筛不到' % code)
if be_actions == fe_actions:
say(' ✓ %d 个动作码两端逐字一致:%s'
% (len(be_actions), '、'.join(sorted(be_actions))))
# ── 汇总 ────────────────────────────────────────────────
print('\n' + '=' * 88)
if notes:
+94 -2
View File
@@ -16,7 +16,14 @@ M0 验收测试。
退出码:0 = 全部通过;1 = 有失败项(可直接用于 CI)。
依赖:cryptography
依赖:cryptography;且需要能 `docker exec xt-pg`(**清理本脚本造出的测试租户与测试组织**)。
⚠️ 关于"跑完要清干净"(2026-10 补)
本脚本以前**完全不清理**:每跑一次就留下一个 `e2e######` 租户(里面有一个叫 `admin`
的管理员)+ demo 租户下的 3 个测试组织。跑两轮之后,库里同一时刻就存在两个
`admin` —— 而"账号跨单位重名"这条保护(40012)一旦被别的东西当作前提,
清理残留这一个动作就会把那个脚本**静默反转**(见 `e2e-m1-tenant-domain.py` 文件头)。
所以:跑完(含中途崩溃)必须把自己造的东西删干净,且**只删自己建的**。
"""
from __future__ import annotations
@@ -24,6 +31,7 @@ import argparse
import base64
import json
import os
import subprocess
import sys
import time
import urllib.error
@@ -35,9 +43,18 @@ if hasattr(sys.stdout, "reconfigure"):
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")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
#: 本脚本造出来的东西。放模块级 —— 脚本中途抛异常时也要能被清掉。
CREATED_TENANT_IDS: list[int] = []
CREATED_ORG_IDS: list[int] = []
# ── HTTP ──────────────────────────────────────────────
@@ -129,6 +146,58 @@ def finish() -> int:
return 0 if not FAILED else 1
# ── 清理 ──────────────────────────────────────────────
def sql(stmt: str) -> tuple[bool, str]:
"""直接执行 SQL —— 只用于清理测试夹具。"""
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: # noqa: BLE001
return False, str(e)
_TENANT_TABLES: list[str] | None = None
def tables_with_tenant_id() -> list[str]:
"""
带 `tenant_id` 的表 —— **动态枚举**,不手抄表名。
手抄的清单在"新增了一张业务表"那天就开始漏,而漏掉的那张表恰好很难被注意到
(租户没了、行还在)。`dev-clean-e2e.py` 是同一口径。
"""
global _TENANT_TABLES
if _TENANT_TABLES is None:
_, out = sql("SELECT table_name FROM information_schema.columns "
"WHERE table_schema='public' AND column_name='tenant_id'")
_TENANT_TABLES = [t.strip() for t in out.splitlines() if t.strip()]
return _TENANT_TABLES
def cleanup_all() -> None:
"""
删掉本脚本造出来的数据。**幂等**,可以重复调用。
⚠️ 只删**自己建的**(按本次跑出来的 id):这里刻意不做
`tenant_code LIKE 'e2e%'` 那种"顺手全清" —— 那是 `dev-clean-e2e.py` 的活。
测试脚本越界删别人建的东西,就会让"跑一个脚本把另一个脚本的在途数据删了"
这种事变得可能。
"""
for oid in list(CREATED_ORG_IDS):
sql(f"DELETE FROM sys_org WHERE id = {oid}")
CREATED_ORG_IDS.remove(oid)
for tid in list(CREATED_TENANT_IDS):
for table in tables_with_tenant_id():
sql(f"DELETE FROM {table} WHERE tenant_id = {tid}")
sql(f"DELETE FROM sys_tenant WHERE id = {tid}")
CREATED_TENANT_IDS.remove(tid)
# ── 用例 ──────────────────────────────────────────────
def main() -> int:
@@ -216,6 +285,8 @@ def main() -> int:
expect_code("B2 平台运营开通租户成功", b, 0)
created = data_of(b)
new_tenant_id = created.get("id")
if isinstance(new_tenant_id, int):
CREATED_TENANT_IDS.append(new_tenant_id) # 登记,收尾与崩溃路径都要清它
new_pwd = created.get("adminInitialPassword")
check("B3 返回初始密码(仅此一次)", bool(new_pwd), json.dumps(created, ensure_ascii=False)[:200])
check("B4 返回小程序码参数", created.get("miniProgramScene") == f"t{new_tenant_id}",
@@ -262,6 +333,10 @@ def main() -> int:
b = mk_org(demo_token, dept_id, f"E2E 24数控1班 {stamp}", 3, f"CLS{stamp}")
expect_code("C3 建班级节点", b, 0)
class_id = b.get("data")
# 登记(C1/C2/C3 若失败,对应的 data 是 None,不能登记成 0/None)
for oid in (school_id, dept_id, class_id):
if isinstance(oid, int):
CREATED_ORG_IDS.append(oid)
expect_code("C4 同级重复组织编码被拒(40001)",
mk_org(demo_token, school_id, "重复编码", 2, f"DEPT{stamp}"), 40001)
@@ -321,8 +396,25 @@ def main() -> int:
check("E4 参数校验失败返回 40001 且带字段级信息", b.get("code") == 40001,
f"code={b.get('code')}")
# ── F. 清理 ───────────────────────────────────────
print("\n[F] 清理本次造出的测试数据")
n_tenant, n_org = len(CREATED_TENANT_IDS), len(CREATED_ORG_IDS)
cleanup_all()
print(f" 已清理 {n_tenant} 个测试租户(连它的角色与账号)"
f"、{n_org} 个挂在 demo 下的测试组织")
check("F1 测试租户与测试组织都删掉了", not CREATED_TENANT_IDS and not CREATED_ORG_IDS,
f"tenant={CREATED_TENANT_IDS} org={CREATED_ORG_IDS}")
return finish()
if __name__ == "__main__":
raise SystemExit(main())
# 崩溃也要清:否则每次失败的运行都往库里丢一个 e2e 租户和一个叫 admin 的账号
# (跨租户同名账号就是这么攒出来的,见文件头 ⚠️)
try:
_code = main()
except BaseException:
print("\n ⚠ 脚本异常退出,正在清理已造出的测试数据…")
cleanup_all()
raise
raise SystemExit(_code)
+134 -13
View File
@@ -14,8 +14,20 @@
所以这条比第 ① 条更重要。
跑法:python scripts/e2e-m1-tenant-domain.py
前提:后端 8080 在跑,且 `fill.tenant-domain` 已启用(dev 默认
enabled=true / base=localhost —— 只有 *.localhost 会走这条路,不影响其它域名)。
前提:后端 8080 在跑;能 `docker exec xt-pg`(造/清"跨租户同名账号"夹具)。
开关开与关都能跑(dev 默认 enabled=false,脚本自己探测)。
⚠️ 「账号跨单位重名」(40012)这个前提,本脚本**自己造、自己清**(2026-10 改)
40012 只有在**真的有两个租户用同一个账号名**时才会触发。以前这里靠的是
"E2E 残留的测试租户凑巧也建了一个 admin" —— 那是个隐式依赖:
跑一次 `python scripts/dev-clean-e2e.py` 把残留清掉,本脚本就会**静默反转**
(账号变唯一 → 不填租户码也能直接登录 → 一整套 40012 断言全反,
连 `detect_mode()` 都会把关闭态误判成开启态,报 8 条莫名其妙的 FAIL)。
所以现在:跑之前建一个 `e2edup######` 租户 + 里面的 admin,跑完(含中途崩溃)删掉。
夹具在,40012 才是可复现的;夹具不在,dev 库里就不该有同名账号。
顺带修掉了同一族的第二处隐式依赖:原脚本假定 `t14.localhost` 是"e2e 建的、
存在且启用的另一个租户",而租户 14 早就被清掉了 —— 改成用夹具租户自己的 id。
"""
from __future__ import annotations
@@ -24,16 +36,26 @@ import base64
import http.client
import json
import os
import subprocess
import sys
import time
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")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
#: 本脚本造出来的「跨租户同名账号」夹具租户 id。模块级 —— 脚本中途抛异常也要能清掉。
FIXTURE_TENANT_IDS: list[int] = []
def ok(name: str) -> None:
PASSED.append(name)
@@ -53,6 +75,93 @@ def check(name: str, cond: bool, detail: str = "") -> bool:
return cond
# ── 重名夹具:造 / 清 ────────────────────────────────────
#
# 为什么必须自己造(而不是"库里本来就有"):见文件头那段 ⚠️。
# 一句话 —— **靠残留数据凑前提的测试,会被"清理残留"这一个动作静默反转**。
def sql(stmt: str) -> tuple[bool, str]:
"""
直接执行 SQL —— 只用于造/清夹具。
⚠️ 造数据时用 `WITH … AS (INSERT … RETURNING id) SELECT id FROM …` 的写法,
**不要**直接 `INSERT … RETURNING id`:后者 psql 会把返回值与命令标签
(`INSERT 0 1`)一起打出来,解析的人一不留神就把 `INSERT 0 1` 当成 id 了。
"""
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: # noqa: BLE001
return False, str(e)
def create_duplicate_fixture() -> int | None:
"""
造一个「另一个租户里也有一个叫 admin 的账号」。
只造登录定位真正需要的两行:`sys_tenant` + `sys_user`。
**故意不给密码** —— 40012 在比对密码之前就抛了(`AuthService.locateUser`),
给密码只会让人误以为这个账号能登进去。
租户编码用 `e2edup` 前缀:万一脚本被强杀(SIGKILL,`finally` 跑不到),
`scripts/dev-clean-e2e.py`(按 `tenant_code LIKE 'e2e%'` 收)还能兜底清掉。
"""
code = f"e2edup{str(int(time.time()))[-6:]}"
okc, out = sql(
"WITH ins AS (INSERT INTO sys_tenant "
"(tenant_code, tenant_name, tenant_type, contact_name, status) "
f"VALUES ('{code}', 'E2E 重名夹具', 1, '测试联系人', 1) RETURNING id) "
"SELECT id FROM ins")
# 取**第一行**判 id:psql 可能把命令标签(`INSERT 0 1`)也打在同一份输出里。
# ⚠️ 这里踩过一次:整串 isdigit() 判不过 → 直接 return None,
# 而那时 id 还没登记进 FIXTURE_TENANT_IDS → 租户行留在库里没人清。
first = out.strip().splitlines()[0].strip() if out.strip() else ""
if not okc or not first.isdigit():
print(f" ✗ 造重名夹具失败:{out}")
return None
tid = int(first)
# 拿到 id 立刻登记:后面任何一步失败,它都能被清掉
FIXTURE_TENANT_IDS.append(tid)
okc, out = sql(
"WITH ins AS (INSERT INTO sys_user "
"(tenant_id, username, real_name, user_type, status) "
f"VALUES ({tid}, 'admin', 'E2E 重名夹具', 2, 1) RETURNING id) "
"SELECT id FROM ins")
if not okc:
print(f" ✗ 造重名夹具账号失败:{out}")
return None
print(f" 已造重名夹具:租户 id={tid}({code}),里面也有一个 admin")
return tid
def cleanup_fixture() -> None:
"""删掉夹具。**幂等** —— main 的 finally 与函数内的显式调用各来一次。"""
for tid in list(FIXTURE_TENANT_IDS):
sql(f"DELETE FROM sys_user WHERE tenant_id = {tid}")
sql(f"DELETE FROM sys_tenant WHERE id = {tid}")
FIXTURE_TENANT_IDS.remove(tid)
def cleanup_and_verify() -> None:
"""
用完就删,并**断言"同名账号"这个前提真的消失了**。
这条断言守的是本脚本自己的副作用:夹具没清干净的话,
下一轮跑起来前提就是假的,而 dev 库里会多出两个同名 admin ——
正是"不同租户不要出现相同账号名"要清掉的那种垃圾。
"""
print("\n[清理] 重名夹具用完即删")
cleanup_fixture()
again = login("localhost", None)
check("夹具删除后 demo 的 admin 又变回唯一 → 不填租户码可直接登录",
again.get("code") == 0,
f"code={again.get('code')} msg={again.get('msg')}"
"(库里还有别的同名 admin:先跑 python scripts/dev-clean-e2e.py 清残留)")
def rsa_encrypt(plain: str) -> str:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
@@ -94,7 +203,7 @@ def login(host: str, tenant_code: str | None = None, account: str = "admin",
return call("POST", "/api/v1/auth/login", host, body=body)
def run_on() -> int:
def run_on(fixture_tid: int) -> int:
ap = argparse.ArgumentParser(description="租户域名 E2E")
ap.add_argument("--password", default="Admin@123")
args = ap.parse_args()
@@ -144,9 +253,10 @@ def run_on() -> int:
check("拿 t1 的 token 访问 t1 域名 → 正常",
call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t1.localhost", token1).get("code") == 0,
"同租户被误拒")
# 14 号租户是 e2e 建的(status=1,存在且启用),所以 t14.localhost 能被解析成一个真实租户
cross = call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t14.localhost", token1)
check("**拿 t1 的 token 访问 t14 域名 → 403(拒绝)**",
# 夹具租户就是"存在、启用、且不是 t1"的另一个真实租户,
# 所以 t{夹具id}.localhost 能被解析成一个真实租户
cross = call("GET", "/api/v1/users?pageNum=1&pageSize=1", f"t{fixture_tid}.localhost", token1)
check(f"**拿 t1 的 token 访问 t{fixture_tid} 域名 → 403(拒绝)**",
cross.get("code") == 40301 or cross.get("_http") == 403
or "另一个单位" in (cross.get("msg") or ""), f"{cross}")
check("拒绝时给出可理解的提示(不是一句「无权限」)",
@@ -164,6 +274,8 @@ def run_on() -> int:
check("无关域名(example.com)不解析",
login("example.com", None).get("code") == 40012, "无关域名被误当租户")
cleanup_and_verify()
print()
print("=" * 76)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
@@ -190,7 +302,7 @@ def detect_mode() -> str:
raise SystemExit(f" ✗ 探测失败(既不是 0 也不是 40012):{r}")
def run_off() -> int:
def run_off(fixture_tid: int) -> int:
"""
关闭态(当前默认):验"关掉之后,行为与从前**完全一致**"。
@@ -214,13 +326,15 @@ def run_off() -> int:
d.get("code") == 40012, f"{d}")
token = (b.get("data") or {}).get("token")
e = call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t14.localhost", token)
check("关掉之后**不引入任何新校验**:带着 t1 的 token 访问 t14 域名照常通过",
e = call("GET", "/api/v1/users?pageNum=1&pageSize=1", f"t{fixture_tid}.localhost", token)
check(f"关掉之后**不引入任何新校验**:带着 t1 的 token 访问 t{fixture_tid} 域名照常通过",
e.get("code") == 0,
f"{e}(这是关闭前的既有行为;开启态下会被 403 拒掉,见 run_on)")
ok("要验开启态:以 XT_TENANT_DOMAIN_ENABLED=true 重启后端,再跑本脚本")
cleanup_and_verify()
print()
print("=" * 76)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
@@ -232,10 +346,17 @@ def run_off() -> int:
def main() -> int:
mode = detect_mode()
if mode == "ON":
return run_on()
return run_off()
# 夹具必须在**探测模式之前**造好:detect_mode() 正是靠"不填租户码会不会 40012"
# 判断开关的,而没有同名账号时那条路会直接登录成功 → 关闭态被误判成开启态(见文件头 ⚠️)
fixture_tid = create_duplicate_fixture()
if fixture_tid is None:
return 1
try:
mode = detect_mode()
return run_on(fixture_tid) if mode == "ON" else run_off(fixture_tid)
finally:
# 中途崩溃也要清 —— 否则"清残留"这件事自己开始制造残留
cleanup_fixture()
if __name__ == "__main__":
+143 -2
View File
@@ -14,6 +14,11 @@
断言要看的是"**它承认自己没接**",而不是"字段存在"。
③ **上传凭证的 `existed=true` 时不返回 uploadUrl**。
返回了而前端不用,会让"到底传没传"变成两处判断 —— 迟早不一致。
④ **租户用量必须只算"这一所学校"**。平台运营的 `tenantId` 是 0,而租户拦截器
对它**整张表关掉过滤**(`FillTenantLineHandler.ignoreTable`)—— 于是
`/tenants/{id}/usage` 会算成**全平台**的学生数与存储量,挂在某一所学校的名下。
⚠️ 这个 bug 在"库里只有一所学校有数据"时**与正确值完全相等**,光断言
"有值、非 null"永远发现不了 —— 所以本脚本要**先造出别的租户的数据**再断言。
## 还有一条不能省的边界
@@ -24,6 +29,7 @@
跑法:python scripts/e2e-m8-orgfile.py
"""
import argparse
import datetime
import importlib.util
import json
import os
@@ -38,6 +44,8 @@ PASSED = []
FAILED = []
CREATED_TASK_IDS = []
CREATED_ORG_NAMES = []
CREATED_TENANT_IDS = []
USAGE_FIXTURE_HASH_PREFIX = "e2e-usage-scope-"
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-of-roster.xlsx")
TMP_ORG = os.path.join(ROOT, "scripts", "_e2e-of-orgs.xlsx")
TINY_JPEG = os.path.join(ROOT, "scripts", "_e2e-of.jpg")
@@ -250,6 +258,103 @@ def main():
check("学生看租户用量 → 403", b.get("code") in (40101, 40301, 403),
"code=%s" % b.get("code"))
# ── 2b. 详情脱敏 / 用量口径 / 变更留痕 / 到期日边界 ──
print("\n[2b] 租户:详情脱敏 / 用量口径 / 变更留痕 / 到期日边界")
# ① 详情接口必须可用,且联系电话是**脱敏值**。
# ⚠️ 修复前这条会 500:`update()` 把手机号**明文**写进了加密列,
# 而 `AesGcmCipher.decrypt` 对非 Base64 的输入直接抛 —— 库里 id=1 就是那种行。
det = body_of("GET", "/api/v1/tenants/1", ops_token)
check("⭐ 租户详情可用(历史明文手机号不能让详情页打不开)",
det.get("code") == 0, json.dumps(det, ensure_ascii=False)[:250])
check("详情给出**脱敏**后的联系电话(形如 139****1111)",
"****" in str((det.get("data") or {}).get("contactPhone")),
"contactPhone=%r" % ((det.get("data") or {}).get("contactPhone"),))
# ② 改手机号:落库必须是密文,并且能读回脱敏值(写得进 + 解得开)
upd = body_of("PUT", "/api/v1/tenants/1", ops_token, {"contactPhone": "13712345678"})
check("改联系电话成功", upd.get("code") == 0, json.dumps(upd, ensure_ascii=False)[:250])
stored_phone = sql("select contact_phone from sys_tenant where id=1;").strip()
check("⭐ 联系电话以**密文**落库(明文落库会让详情页从此永远打不开)",
stored_phone != "13712345678" and len(stored_phone) > 20, "stored=%r" % stored_phone)
det2 = body_of("GET", "/api/v1/tenants/1", ops_token)
check("⭐ 新写的号能读回脱敏值(证明密文与解密对得上,不是「写进去就算了」)",
(det2.get("data") or {}).get("contactPhone") == "137****5678",
"contactPhone=%r" % ((det2.get("data") or {}).get("contactPhone"),))
# ③ 到期日筛选:格式不对要**明确报错**,不能静默忽略
bad_exp = body_of("GET", "/api/v1/tenants?expireBefore=" + urllib.parse.quote("去年"), ops_token)
check("⭐ expireBefore 写错 → 明确报错(静默忽略会让人以为「我按到期日筛过了」)",
bad_exp.get("code") != 0, "code=%s msg=%s" % (bad_exp.get("code"), bad_exp.get("msg")))
# ④ 后面几条用一个**新建的**租户(不动 demo,免得影响别的用例)
today = datetime.date.today().isoformat()
_, created = R.call("POST", BASE + "/api/v1/tenants", ops_token, {
"tenantName": "E2E用量学校 " + suffix,
"tenantCode": "e2eusage" + suffix, # ⚠️ 必须以 e2e 开头,dev-clean-e2e.py 按它清
"tenantType": 1, "contactName": "E2E用量联系人", "contactPhone": "13900139009",
"expireTime": today, "adminUsername": "admin",
"adminRealName": "E2E用量管理员", "adminPhone": "13900139010",
})
ntid = ((created or {}).get("data") or {}).get("id")
check("建一所 E2E 租户(后面的断言靠它,收尾会清掉)", isinstance(ntid, int),
json.dumps(created, ensure_ascii=False)[:250])
if isinstance(ntid, int):
CREATED_TENANT_IDS.append(ntid)
# 开通就要留痕(F-1 点名「租户开通」是优先留痕点,而本模块原先一条都不写)
row = sql("select action || '|' || tenant_id from sys_operation_log "
"where target_type='TENANT' and target_id=%d order by id desc limit 1;" % ntid).strip()
check("⭐ 开通租户留痕(target_type=TENANT & target_id=租户 id)",
row.startswith("CREATE_TENANT|"), "最新一行=%r" % row)
check("⭐ 留痕的 tenant_id 是 **0**(平台身份),不是被操作的那所学校 —— "
"所以查「这所学校是谁开通的」要用 target,不能用 tenant_id 筛",
row.split("|")[-1] == "0", "row=%r" % row)
# 状态流转:2 试用 / 0 停用 / 1 启用,各自留痕(停用与启用不能并成一个动作)
for st, act in ((2, "UPDATE_TENANT"), (0, "DISABLE_TENANT"), (1, "ENABLE_TENANT")):
r = body_of("PUT", "/api/v1/tenants/%d" % ntid, ops_token, {"status": st})
check("status=%d 能改(⚠️ status=2「试用」原先被拒:只放行 0/1,而前端有「试用」)" % st,
r.get("code") == 0, json.dumps(r, ensure_ascii=False)[:200])
got = sql("select action from sys_operation_log where target_type='TENANT' "
"and target_id=%d order by id desc limit 1;" % ntid).strip()
check("status→%d 的留痕动作是 %s" % (st, act), got == act, "实际=%r" % got)
r4 = body_of("PUT", "/api/v1/tenants/%d" % ntid, ops_token, {"tenantType": 4})
check("⭐ tenantType=4(企业)能改(原先只放行 1~3,而前端下拉有 4 项)",
r4.get("code") == 0, json.dumps(r4, ensure_ascii=False)[:200])
# 到期日**含当天**:把到期日设成今天,`expireBefore=今天` 必须能查到它
body_of("PUT", "/api/v1/tenants/%d" % ntid, ops_token, {"expireTime": today})
lst = body_of("GET", "/api/v1/tenants?expireBefore=%s&pageSize=200" % today, ops_token)
hit = [t.get("id") for t in ((lst.get("data") or {}).get("list") or [])]
check("⭐ expireBefore=今天 能查到「今天到期」的租户"
"(原先用 atStartOfDay() 比,差一天,查不到)",
ntid in hit, "今天=%s 命中 id=%s" % (today, hit[:10]))
# ⭐ 用量口径 —— 本段最重要的一条。
# 平台身份会让租户拦截器**整张表关掉过滤**,所以 `/tenants/{id}/usage`
# 曾经返回的是**全平台**的学生数与存储量。库里只有一所学校有数据时
# 它与正确值完全相等,所以这里必须先往**另一个租户**塞一行名册。
R.sql("insert into collect_roster (tenant_id, task_id, real_name, id_card_no, id_card_hash) "
"values (%d, -1, 'E2E用量范围', 'x', '%s%s');"
% (ntid, USAGE_FIXTURE_HASH_PREFIX, suffix))
own = int(sql("select count(distinct id_card_hash) from collect_roster "
"where tenant_id=1 and deleted=0;").strip() or 0)
allc = int(sql("select count(distinct id_card_hash) from collect_roster "
"where deleted=0;").strip() or 0)
u1 = (body_of("GET", "/api/v1/tenants/1/usage", ops_token).get("data") or {})
check("⭐ 平台看到的「1 号学校学生数」= 库里 tenant_id=1 的去重人数,**不是全平台**",
u1.get("studentCount") == own,
"接口=%s 库里 tenant_id=1 去重=%s 全平台去重=%s(接口等于后者就说明过滤被算丢了)"
% (u1.get("studentCount"), own, allc))
un = (body_of("GET", "/api/v1/tenants/%d/usage" % ntid, ops_token).get("data") or {})
check("⭐ 新租户看到的是**它自己**那一行(顺带证明不是恒 0)",
un.get("studentCount") == 1, "新租户 studentCount=%s" % un.get("studentCount"))
check("全平台确实不止 1 个(否则上面那条断言是空转的)", allc > own,
"全平台=%s 一号学校=%s" % (allc, own))
# ── 3. 文件夹具 ──
print("\n[3] 摆文件夹具(一个任务 + 一份草稿 + 一个材料文件)")
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
@@ -476,6 +581,24 @@ def _make_jpeg():
f.write(base64.b64decode(b64))
_TENANT_TABLES = None
def _tables_with_tenant_id():
"""
带 `tenant_id` 的表 —— **动态枚举**,不手抄表名。
手抄的清单在"新增了一张业务表"那天就开始漏,而漏掉的那张表恰好很难被注意到
(租户没了、行还在)。`dev-clean-e2e.py` 是同一口径。
"""
global _TENANT_TABLES
if _TENANT_TABLES is None:
out = sql("SELECT table_name FROM information_schema.columns "
"WHERE table_schema='public' AND column_name='tenant_id'")
_TENANT_TABLES = [t.strip() for t in out.splitlines() if t.strip()]
return _TENANT_TABLES
def cleanup():
for tid in CREATED_TASK_IDS:
R.sql("delete from collect_validation_issue where task_id=%d;" % tid)
@@ -489,12 +612,30 @@ def cleanup():
R.sql("delete from collect_task where id=%d;" % tid)
for name in CREATED_ORG_NAMES:
R.sql("delete from sys_org where org_name = '%s';" % name)
R.sql("update sys_tenant set contact_name='张校长' where id=1;")
# 用量夹具(本脚本往 E2E 租户里插的那一行名册)
R.sql("delete from collect_roster where id_card_hash like '%s%%';" % USAGE_FIXTURE_HASH_PREFIX)
# 本脚本建的 E2E 租户:连带它的用户 / 角色 / 组织一起删。
# ⚠️ 日志行要单独删 —— 它们的 `tenant_id` 是 **0**(平台身份),
# 用 tenant_id 清是清不掉的,得按 target 清。
for tid in CREATED_TENANT_IDS:
R.sql("delete from sys_operation_log where target_type='TENANT' and target_id=%d;" % tid)
for table in _tables_with_tenant_id():
R.sql("delete from %s where tenant_id=%d;" % (table, tid))
R.sql("delete from sys_tenant where id=%d;" % tid)
del CREATED_TENANT_IDS[:]
# 联系人 / 联系电话复原:本条用例改过它们(手机号改完是**密文**,
# 所以复原成改动前那个明文值 —— 顺带保留"库里有一行历史明文"这个现状,
# 那正是详情接口的容错分支要处理的输入)
R.sql("update sys_tenant set contact_name='张校长', contact_phone='13800138000' where id=1;")
R.sql("delete from collect_message where user_id in (997201, 997202, 997299);")
for p in (TMP_ROSTER, TMP_ORG, TINY_JPEG):
if os.path.exists(p):
os.remove(p)
print(" 已清理:任务 / 材料文件 / 测试组织 / 租户联系人复原 / 临时文件")
print(" 已清理:任务 / 材料文件 / 测试组织 / E2E 租户与用量夹具 / "
"租户联系人复原 / 临时文件")
if __name__ == "__main__":