Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31e7483464 | |||
| 624a05aa66 | |||
| b516a7c47b | |||
| 9f6d26c650 |
@@ -101,7 +101,7 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
|
|||||||
python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑
|
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`),
|
`verify-all --with-e2e` 的输出为准;2026-10 需求收敛删掉了 `e2e-m2-join.py` 与 `e2e-m7-cleanup.py`),
|
||||||
或一条命令 `python scripts/verify-all.py --with-e2e`
|
或一条命令 `python scripts/verify-all.py --with-e2e`
|
||||||
- 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` +
|
- 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` +
|
||||||
@@ -122,7 +122,10 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑
|
|||||||
**模板里的 `<n-*>` 组件有没有漏 import**(第 ⑧ 项 —— 漏了会被当未知元素渲染,样式整块丢、
|
**模板里的 `<n-*>` 组件有没有漏 import**(第 ⑧ 项 —— 漏了会被当未知元素渲染,样式整块丢、
|
||||||
编译与 lint 都不红)、
|
编译与 lint 都不红)、
|
||||||
**`.svg` 资产是不是合法 XML**(第 ⑨ 项 —— SVG 注释里出现连续两个减号就 not well-formed,
|
**`.svg` 资产是不是合法 XML**(第 ⑨ 项 —— SVG 注释里出现连续两个减号就 not well-formed,
|
||||||
图标变裂图而控制台一句话都不打;2026-10 换品牌标时连踩两次))。
|
图标变裂图而控制台一句话都不打;2026-10 换品牌标时连踩两次)、
|
||||||
|
**后端会写的日志动作码 ↔ PC 端 `OPERATION_ACTIONS` 的文案**(第 ⑩ 项 —— 缺一个文案,
|
||||||
|
日志页会**原样回显英文码**;做这一项时才发现 `AUTO_CLOSE` 是字符串字面量写的,
|
||||||
|
所以"提成常量"是让这个检查真正生效的前提)。
|
||||||
⚠️ 这一族错**编译 / lint / review 都不会红**(类在、表在、常量在、注释还写得很清楚),
|
⚠️ 这一族错**编译 / lint / review 都不会红**(类在、表在、常量在、注释还写得很清楚),
|
||||||
只有"从入口走一遍"才暴露 —— 所以把它固化成命令;有意不接线的写在脚本上方的例外表里(带理由)。
|
只有"从入口走一遍"才暴露 —— 所以把它固化成命令;有意不接线的写在脚本上方的例外表里(带理由)。
|
||||||
⚠️ **检查器自己也会失效**:PC 端重搭后权限码的写法从 `permissions.includes` / `v-hasPerm`
|
⚠️ **检查器自己也会失效**:PC 端重搭后权限码的写法从 `permissions.includes` / `v-hasPerm`
|
||||||
|
|||||||
+2
-3
@@ -147,9 +147,8 @@ public class LogController {
|
|||||||
@Operation(summary = "登录日志",
|
@Operation(summary = "登录日志",
|
||||||
description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。"
|
description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。"
|
||||||
+ "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。"
|
+ "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。"
|
||||||
+ "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— "
|
+ "登录方式 `loginType`:`PASSWORD`(PC 账号密码)/ `WECHAT`(学生从小程序、H5 走微信授权)。"
|
||||||
+ "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。"
|
+ "⚠️ `location`(IP 归属地)一期**恒为 null** —— 没接 IP 库,返回 null 而不是编一个")
|
||||||
+ "所以「学生登录记录为空」是已知情况,不是查询条件写错了")
|
|
||||||
public ApiResult<PageResult<LogDtos.LoginLogVO>> logins(
|
public ApiResult<PageResult<LogDtos.LoginLogVO>> logins(
|
||||||
@RequestParam(required = false) Long tenantId,
|
@RequestParam(required = false) Long tenantId,
|
||||||
@RequestParam(required = false) Long userId,
|
@RequestParam(required = false) Long userId,
|
||||||
|
|||||||
@@ -49,6 +49,25 @@ public final class LogDtos {
|
|||||||
|
|
||||||
private String userAgent;
|
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 Integer bizCode;
|
||||||
|
|
||||||
private String traceId;
|
private String traceId;
|
||||||
@@ -74,11 +93,28 @@ public final class LogDtos {
|
|||||||
|
|
||||||
private Long id;
|
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 Long userId;
|
||||||
|
|
||||||
private String userName;
|
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;
|
private Integer userType;
|
||||||
|
|
||||||
/** {@code PASSWORD} / {@code WECHAT} / {@code PHONE} */
|
/** {@code PASSWORD} / {@code WECHAT} / {@code PHONE} */
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
|
|||||||
@InterceptorIgnore(tenantLine = "true")
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<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,
|
COALESCE(u.real_name, l.username) AS userName,
|
||||||
u.user_type AS userType,
|
u.user_type AS userType,
|
||||||
CASE l.login_type
|
CASE l.login_type
|
||||||
|
|||||||
+2
@@ -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.module, l.action, l.target_type AS targetType, l.target_id AS targetId,
|
||||||
l.before_val AS beforeVal, l.after_val AS afterVal,
|
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.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
|
l.create_time AS createTime
|
||||||
FROM sys_operation_log l
|
FROM sys_operation_log l
|
||||||
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
|
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
|
||||||
|
|||||||
+36
-1
@@ -67,8 +67,16 @@ public class OperationLogService {
|
|||||||
public static final String MODULE_JOIN = "JOIN";
|
public static final String MODULE_JOIN = "JOIN";
|
||||||
public static final String MODULE_USER = "USER";
|
public static final String MODULE_USER = "USER";
|
||||||
public static final String MODULE_FILE = "FILE";
|
public static final String MODULE_FILE = "FILE";
|
||||||
/** 角色与授权(二期)。改授权等于改"谁能做什么",是这套系统里最敏感的一类动作。 */
|
/** 角色与授权。改授权等于改"谁能做什么",是这套系统里最敏感的一类动作。 */
|
||||||
public static final String MODULE_ROLE = "ROLE";
|
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_IDCARD = "UPDATE_IDCARD";
|
||||||
public static final String ACTION_UPDATE_ROSTER_NAME = "UPDATE_ROSTER_NAME";
|
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_RESUME_TASK = "RESUME_TASK";
|
||||||
/** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */
|
/** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */
|
||||||
public static final String ACTION_CLOSE_TASK = "CLOSE_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";
|
public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_TASK";
|
||||||
/** 删除已归档任务(2026-09-30 客户确认,§17.2 D-9):级联软删 + 尽力删文件,必须留痕。 */
|
/** 删除已归档任务(2026-09-30 客户确认,§17.2 D-9):级联软删 + 尽力删文件,必须留痕。 */
|
||||||
public static final String ACTION_DELETE_TASK = "DELETE_TASK";
|
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_UPDATE_ROLE_MENU = "UPDATE_ROLE_MENU";
|
||||||
/** 停用 / 启用角色。 */
|
/** 停用 / 启用角色。 */
|
||||||
public static final String ACTION_TOGGLE_ROLE = "TOGGLE_ROLE";
|
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_ROSTER = "ROSTER";
|
||||||
public static final String TARGET_TASK = "TASK";
|
public static final String TARGET_TASK = "TASK";
|
||||||
@@ -110,6 +143,8 @@ public class OperationLogService {
|
|||||||
public static final String TARGET_USER = "USER";
|
public static final String TARGET_USER = "USER";
|
||||||
/** 角色({@code sys_role.id})。 */
|
/** 角色({@code sys_role.id})。 */
|
||||||
public static final String TARGET_ROLE = "ROLE";
|
public static final String TARGET_ROLE = "ROLE";
|
||||||
|
/** 租户({@code sys_tenant.id})。平台级动作的 target,见 {@link #MODULE_TENANT}。 */
|
||||||
|
public static final String TARGET_TENANT = "TENANT";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 记一条操作日志(**不抛异常**)。
|
* 记一条操作日志(**不抛异常**)。
|
||||||
|
|||||||
+5
-2
@@ -688,7 +688,8 @@ public class TaskService {
|
|||||||
* **"系统到点关的"与"管理员手动关的"** —— 出了争议时这是第一个要看的信息。
|
* **"系统到点关的"与"管理员手动关的"** —— 出了争议时这是第一个要看的信息。
|
||||||
*/
|
*/
|
||||||
public void autoClose(Long taskId) {
|
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) {
|
if (from == status) {
|
||||||
return;
|
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,
|
operationLog.record(cn.tianbaotong.fill.module.log.service.OperationLogService.MODULE_TASK,
|
||||||
act, cn.tianbaotong.fill.module.log.service.OperationLogService.TARGET_TASK, id,
|
act, cn.tianbaotong.fill.module.log.service.OperationLogService.TARGET_TASK, id,
|
||||||
String.valueOf(from), String.valueOf(status));
|
String.valueOf(from), String.valueOf(status));
|
||||||
|
|||||||
+66
-4
@@ -4,6 +4,7 @@ import cn.tianbaotong.fill.common.api.ErrorCode;
|
|||||||
import cn.tianbaotong.fill.common.api.PageResult;
|
import cn.tianbaotong.fill.common.api.PageResult;
|
||||||
import cn.tianbaotong.fill.common.exception.BizException;
|
import cn.tianbaotong.fill.common.exception.BizException;
|
||||||
import cn.tianbaotong.fill.common.util.MaskUtil;
|
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.TenantCreateReq;
|
||||||
import cn.tianbaotong.fill.module.tenant.dto.TenantVO;
|
import cn.tianbaotong.fill.module.tenant.dto.TenantVO;
|
||||||
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
|
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
|
||||||
@@ -48,6 +49,7 @@ public class TenantService {
|
|||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final RoleProvisioner roleProvisioner;
|
private final RoleProvisioner roleProvisioner;
|
||||||
private final SensitiveCodec codec;
|
private final SensitiveCodec codec;
|
||||||
|
private final OperationLogService operationLog;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
// ── 列表 ──────────────────────────────────────
|
// ── 列表 ──────────────────────────────────────
|
||||||
@@ -65,7 +67,10 @@ public class TenantService {
|
|||||||
.orderByDesc(SysTenant::getId);
|
.orderByDesc(SysTenant::getId);
|
||||||
|
|
||||||
if (expireBefore != null && !expireBefore.isBlank()) {
|
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);
|
IPage<SysTenant> page = tenantMapper.selectPage(new Page<>(num, size), query);
|
||||||
@@ -120,19 +125,76 @@ public class TenantService {
|
|||||||
|
|
||||||
log.info("已开通租户 {}(id={}),管理员账号 {}", code, tid, req.getAdminUsername());
|
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);
|
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) {
|
public TenantVO detail(Long id) {
|
||||||
SysTenant t = tenantMapper.selectById(id);
|
SysTenant t = tenantMapper.selectById(id);
|
||||||
if (t == null) {
|
if (t == null) {
|
||||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "租户不存在");
|
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) {
|
private static LocalDateTime parseExpire(String s) {
|
||||||
try {
|
try {
|
||||||
// 允许 "2027-12-31" 或 "2027-12-31T23:59:59"
|
// 允许 "2027-12-31" 或 "2027-12-31T23:59:59"
|
||||||
@@ -151,8 +213,8 @@ public class TenantService {
|
|||||||
vo.setTenantName(t.getTenantName());
|
vo.setTenantName(t.getTenantName());
|
||||||
vo.setTenantType(t.getTenantType());
|
vo.setTenantType(t.getTenantType());
|
||||||
vo.setContactName(t.getContactName());
|
vo.setContactName(t.getContactName());
|
||||||
// 联系电话以密文入库,列表页要求脱敏展示时的处理在导出/详情里做,
|
// 联系电话以密文入库:**列表恒不返回**(脱敏值也不行),
|
||||||
// 这里不返回明文,避免列表接口成为批量拉取联系方式的通道
|
// 避免列表接口成为批量拉取联系方式的通道。脱敏值只有 detail() 给。
|
||||||
vo.setContactPhone(null);
|
vo.setContactPhone(null);
|
||||||
vo.setPackageId(t.getPackageId());
|
vo.setPackageId(t.getPackageId());
|
||||||
vo.setExpireTime(t.getExpireTime());
|
vo.setExpireTime(t.getExpireTime());
|
||||||
|
|||||||
+100
-15
@@ -4,10 +4,13 @@ import cn.tianbaotong.fill.common.api.ErrorCode;
|
|||||||
import cn.tianbaotong.fill.common.exception.BizException;
|
import cn.tianbaotong.fill.common.exception.BizException;
|
||||||
import cn.tianbaotong.fill.module.file.entity.CollectFile;
|
import cn.tianbaotong.fill.module.file.entity.CollectFile;
|
||||||
import cn.tianbaotong.fill.module.file.mapper.FileMapper;
|
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.mapper.CollectRosterMapper;
|
||||||
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
|
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
|
||||||
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
|
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
|
||||||
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
|
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 com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -18,6 +21,7 @@ import java.time.LocalDateTime;
|
|||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 租户用量统计(《03-接口设计》§6.7)。
|
* 租户用量统计(《03-接口设计》§6.7)。
|
||||||
@@ -45,6 +49,8 @@ public class TenantUsageService {
|
|||||||
private final TenantMapper tenantMapper;
|
private final TenantMapper tenantMapper;
|
||||||
private final CollectRosterMapper rosterMapper;
|
private final CollectRosterMapper rosterMapper;
|
||||||
private final FileMapper fileMapper;
|
private final FileMapper fileMapper;
|
||||||
|
private final SensitiveCodec codec;
|
||||||
|
private final OperationLogService operationLog;
|
||||||
|
|
||||||
/** 默认套餐额度(没有配套餐时的兜底)。 */
|
/** 默认套餐额度(没有配套餐时的兜底)。 */
|
||||||
private static final long DEFAULT_STUDENT_LIMIT = 2000L;
|
private static final long DEFAULT_STUDENT_LIMIT = 2000L;
|
||||||
@@ -64,15 +70,22 @@ public class TenantUsageService {
|
|||||||
vo.setTenantName(t.getTenantName());
|
vo.setTenantName(t.getTenantName());
|
||||||
vo.setPeriod(p.text());
|
vo.setPeriod(p.text());
|
||||||
|
|
||||||
// 学生数:**按证件号去重**(同一个人可能在多个任务里各有一行名册)
|
// ⚠️ 这两项必须**切到目标租户的上下文里**再查,不能直接用当前请求的身份查。
|
||||||
vo.setStudentCount(distinctStudents());
|
//
|
||||||
|
// 原因:本接口只有平台运营能调(@RequirePerm(TENANT_VIEW),那个码在
|
||||||
// 存储:材料规范件的体积合计(原始件默认不保留,所以它才是真实占用)
|
// PermissionDef.platformOnlyCodes() 里),而平台运营的 tenantId 是 0 ——
|
||||||
Long bytes = fileMapper.selectList(Wrappers.<CollectFile>lambdaQuery()
|
// FillTenantLineHandler.ignoreTable() 对平台身份直接返回 true,
|
||||||
.select(CollectFile::getFinalSize))
|
// **整张表的租户过滤被关掉**。于是"学生数 / 存储量"会算成**全平台**的数,
|
||||||
.stream().map(CollectFile::getFinalSize)
|
// 却挂在某一所学校的名下:运营拿着它去谈续费,而数字是假的。
|
||||||
.filter(java.util.Objects::nonNull).mapToLong(Long::longValue).sum();
|
//
|
||||||
vo.setStorageMb(bytes / 1024 / 1024);
|
// 这个 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(见类注释)
|
// OCR / 短信:一期没有通道,如实返回 0(见类注释)
|
||||||
vo.setOcrCount(0);
|
vo.setOcrCount(0);
|
||||||
@@ -94,6 +107,19 @@ public class TenantUsageService {
|
|||||||
return vo;
|
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` 去重(同一个人在多任务里只算一次)。 */
|
/** 学生数:按 `id_card_hash` 去重(同一个人在多任务里只算一次)。 */
|
||||||
private long distinctStudents() {
|
private long distinctStudents() {
|
||||||
// 单独写一句 SQL 更省事,但这里刻意用 MyBatis-Plus 的写法:
|
// 单独写一句 SQL 更省事,但这里刻意用 MyBatis-Plus 的写法:
|
||||||
@@ -152,6 +178,7 @@ public class TenantUsageService {
|
|||||||
if (req == null) {
|
if (req == null) {
|
||||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请求体为空");
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请求体为空");
|
||||||
}
|
}
|
||||||
|
Integer oldStatus = exist.getStatus();
|
||||||
SysTenant upd = new SysTenant();
|
SysTenant upd = new SysTenant();
|
||||||
upd.setId(id);
|
upd.setId(id);
|
||||||
if (req.getTenantName() != null) {
|
if (req.getTenantName() != null) {
|
||||||
@@ -161,21 +188,34 @@ public class TenantUsageService {
|
|||||||
upd.setTenantName(req.getTenantName().trim());
|
upd.setTenantName(req.getTenantName().trim());
|
||||||
}
|
}
|
||||||
if (req.getTenantType() != null) {
|
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,
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||||
"租户类型只能是 1(学校)/ 2(机构)/ 3(企业)");
|
"租户类型只能是 1(学校)/ 2(认定机构)/ 3(报名点)/ 4(企业)");
|
||||||
}
|
}
|
||||||
upd.setTenantType(req.getTenantType());
|
upd.setTenantType(req.getTenantType());
|
||||||
}
|
}
|
||||||
upd.setContactName(req.getContactName());
|
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.setPackageId(req.getPackageId());
|
||||||
upd.setExpireTime(req.getExpireTime());
|
upd.setExpireTime(req.getExpireTime());
|
||||||
upd.setLogoUrl(req.getLogoUrl());
|
upd.setLogoUrl(req.getLogoUrl());
|
||||||
upd.setConfigJson(req.getConfigJson());
|
upd.setConfigJson(req.getConfigJson());
|
||||||
if (req.getStatus() != null) {
|
if (req.getStatus() != null) {
|
||||||
if (req.getStatus() != 0 && req.getStatus() != 1) {
|
// 取值以 SysTenant.status 的注释为准(0 停用 / 1 正常 / 2 试用)。
|
||||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "状态只能是 0(停用)或 1(启用)");
|
// ⚠️ 这里曾经只放行 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());
|
upd.setStatus(req.getStatus());
|
||||||
if (req.getStatus() == 0) {
|
if (req.getStatus() == 0) {
|
||||||
@@ -187,7 +227,52 @@ public class TenantUsageService {
|
|||||||
tenantMapper.updateById(upd);
|
tenantMapper.updateById(upd);
|
||||||
log.info("修改租户 id={} 名称={} 状态={} 到期={}", id, upd.getTenantName(),
|
log.info("修改租户 id={} 名称={} 状态={} 到期={}", id, upd.getTenantName(),
|
||||||
upd.getStatus(), upd.getExpireTime());
|
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
@@ -1187,37 +1187,109 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
|
|
||||||
**权限**:运营 **Query**:`keyword`、`status`、`expireBefore`、分页
|
**权限**:运营 **Query**:`keyword`、`status`、`expireBefore`、分页
|
||||||
|
|
||||||
|
`expireBefore`(`yyyy-MM-dd`)**含当天** —— `expire_time` 存的是当天 `23:59:59`,
|
||||||
|
所以比较要补到当天末尾,否则"今天到期"的租户查不出来。格式不对返回 `40001`
|
||||||
|
(**不静默忽略筛选**:这个值只由界面按当前日期算出来,非法值只可能来自手搓请求)。
|
||||||
|
|
||||||
**响应**:`{ list: TenantVO[], total }`
|
**响应**:`{ list: TenantVO[], total }`
|
||||||
|
|
||||||
|
⚠️ 列表里的 `contactPhone` **恒为 `null`** —— 联系电话以密文入库,列表接口不能成为
|
||||||
|
批量拉取联系方式的通道。脱敏值只有 6.9 给。
|
||||||
|
`packageName` 与内嵌 `usage{}`(§3.12 的简表里画了)**不实现**:200 行内嵌用量会变成 N+1 统计。
|
||||||
|
|
||||||
### 6.6 `POST /api/v1/tenants`
|
### 6.6 `POST /api/v1/tenants`
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `tenantName` | string | ✅ | — |
|
| `tenantName` | string | ✅ | — |
|
||||||
| `tenantCode` | string | ✅ | 全局唯一,如 `jxjd` |
|
| `tenantCode` | string | ✅ | 全局唯一,如 `jxjd`;**入库时小写化** |
|
||||||
| `tenantType` | int | ✅ | `1` 学校 / `2` 机构 / `3` 企业 |
|
| `tenantType` | int | ✅ | `1` 学校 / `2` 认定机构 / `3` 报名点 / `4` 企业 |
|
||||||
| `contactName` / `contactPhone` | string | ✅ | 联系人 |
|
| `contactName` / `contactPhone` | string | ✅ | 联系人;电话**加密入库** |
|
||||||
| `packageId` | int64 | ✅ | 套餐 |
|
| `packageId` | int64 | | 套餐(可选,无外键) |
|
||||||
| `expireTime` | string | ✅ | 到期时间 |
|
| `expireTime` | string | ✅ | 到期时间;只给日期时补成当天 `23:59:59` |
|
||||||
| `adminUsername` / `adminRealName` / `adminPhone` | string | ✅ | **同时创建首个管理员** |
|
| `adminUsername` / `adminRealName` / `adminPhone` | string | ✅ | **同时创建首个管理员** |
|
||||||
|
|
||||||
**响应**:`{ "id": 1002, "adminInitialPassword": "Kp9#vR4t", "miniProgramScene": "t1002" }`
|
**响应**:`{ "id": 1002, "adminInitialPassword": "Kp9#vR4t", "miniProgramScene": "t1002" }`
|
||||||
|
|
||||||
|
**副作用**:切到新租户的作用域发放 6 个内置角色、建管理员并绑定 `SCHOOL_ADMIN`;
|
||||||
|
写一条操作日志(动作 `CREATE_TENANT`,`target_type=TENANT`)。
|
||||||
|
|
||||||
### 6.7 `GET /api/v1/tenants/{id}/usage`
|
### 6.7 `GET /api/v1/tenants/{id}/usage`
|
||||||
|
|
||||||
**Query**:`period`(`2026-09` 或 `2026Q3`)
|
**Query**:`period`(`2026-09` 或 `2026Q3`;不传 = 当月)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"data": {
|
"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 },
|
"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 任务与配置
|
## §7 任务与配置
|
||||||
@@ -2477,7 +2549,13 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
|
|
||||||
#### `GET /api/v1/logs/operations`
|
#### `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})`):
|
**范围**(权限二选一,`@RequirePerm({log:viewTenant, log:viewAll})`):
|
||||||
|
|
||||||
@@ -2496,28 +2574,55 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
{ "id": 70001, "operatorId": 10086, "operatorName": "王老师", "module": "ROSTER",
|
{ "id": 70001, "operatorId": 10086, "operatorName": "王老师", "module": "ROSTER",
|
||||||
"action": "UPDATE_IDCARD", "targetType": "ROSTER", "targetId": 9012,
|
"action": "UPDATE_IDCARD", "targetType": "ROSTER", "targetId": 9012,
|
||||||
"beforeVal": "3610292009******10", "afterVal": "3610292009******99",
|
"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
|
"total": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**必记的敏感操作**(PRD FR-506 / FR-1202)
|
**动作码全集**(`action`,权威来源是后端 `OperationLogService` 的 `ACTION_*` 常量,
|
||||||
|
PC 端 `apis/xt/log.ts` 的 `OPERATION_ACTIONS` 是它的文案表 —— 两端由
|
||||||
|
`scripts/check-wiring.py` 第 ⑩ 项**逐字核对**,缺一个界面上就会原样回显英文码)
|
||||||
|
|
||||||
| `action` | 场景 |
|
| `action` | 场景 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `UPDATE_IDCARD` | **改名册证件号**(留痕强制) |
|
| `UPDATE_IDCARD` | **改名册证件号**(留痕强制) |
|
||||||
| `UPDATE_ROSTER_NAME` | 改名 |
|
| `UPDATE_ROSTER_NAME` | 改名 |
|
||||||
| `DELETE_ROSTER` | 删名册项 |
|
| `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` | **撤回审核** |
|
| `REVOKE_AUDIT` | **撤回审核** |
|
||||||
| `EXPORT_CREATE` | 创建导出 |
|
| `EXPORT_CREATE` / `EXPORT_DOWNLOAD` | 创建导出 / 下载导出文件 |
|
||||||
| `PROXY_UPLOAD` | 代传材料 |
|
| `PROXY_UPLOAD` | 代传材料 |
|
||||||
| `VIEW_IDCARD_FULL` | **查看完整身份证号**(单独留痕) |
|
| `VIEW_IDCARD_FULL` | **查看完整身份证号**(单独留痕) |
|
||||||
| `CONFIG_COMPRESS` | 改压缩配置 |
|
| `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` 中的证件号**一律脱敏存储**,即使日志泄露也不暴露明文。
|
**脱敏**:`beforeVal` / `afterVal` 中的证件号**一律脱敏存储**,即使日志泄露也不暴露明文。
|
||||||
|
服务层还会再兜底脱敏一次并截断到 512 字符(两层都做,是因为"忘记脱敏"的后果不可逆)。
|
||||||
|
|
||||||
|
**两列刻意不采集**:`request_param`(请求参数)与 `duration_ms`(耗时)**没有任何写入点**
|
||||||
|
(全库 0 行有值)。要采集得引入 Servlet 过滤器或 AOP 抓请求体并脱敏,
|
||||||
|
而合规留痕本来就不需要它们 —— 界面上不展示、也不做筛选。
|
||||||
|
|
||||||
|
> **一期只做查询,不做日志导出**(`backend/.../module/log/controller/LogController.java` 的类注释):
|
||||||
|
> 导出等于把「谁在什么时候看了谁的身份证」这份名单落到本地磁盘上,
|
||||||
|
> 而"有人导出过这份日志"本身也要留痕 —— 一个追着自己尾巴的循环。
|
||||||
|
> 这条口径**只写在代码注释里**过一段时间,所以在这里也记一笔。
|
||||||
|
|
||||||
#### `GET /api/v1/logs/logins`
|
#### `GET /api/v1/logs/logins`
|
||||||
|
|
||||||
@@ -2525,7 +2630,17 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
|
|
||||||
**范围**:与操作日志同一口径(`log:viewAll` 可跨租户,学校管理员恒为本租户)。
|
**范围**:与操作日志同一口径(`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` | 含义 |
|
| `loginType` | 含义 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
+14
-1
@@ -17,7 +17,7 @@
|
|||||||
> | PC 业务菜单 | 12 | **10** |
|
> | PC 业务菜单 | 12 | **10** |
|
||||||
> | 小程序页面 | 10 | **9** |
|
> | 小程序页面 | 10 | **9** |
|
||||||
> | 原型屏 | 32 | **29** |
|
> | 原型屏 | 32 | **29** |
|
||||||
> | E2E 脚本 | 39 | **37**(断言 1698 → **1595 条**,0 失败 0 跳过) |
|
> | E2E 脚本 | 39 | **37**(断言 1698 → **1617 条**,0 失败 0 跳过) |
|
||||||
>
|
>
|
||||||
> **删除的三块**:① 内置角色「副班主任 `DEPUTY_TEACHER`」「审核员 `AUDITOR`」;
|
> **删除的三块**:① 内置角色「副班主任 `DEPUTY_TEACHER`」「审核员 `AUDITOR`」;
|
||||||
> ② **历史整理器**(`module/cleanup`,5 接口 + `cleanup:run` + `collect_cleanup_job` 表 + PC 页 + 原型屏);
|
> ② **历史整理器**(`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-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-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-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
@@ -8,17 +8,21 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## §1 三种角色,三个入口
|
## §1 四种角色,四个入口
|
||||||
|
|
||||||
| 角色 | 入口 | 设备 | 主要做什么 |
|
| 角色 | 入口 | 设备 | 主要做什么 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **学生** | 扫学校/班主任发的**二维码** | 手机(微信小程序 / H5) | 核对身份 → 填表 → 上传材料 → 提交 |
|
| **学生** | 扫学校/班主任发的**二维码** | 手机(微信小程序 / H5) | 核对身份 → 填表 → 上传材料 → 提交 |
|
||||||
| **班主任** | 微信小程序(同一套码,登录后进教师端) | 手机 | 看本班进度、审本班材料、代传、催报 |
|
| **班主任** | 微信小程序(同一套码,登录后进教师端) | 手机 | 看本班进度、审本班材料、代传、催报 |
|
||||||
| **学校管理员** | 电脑浏览器打开管理端 | PC | 建任务、配表单与材料、导名册、终审、导出交付包 |
|
| **学校管理员** | 电脑浏览器打开管理端 | PC | 建任务、配表单与材料、导名册、终审、导出交付包 |
|
||||||
|
| **平台运营** | 电脑浏览器打开管理端(账号**不用填单位编码**) | PC | 开通学校、改到期与联系人、停用学校、查全平台日志 |
|
||||||
|
|
||||||
**⚠️ 学生与老师的小程序是同一个**,靠账号角色区分。学生第一次要"核验身份"(填姓名+证件号),
|
**⚠️ 学生与老师的小程序是同一个**,靠账号角色区分。学生第一次要"核验身份"(填姓名+证件号),
|
||||||
这一步就是**门禁**:名册里没有的人进不去(这是产品铁律,见 §5.1)。
|
这一步就是**门禁**:名册里没有的人进不去(这是产品铁律,见 §5.1)。
|
||||||
|
|
||||||
|
**⚠️ 平台运营是唯一跨单位的角色**:它看的是**所有学校**的数据,所以在"组织与账号""收集管理"
|
||||||
|
这类本来就是单位内的页面上,它必须先选"**当前学校**"(顶栏那个切换),选之前看到的是混在一起的。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## §2 学生操作手册
|
## §2 学生操作手册
|
||||||
@@ -199,7 +203,9 @@
|
|||||||
| 改学校/院系/班级结构 | 组织与账号 → 组织架构(也支持 Excel 导入) |
|
| 改学校/院系/班级结构 | 组织与账号 → 组织架构(也支持 Excel 导入) |
|
||||||
| 加一个下拉选项(如多了种学历写法) | 配置 → 字典管理(**内置项不可改名**,要加就追加自定义项) |
|
| 加一个下拉选项(如多了种学历写法) | 配置 → 字典管理(**内置项不可改名**,要加就追加自定义项) |
|
||||||
| 给某类人配一套能力范围 | 组织与账号 → 角色与授权(见 §4.5) |
|
| 给某类人配一套能力范围 | 组织与账号 → 角色与授权(见 §4.5) |
|
||||||
| 查"谁在什么时候看了证件号/导出了什么" | 平台运维 → 操作日志 |
|
| 查"谁在什么时候看了证件号/导出了什么" | 平台运维 → 日志(「操作日志」页签,点某一行看详情) |
|
||||||
|
| 查"某个账号什么时候登进来的、从哪登的" | 平台运维 → 日志 → 「登录日志」页签 |
|
||||||
|
| 开通一所新学校 / 改到期日 / 停用学校 | 平台运维 → 租户管理(**仅平台运营可见**;停用前会二次确认) |
|
||||||
| 查"通知到底发出去没有" | 收集管理 → 通知记录 |
|
| 查"通知到底发出去没有" | 收集管理 → 通知记录 |
|
||||||
| 看某个任务的分班进度 | 工作台 → 数据看板(选一个任务) |
|
| 看某个任务的分班进度 | 工作台 → 数据看板(选一个任务) |
|
||||||
|
|
||||||
@@ -261,7 +267,8 @@ A:系统上限是 3 次(防反复折腾)。到上限后由班主任/管理
|
|||||||
3. **屏幕上的完整提示文字**(照抄,不要转述 —— 提示文案与错误码是一一对应的);
|
3. **屏幕上的完整提示文字**(照抄,不要转述 —— 提示文案与错误码是一一对应的);
|
||||||
4. **发生时间**(精确到分钟)。
|
4. **发生时间**(精确到分钟)。
|
||||||
|
|
||||||
有了这四样,技术同学可以直接去「操作日志」与「通知记录」里定位。
|
有了这四样,技术同学可以直接去「日志」(平台运维菜单,含操作日志与登录日志两个页签)
|
||||||
|
与「通知记录」里定位 —— 日志详情里能拿到 `traceId`,那是后端排查时最有用的一个线索。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+145
-2
@@ -2847,7 +2847,7 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
|||||||
| 手段 | 结果 |
|
| 手段 | 结果 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `scripts/e2e-m10-role.py`(新增) | **77/77**:改授权后**同一个 token** 立刻生效 / 停用立刻失效 / 内置角色只读 / 越权与自锁 / 删除守卫 `40045` / 内置角色权限以代码为准 + 同步回填幂等 / 跨租户 |
|
| `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` 逐字核对)→ 删除(软删 + 授权/菜单行一并软删);截图留证 |
|
| 浏览器实测(真点) | 新建自定义角色 → 账号页角色候选出现它(且平台角色已不在候选里)→ 改权限落库(`sys_role_permission` 逐字核对)→ 删除(软删 + 授权/菜单行一并软删);截图留证 |
|
||||||
| 机械校验 | `check-api-parity`(契约 **119** / 已实现 118 / 缺口 1)、`verify-docs` 22/22(原型 **29** 屏)、`check-wiring` 未接线 0、`check-entity-columns` 28 实体一致、`check-manual` 0 错 |
|
| 机械校验 | `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` 与改动过的账号页) |
|
| 前端 | `pnpm typecheck` 0 错、`eslint` 0 错(`views/role/**`、`apis/xt/role.ts` 与改动过的账号页) |
|
||||||
@@ -2892,7 +2892,7 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
|||||||
| PC 业务菜单 | 12 | **10** |
|
| PC 业务菜单 | 12 | **10** |
|
||||||
| 小程序页面 | 10 | **9** |
|
| 小程序页面 | 10 | **9** |
|
||||||
| 原型屏 | 32 | **29**(学生 10 / 班主任 9 / 管理端 10) |
|
| 原型屏 | 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),
|
> ① **契约是 119,不是 116**。116 是"删 13 个**代码端点**"(cleanup 8 + join 5),
|
||||||
@@ -3183,4 +3183,147 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
|||||||
根 `AGENTS.md` 的接线检查说明补上第 ⑨ 项。
|
根 `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` 的接线检查说明补第 ⑩ 项。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
├── code-ts/SKILL.md # TS 命名 / 导入 / 显式 import
|
├── code-ts/SKILL.md # TS 命名 / 导入 / 显式 import
|
||||||
├── code-vue/SKILL.md # SFC 写法与模板约定
|
├── code-vue/SKILL.md # SFC 写法与模板约定
|
||||||
├── code-css/SKILL.md # SCSS 与样式边界
|
├── code-css/SKILL.md # SCSS 与样式边界
|
||||||
├── project-guide/SKILL.md # 技术栈 / 目录 / 七条最容易写错的约定
|
├── project-guide/SKILL.md # 技术栈 / 目录 / 九条最容易写错的约定
|
||||||
├── project-crud/SKILL.md # 列表 + 表单的标准写法
|
├── project-crud/SKILL.md # 列表 + 表单的标准写法
|
||||||
├── project-api/SKILL.md # 接口调用(没有 Mock)
|
├── project-api/SKILL.md # 接口调用(没有 Mock)
|
||||||
└── git-commit/SKILL.md # 提交规范(与框架无关)
|
└── git-commit/SKILL.md # 提交规范(与框架无关)
|
||||||
|
|||||||
@@ -63,6 +63,29 @@ naive-ui 2.45.3 里,`tab="文字"` **属性**写法渲染不出任何文字(
|
|||||||
原因:属性值走 `normalizeVNode(() => render(mergedTab ?? name))`,而这个 thunk 返回
|
原因:属性值走 `normalizeVNode(() => render(mergedTab ?? name))`,而这个 thunk 返回
|
||||||
Text VNode,被当成块 `createBlock(vnode)` 处理;插槽那条分支返回数组,所以正常。
|
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
|
```ts
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: project-guide
|
name: project-guide
|
||||||
description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及七条最容易写错的约定。开始任何 PC 端改动前加载本技能。
|
description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及九条最容易写错的约定。开始任何 PC 端改动前加载本技能。
|
||||||
---
|
---
|
||||||
|
|
||||||
# 项目总览(PC 管理端)
|
# 项目总览(PC 管理端)
|
||||||
@@ -42,7 +42,7 @@ src/
|
|||||||
- 平台运营要**先选学校**(`stores/acting-tenant.ts` → 请求头 `X-Tenant-Id`);
|
- 平台运营要**先选学校**(`stores/acting-tenant.ts` → 请求头 `X-Tenant-Id`);
|
||||||
不选就不发请求,否则会把各租户数据混在一起且看起来正常。
|
不选就不发请求,否则会把各租户数据混在一起且看起来正常。
|
||||||
|
|
||||||
## 七条最容易写错的约定
|
## 九条最容易写错的约定
|
||||||
|
|
||||||
1. **没有 auto-import**:组件与 API 全显式 import,标签写 **PascalCase**。
|
1. **没有 auto-import**:组件与 API 全显式 import,标签写 **PascalCase**。
|
||||||
漏 import 是**静默故障**(未知自定义元素 → 样式整块丢掉,typecheck/lint 全绿);
|
漏 import 是**静默故障**(未知自定义元素 → 样式整块丢掉,typecheck/lint 全绿);
|
||||||
@@ -60,6 +60,13 @@ src/
|
|||||||
**不要换回文字字形标**;页签用 `public/favicon.svg`(加粗版),改一个要同步另一个。
|
**不要换回文字字形标**;页签用 `public/favicon.svg`(加粗版),改一个要同步另一个。
|
||||||
⚠️ SVG 注释里**不能出现连续两个减号**(XML 规范禁止)→ 整个文件 not well-formed,
|
⚠️ SVG 注释里**不能出现连续两个减号**(XML 规范禁止)→ 整个文件 not well-formed,
|
||||||
图片裂开而控制台一句话都不报;`python scripts/check-wiring.py` 第 ⑨ 项会核对。
|
图片裂开而控制台一句话都不报;`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
@@ -43,7 +43,7 @@ pnpm lint # ESLint(@antfu/eslint-config)
|
|||||||
- 接口模块在 `src/apis/xt/`(按业务域分文件),统一从 `@/apis/xt` 或 `@/apis/xt/<域>` 引
|
- 接口模块在 `src/apis/xt/`(按业务域分文件),统一从 `@/apis/xt` 或 `@/apis/xt/<域>` 引
|
||||||
- 演示账号:`admin / Admin@123` + 租户码 `demo`(⚠️ **不是** Gi 模板 README 里的 `admin/123456`)
|
- 演示账号:`admin / Admin@123` + 租户码 `demo`(⚠️ **不是** Gi 模板 README 里的 `admin/123456`)
|
||||||
|
|
||||||
## 七条最容易写错的约定
|
## 九条最容易写错的约定
|
||||||
|
|
||||||
1. **没有 auto-import**:naive-ui 组件、Vue API、工具函数**全部显式 `import`**,
|
1. **没有 auto-import**:naive-ui 组件、Vue API、工具函数**全部显式 `import`**,
|
||||||
标签统一写 **PascalCase**(`<NButton>`)。
|
标签统一写 **PascalCase**(`<NButton>`)。
|
||||||
@@ -70,3 +70,15 @@ pnpm lint # ESLint(@antfu/eslint-config)
|
|||||||
⚠️ **SVG 的注释里不能出现连续两个减号**(XML 规范禁止):会让整个文件 not well-formed,
|
⚠️ **SVG 的注释里不能出现连续两个减号**(XML 规范禁止):会让整个文件 not well-formed,
|
||||||
表现是图片裂开、**控制台一句话都不报**,typecheck/lint 也全绿。
|
表现是图片裂开、**控制台一句话都不报**,typecheck/lint 也全绿。
|
||||||
`python scripts/check-wiring.py` 第 ⑨ 项会核对这件事。
|
`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 页。
|
||||||
|
|||||||
@@ -15,9 +15,11 @@
|
|||||||
*
|
*
|
||||||
* ## ⚠️ 一期的已知边界(界面上要说明,不能让人以为系统坏了)
|
* ## ⚠️ 一期的已知边界(界面上要说明,不能让人以为系统坏了)
|
||||||
*
|
*
|
||||||
* - 登录日志**只有 PC 账号密码登录**;学生从微信进来目前不写这张表;
|
* - `location`(IP 归属地)**恒为 null**:没接 IP 库,宁可留空也不编一个;
|
||||||
* - IP 取的是代理链第一跳,**可以伪造** —— 用于排查,不作合规证据。
|
* - IP 取的是代理链第一跳,**可以伪造** —— 用于排查,不作合规证据。
|
||||||
* 真正的证据是"哪个账号在什么时候做了什么"。
|
* 真正的证据是"哪个账号在什么时候做了什么"。
|
||||||
|
* - `request_param` / `duration_ms` 两列**没有任何写入点**(全库 0 行有值),
|
||||||
|
* 所以界面上不展示、也不做筛选,别为它们写查询。
|
||||||
*/
|
*/
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
|
|
||||||
@@ -35,6 +37,12 @@ export interface OperationLog {
|
|||||||
afterVal?: string
|
afterVal?: string
|
||||||
ip?: string
|
ip?: string
|
||||||
userAgent?: string
|
userAgent?: string
|
||||||
|
/** 请求方法 GET / POST / PUT / DELETE —— 详情里展示 */
|
||||||
|
requestMethod?: string
|
||||||
|
/** 请求路径(不含 query string) */
|
||||||
|
requestUri?: string
|
||||||
|
/** 补充上下文(JSON 串);不是每条都有,可能是合法的 `null` */
|
||||||
|
extraJson?: string
|
||||||
bizCode?: number
|
bizCode?: number
|
||||||
traceId?: string
|
traceId?: string
|
||||||
createTime?: string
|
createTime?: string
|
||||||
@@ -47,6 +55,13 @@ export interface OperationLogPage {
|
|||||||
|
|
||||||
export interface LoginLog {
|
export interface LoginLog {
|
||||||
id: number
|
id: number
|
||||||
|
/**
|
||||||
|
* ⚠️ `0` = **平台**(不是 null)。
|
||||||
|
*
|
||||||
|
* 平台运营看到的是**跨单位**的登录日志,没有它分不清一行是哪所学校/单位的
|
||||||
|
* (《03-接口设计》§13.4 `GET /logs/logins`:`tenantId`(`0` = 平台)**必须有**)。
|
||||||
|
*/
|
||||||
|
tenantId?: number
|
||||||
userId?: number
|
userId?: number
|
||||||
userName?: string
|
userName?: string
|
||||||
userType?: number
|
userType?: number
|
||||||
@@ -66,6 +81,8 @@ export interface LoginLogPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getOperationLogs(params: {
|
export function getOperationLogs(params: {
|
||||||
|
/** ⚠️ 只有 `log:viewAll`(平台运营)能指定别的租户;学校管理员传别人的租户号会 40301 */
|
||||||
|
tenantId?: number
|
||||||
operatorId?: number
|
operatorId?: number
|
||||||
module?: string
|
module?: string
|
||||||
action?: string
|
action?: string
|
||||||
@@ -80,6 +97,7 @@ export function getOperationLogs(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getLoginLogs(params: {
|
export function getLoginLogs(params: {
|
||||||
|
tenantId?: number
|
||||||
userId?: number
|
userId?: number
|
||||||
userType?: number
|
userType?: number
|
||||||
result?: boolean
|
result?: boolean
|
||||||
@@ -91,7 +109,12 @@ export function getLoginLogs(params: {
|
|||||||
return http.get<LoginLogPage>('/logs/logins', params)
|
return http.get<LoginLogPage>('/logs/logins', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 敏感动作 → 文案。**与后端 OperationLogService 的常量一致**。 */
|
/**
|
||||||
|
* 动作码 → 文案。
|
||||||
|
*
|
||||||
|
* ⚠️ **必须与后端 `OperationLogService` 的 `ACTION_*` 常量逐字一致**(`check-wiring.py` 第 ⑩ 项双向核对)。
|
||||||
|
* 缺一个不会报错 —— `actionText` 会原样回显那个码,看起来像"系统没翻译"。
|
||||||
|
*/
|
||||||
export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
|
export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
|
||||||
{ value: 'UPDATE_IDCARD', label: '改名册证件号' },
|
{ value: 'UPDATE_IDCARD', label: '改名册证件号' },
|
||||||
{ value: 'UPDATE_ROSTER_NAME', label: '改名册姓名' },
|
{ value: 'UPDATE_ROSTER_NAME', label: '改名册姓名' },
|
||||||
@@ -100,9 +123,13 @@ export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
|
|||||||
{ value: 'PAUSE_TASK', label: '暂停任务' },
|
{ value: 'PAUSE_TASK', label: '暂停任务' },
|
||||||
{ value: 'RESUME_TASK', label: '恢复任务' },
|
{ value: 'RESUME_TASK', label: '恢复任务' },
|
||||||
{ value: 'CLOSE_TASK', label: '截止任务' },
|
{ value: 'CLOSE_TASK', label: '截止任务' },
|
||||||
|
{ value: 'AUTO_CLOSE', label: '自动截止任务' },
|
||||||
|
{ value: 'STATUS_CHANGE', label: '改任务状态' },
|
||||||
{ value: 'ARCHIVE_TASK', label: '归档任务' },
|
{ value: 'ARCHIVE_TASK', label: '归档任务' },
|
||||||
|
{ value: 'DELETE_TASK', label: '删除已归档任务' },
|
||||||
{ value: 'REVOKE_AUDIT', label: '撤回审核' },
|
{ value: 'REVOKE_AUDIT', label: '撤回审核' },
|
||||||
{ value: 'EXPORT_CREATE', label: '创建导出' },
|
{ value: 'EXPORT_CREATE', label: '创建导出' },
|
||||||
|
{ value: 'EXPORT_DOWNLOAD', label: '下载导出文件' },
|
||||||
{ value: 'PROXY_UPLOAD', label: '代传材料' },
|
{ value: 'PROXY_UPLOAD', label: '代传材料' },
|
||||||
{ value: 'VIEW_IDCARD_FULL', label: '查看完整证件号' },
|
{ value: 'VIEW_IDCARD_FULL', label: '查看完整证件号' },
|
||||||
{ value: 'CONFIG_COMPRESS', label: '改压缩配置' },
|
{ value: 'CONFIG_COMPRESS', label: '改压缩配置' },
|
||||||
@@ -113,18 +140,51 @@ export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
|
|||||||
{ value: 'DELETE_ROLE', label: '删除角色' },
|
{ value: 'DELETE_ROLE', label: '删除角色' },
|
||||||
{ value: 'GRANT_ROLE', label: '改角色授权' },
|
{ value: 'GRANT_ROLE', label: '改角色授权' },
|
||||||
{ value: 'UPDATE_ROLE_MENU', 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) {
|
export function actionText(v?: string) {
|
||||||
return OPERATION_ACTIONS.find((a) => a.value === v)?.label || v || '—'
|
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> = {
|
export const LOGIN_TYPES: Record<string, string> = {
|
||||||
PASSWORD: '账号密码',
|
PASSWORD: '账号密码',
|
||||||
WECHAT: '微信授权',
|
WECHAT: '微信授权',
|
||||||
PHONE: '手机号快捷'
|
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']
|
||||||
|
|||||||
@@ -59,6 +59,66 @@ export function getTenantDetail(id: number) {
|
|||||||
return http.get<TenantVO>(`/tenants/${id}`)
|
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 个内置角色 + 创建首个管理员」。
|
* 开通租户:一次请求完成「建租户 + 发放 6 个内置角色 + 创建首个管理员」。
|
||||||
* 返回的 `adminInitialPassword` 只在这一次响应里,界面必须让用户当场记下。
|
* 返回的 `adminInitialPassword` 只在这一次响应里,界面必须让用户当场记下。
|
||||||
|
|||||||
@@ -142,7 +142,10 @@ export const XT_MENUS: XtMenu[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'log',
|
key: 'log',
|
||||||
title: '操作日志',
|
// ⚠️ `key` 保持 'log' 不动(后端 BuiltinRole 的菜单键与 check-wiring.py 第 ⑥ 项都按它核对,
|
||||||
|
// 改 key 会让平台角色的菜单静默少一项);改的只是**显示名** ——
|
||||||
|
// 这一页现在有「操作日志 / 登录日志」两个页签,叫「操作日志」会名不副实。
|
||||||
|
title: '日志',
|
||||||
icon: 'log',
|
icon: 'log',
|
||||||
path: '/platform/log',
|
path: '/platform/log',
|
||||||
component: 'log/index',
|
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>
|
||||||
+497
-112
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DataTableColumns } from 'naive-ui'
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
import type { OperationLog } from '@/apis/xt/log'
|
import type { LoginLog, OperationLog } from '@/apis/xt/log'
|
||||||
import {
|
import {
|
||||||
NButton,
|
NButton,
|
||||||
NCard,
|
NCard,
|
||||||
@@ -11,54 +11,77 @@ import {
|
|||||||
NTag,
|
NTag,
|
||||||
useMessage
|
useMessage
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
import { computed, h, onMounted, reactive, ref, useTemplateRef } from 'vue'
|
||||||
import {
|
import {
|
||||||
actionText,
|
actionText,
|
||||||
|
getLoginLogs,
|
||||||
getOperationLogs,
|
getOperationLogs,
|
||||||
|
LOGIN_TYPES,
|
||||||
|
MODULE_OPTIONS,
|
||||||
OPERATION_ACTIONS,
|
OPERATION_ACTIONS,
|
||||||
SENSITIVE_ACTIONS
|
SENSITIVE_ACTIONS,
|
||||||
|
TARGET_TYPE_OPTIONS
|
||||||
} from '@/apis/xt/log'
|
} from '@/apis/xt/log'
|
||||||
import { getTenantList } from '@/apis/xt/tenant'
|
import { getTenantList } from '@/apis/xt/tenant'
|
||||||
|
import { USER_TYPE_NAMES } from '@/apis/xt/user'
|
||||||
import { listPagination } from '@/composables/pagination'
|
import { listPagination } from '@/composables/pagination'
|
||||||
import AppIcon from '@/icons/AppIcon.vue'
|
import AppIcon from '@/icons/AppIcon.vue'
|
||||||
import { useUserStore } from '@/stores/user'
|
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` 决定。⚠️ 平台看到的每条日志属于哪个单位,
|
* 由服务端按 `log:viewAll` / `log:viewTenant` 决定。⚠️ 平台看到的每条日志属于哪个单位,
|
||||||
* 靠「租户」这一列 —— 它**只对平台显示**(学校管理员永远是本租户,列没有意义)。
|
* 靠两张表里那一列「单位」(`0` → 「平台」)—— 它**只对平台显示**:
|
||||||
|
* 学校管理员永远是本租户,整列都是同一个值,加了是噪音。
|
||||||
|
*
|
||||||
|
* 两张表的这一列**用同一个词**(「单位」),与筛选栏那两个下拉的占位符一致。
|
||||||
|
* 不叫「租户」是因为它要能装下 `tenantId = 0` 的「平台」—— 平台不是租户,
|
||||||
|
* 表头写「租户」而格子里写着「平台」,读起来是矛盾的。
|
||||||
*/
|
*/
|
||||||
defineOptions({ name: 'OperationLogs' })
|
defineOptions({ name: 'Logs' })
|
||||||
|
|
||||||
|
type LogMode = 'operation' | 'login'
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const user = useUserStore()
|
const user = useUserStore()
|
||||||
|
const detailRef = useTemplateRef('detailRef')
|
||||||
|
|
||||||
/** 平台运营:看全量日志,因此要显示「租户」列并把 id 翻成单位名 */
|
/** 平台运营:看全量日志,因此要显示「单位」列并把 id 翻成单位名 */
|
||||||
const isPlatform = computed(() => user.isPlatform)
|
const isPlatform = computed(() => user.isPlatform)
|
||||||
|
/** 平台运营的权限码口径(与后端 `logScopeTenantId` 同一个码):多一个「单位」筛选 */
|
||||||
|
const canViewAll = computed(() => user.has('log:viewAll'))
|
||||||
|
|
||||||
const tenantNames = ref<Record<number, string>>({})
|
const tenantNames = ref<Record<number, string>>({})
|
||||||
|
const tenantOptions = ref<Array<{ label: string, value: number }>>([])
|
||||||
|
|
||||||
const loading = ref(false)
|
const mode = ref<LogMode>('operation')
|
||||||
const list = ref<OperationLog[]>([])
|
|
||||||
const total = ref(0)
|
|
||||||
|
|
||||||
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
|
targetId: number | null
|
||||||
targetType: string | null
|
targetType: string | null
|
||||||
action: string | null
|
action: string | null
|
||||||
@@ -66,6 +89,7 @@ const query = reactive<{
|
|||||||
pageNum: number
|
pageNum: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
}>({
|
}>({
|
||||||
|
tenantId: null,
|
||||||
targetId: null,
|
targetId: null,
|
||||||
targetType: null,
|
targetType: null,
|
||||||
action: null,
|
action: null,
|
||||||
@@ -74,24 +98,45 @@ const query = reactive<{
|
|||||||
pageSize: 20
|
pageSize: 20
|
||||||
})
|
})
|
||||||
|
|
||||||
const targetTypeOptions = [
|
// ── 登录日志 ─────────────────────────────────────────
|
||||||
{ label: '名册项', value: 'ROSTER' },
|
const loginLoading = ref(false)
|
||||||
{ label: '任务', value: 'TASK' },
|
const loginList = ref<LoginLog[]>([])
|
||||||
{ label: '填报单', value: 'SUBMISSION' },
|
const loginTotal = ref(0)
|
||||||
{ label: '导出', value: 'EXPORT' },
|
const loginFrom = ref<string | null>(null)
|
||||||
{ label: '账号', value: 'USER' }
|
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 }))
|
const actionOptions = OPERATION_ACTIONS.map((a) => ({ label: a.label, value: a.value }))
|
||||||
|
|
||||||
/** 加入申请(JOIN)已随需求收敛下线,这里不再列它 */
|
/**
|
||||||
const moduleOptions = [
|
* 登录结果的筛选值用 `1 / 0`,**不用 `true / false`** ——
|
||||||
{ label: '名册', value: 'ROSTER' },
|
* naive 的 `SelectOption.value` 只接受 `string | number`,传布尔会在 `vue-tsc` 里报类型错;
|
||||||
{ label: '任务', value: 'TASK' },
|
* 翻成后端要的 `Boolean` 只在调接口那一处做一次。
|
||||||
{ label: '审核', value: 'AUDIT' },
|
*/
|
||||||
{ label: '导出', value: 'EXPORT' }
|
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 {
|
function fmt(v?: string): string {
|
||||||
return v ? v.replace('T', ' ').slice(0, 19) : '—'
|
return v ? v.replace('T', ' ').slice(0, 19) : '—'
|
||||||
}
|
}
|
||||||
@@ -100,16 +145,36 @@ function isSensitive(action?: string): boolean {
|
|||||||
return !!action && SENSITIVE_ACTIONS.includes(action)
|
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> = []
|
const cols: DataTableColumns<OperationLog> = []
|
||||||
|
|
||||||
// 「租户」列只给平台运营看
|
// 「单位」列只给平台运营看
|
||||||
if (isPlatform.value) {
|
if (isPlatform.value) {
|
||||||
cols.push({
|
cols.push({
|
||||||
title: '租户',
|
title: '单位',
|
||||||
key: 'tenantId',
|
key: 'tenantId',
|
||||||
width: 150,
|
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',
|
key: 'operatorName',
|
||||||
width: 130,
|
width: 130,
|
||||||
// ⚠️ 只显示账号/姓名,不显示用户 ID(客户走查要求):
|
// ⚠️ 只显示账号/姓名,不显示用户 ID(客户走查要求):
|
||||||
// 数字 ID 对看日志的人没有意义,他要知道的是"哪个账号做的"
|
// 数字 ID 对看日志的人没有意义,他要知道的是"哪个账号做的"。
|
||||||
|
// 需要 ID 时点「详情」—— 那里带 `#id`,与库里那一行对得上
|
||||||
render: (row) => row.operatorName || '—'
|
render: (row) => row.operatorName || '—'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -167,6 +233,105 @@ const columns = computed<DataTableColumns<OperationLog>>(() => {
|
|||||||
h('div', { class: 'log__muted' }, row.ip || '—'),
|
h('div', { class: 'log__muted' }, row.ip || '—'),
|
||||||
h('div', { class: 'log__ua', title: row.userAgent || '' }, row.userAgent || '')
|
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 页重新取数,
|
* 同时处理 `@update:page` 与 `@update:page-size`(改每页条数一律回到第 1 页重新取数,
|
||||||
* 否则会出现"分页器说 50 条/页、表里还是上一次那 20 条")。
|
* 否则会出现"分页器说 50 条/页、表里还是上一次那 20 条")。
|
||||||
*/
|
*/
|
||||||
const pagination = computed(() => listPagination({
|
const opPagination = computed(() => listPagination({
|
||||||
page: query.pageNum,
|
page: opQuery.pageNum,
|
||||||
pageSize: query.pageSize,
|
pageSize: opQuery.pageSize,
|
||||||
itemCount: total.value,
|
itemCount: opTotal.value,
|
||||||
pageSizes: [20, 50, 100]
|
pageSizes: [20, 50, 100]
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function onPage(page: number): void {
|
const loginPagination = computed(() => listPagination({
|
||||||
query.pageNum = page
|
page: loginQuery.pageNum,
|
||||||
void load()
|
pageSize: loginQuery.pageSize,
|
||||||
}
|
itemCount: loginTotal.value,
|
||||||
|
pageSizes: [20, 50, 100]
|
||||||
|
}))
|
||||||
|
|
||||||
function onPageSize(size: number): void {
|
async function loadOperations(): Promise<void> {
|
||||||
query.pageSize = size
|
opLoading.value = true
|
||||||
query.pageNum = 1
|
|
||||||
void load()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
const page = await getOperationLogs({
|
const page = await getOperationLogs({
|
||||||
targetId: query.targetId ?? undefined,
|
// ⚠️ `?? undefined` 而不是 `||`:0 是「平台」这个**有效值**,用 `||` 会被当成"没选"
|
||||||
targetType: query.targetType ?? undefined,
|
tenantId: opQuery.tenantId ?? undefined,
|
||||||
action: query.action ?? undefined,
|
targetId: opQuery.targetId ?? undefined,
|
||||||
module: query.module ?? undefined,
|
targetType: opQuery.targetType ?? undefined,
|
||||||
|
action: opQuery.action ?? undefined,
|
||||||
|
module: opQuery.module ?? undefined,
|
||||||
// 后端 `to` 会补到当天 23:59:59,所以这里只给日期就够
|
// 后端 `to` 会补到当天 23:59:59,所以这里只给日期就够
|
||||||
from: fromDate.value || undefined,
|
from: opFrom.value || undefined,
|
||||||
to: toDate.value || undefined,
|
to: opTo.value || undefined,
|
||||||
pageNum: query.pageNum,
|
pageNum: opQuery.pageNum,
|
||||||
pageSize: query.pageSize
|
pageSize: opQuery.pageSize
|
||||||
})
|
})
|
||||||
list.value = page.list ?? []
|
opList.value = page.list ?? []
|
||||||
total.value = page.total ?? 0
|
opTotal.value = page.total ?? 0
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
opLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function search(): void {
|
async function loadLogins(): Promise<void> {
|
||||||
query.pageNum = 1
|
loginLoading.value = true
|
||||||
void load()
|
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
|
* ⚠️ 进入哪个模式,就把它**自己**的页码重置到第 1 页:
|
||||||
query.module = null
|
* 两个模式各自记着上次翻到第几页,不重置就会出现"切过来是第 5 页、而这张表只有 2 页"的空表 ——
|
||||||
fromDate.value = null
|
* 那看起来就是"登录日志一条都没有"。筛选条件保留(切回来还是刚才那批条件),只有页码归零。
|
||||||
toDate.value = null
|
*/
|
||||||
search()
|
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 () => {
|
onMounted(async () => {
|
||||||
// 平台运营才需要"租户 id → 单位名"。取不到也不影响看日志:列里退回显示 #id
|
await loadTenants()
|
||||||
if (isPlatform.value) {
|
await loadOperations()
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NCard class="page-card log" :bordered="false" size="small">
|
<NCard class="page-card log" :bordered="false" size="small">
|
||||||
<template #header>
|
<template #header>
|
||||||
操作日志
|
日志
|
||||||
</template>
|
</template>
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<span class="log__hint">
|
<span v-if="mode === 'operation'" class="log__hint">
|
||||||
按「对象」查是这个页面最重要的用法:填对象类型 + ID,就能看到它被谁改过
|
按「对象」查是这个页面最重要的用法:填对象类型 + ID,就能看到它被谁改过
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else class="log__hint">
|
||||||
|
登录日志用来回答「谁在什么时候从哪里登了进来」;IP 归属地一期没有采集,所以没有这一列
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -269,62 +539,136 @@ onMounted(async () => {
|
|||||||
否则换行后每行会被"两端对齐"抻开,看起来像随机的空档。
|
否则换行后每行会被"两端对齐"抻开,看起来像随机的空档。
|
||||||
-->
|
-->
|
||||||
<div class="toolbar log__filters">
|
<div class="toolbar log__filters">
|
||||||
|
<!--
|
||||||
|
模式切换:用两个按钮做的分段控件(刻意不用 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 定位 -->
|
<!-- 对象 ID 放最左:合规问题只能靠 target_type + target_id 定位 -->
|
||||||
<NInputNumber
|
<NInputNumber
|
||||||
v-model:value="query.targetId"
|
v-model:value="opQuery.targetId"
|
||||||
class="log__num"
|
class="log__num"
|
||||||
:min="1"
|
:min="1"
|
||||||
clearable
|
clearable
|
||||||
placeholder="对象 ID"
|
placeholder="对象 ID"
|
||||||
@update:value="search"
|
@update:value="searchOperations"
|
||||||
/>
|
/>
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="query.targetType"
|
v-model:value="opQuery.targetType"
|
||||||
class="log__sel"
|
class="log__sel"
|
||||||
clearable
|
clearable
|
||||||
placeholder="对象类型"
|
placeholder="对象类型"
|
||||||
:options="targetTypeOptions"
|
:options="TARGET_TYPE_OPTIONS"
|
||||||
@update:value="search"
|
@update:value="searchOperations"
|
||||||
/>
|
/>
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="query.action"
|
v-model:value="opQuery.action"
|
||||||
class="log__sel2"
|
class="log__sel2"
|
||||||
clearable
|
clearable
|
||||||
placeholder="动作"
|
placeholder="动作"
|
||||||
:options="actionOptions"
|
:options="actionOptions"
|
||||||
@update:value="search"
|
@update:value="searchOperations"
|
||||||
/>
|
/>
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="query.module"
|
v-model:value="opQuery.module"
|
||||||
class="log__sel"
|
class="log__sel"
|
||||||
clearable
|
clearable
|
||||||
placeholder="模块"
|
placeholder="模块"
|
||||||
:options="moduleOptions"
|
:options="MODULE_OPTIONS"
|
||||||
@update:value="search"
|
@update:value="searchOperations"
|
||||||
/>
|
/>
|
||||||
<!-- 起止日期用两个独立日期框,不用 range:两个框各自能单独清空,核查时更好用 -->
|
<!-- 起止日期用两个独立日期框,不用 range:两个框各自能单独清空,核查时更好用 -->
|
||||||
<NDatePicker
|
<NDatePicker
|
||||||
v-model:formatted-value="fromDate"
|
v-model:formatted-value="opFrom"
|
||||||
type="date"
|
type="date"
|
||||||
value-format="yyyy-MM-dd"
|
value-format="yyyy-MM-dd"
|
||||||
clearable
|
clearable
|
||||||
placeholder="开始日期"
|
placeholder="开始日期"
|
||||||
class="log__date"
|
class="log__date"
|
||||||
@update:formatted-value="search"
|
@update:formatted-value="searchOperations"
|
||||||
/>
|
/>
|
||||||
<NDatePicker
|
<NDatePicker
|
||||||
v-model:formatted-value="toDate"
|
v-model:formatted-value="opTo"
|
||||||
type="date"
|
type="date"
|
||||||
value-format="yyyy-MM-dd"
|
value-format="yyyy-MM-dd"
|
||||||
clearable
|
clearable
|
||||||
placeholder="结束日期"
|
placeholder="结束日期"
|
||||||
class="log__date"
|
class="log__date"
|
||||||
@update:formatted-value="search"
|
@update:formatted-value="searchOperations"
|
||||||
/>
|
/>
|
||||||
<NButton @click="reset">
|
</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>
|
||||||
<NButton @click="load">
|
<NButton @click="refresh">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<AppIcon name="refresh" :size="16" />
|
<AppIcon name="refresh" :size="16" />
|
||||||
</template>
|
</template>
|
||||||
@@ -332,23 +676,52 @@ onMounted(async () => {
|
|||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</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">
|
<div class="page-card__body">
|
||||||
|
<!--
|
||||||
|
`:scroll-x` 是**列宽合计**:横向滚动条按它算。
|
||||||
|
两张表都是"平台比学校管理员多一列「单位」(150)",所以两个数各有一对;
|
||||||
|
操作日志末尾还含固定的「操作」列 90(登录日志没有这一列)。
|
||||||
|
-->
|
||||||
<NDataTable
|
<NDataTable
|
||||||
|
v-if="mode === 'operation'"
|
||||||
flex-height
|
flex-height
|
||||||
style="height: 100%"
|
style="height: 100%"
|
||||||
remote
|
remote
|
||||||
:columns="columns"
|
:columns="operationColumns"
|
||||||
:data="list"
|
:data="opList"
|
||||||
:loading="loading"
|
:loading="opLoading"
|
||||||
:row-key="row => row.id"
|
:row-key="row => row.id"
|
||||||
:scroll-x="isPlatform ? 1240 : 1090"
|
:scroll-x="isPlatform ? 1330 : 1180"
|
||||||
:pagination="pagination"
|
:pagination="opPagination"
|
||||||
size="small"
|
size="small"
|
||||||
@update:page="onPage"
|
@update:page="onOperationPage"
|
||||||
@update:page-size="onPageSize"
|
@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>
|
</div>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|
||||||
|
<!-- 详情不发请求:整行数据已经查回来了,抽屉只负责把被截断/没展示的字段摊开 -->
|
||||||
|
<LogDetailDrawer ref="detailRef" :tenant-names="tenantNames" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -384,6 +757,18 @@ onMounted(async () => {
|
|||||||
justify-content: flex-start;
|
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 {
|
.log__num {
|
||||||
width: 130px;
|
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>
|
||||||
@@ -13,18 +13,22 @@ import {
|
|||||||
NFormItem,
|
NFormItem,
|
||||||
NInput,
|
NInput,
|
||||||
NModal,
|
NModal,
|
||||||
|
NPopconfirm,
|
||||||
NSelect,
|
NSelect,
|
||||||
NSpace,
|
NSpace,
|
||||||
NTag,
|
NTag,
|
||||||
useMessage
|
useMessage
|
||||||
} from 'naive-ui'
|
} 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 * as tenantApi from '@/apis/xt/tenant'
|
||||||
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
|
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
|
||||||
import { listPagination } from '@/composables/pagination'
|
import { listPagination } from '@/composables/pagination'
|
||||||
import AppIcon from '@/icons/AppIcon.vue'
|
import AppIcon from '@/icons/AppIcon.vue'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
import { fmtDate } from '@/utils/format'
|
import { fmtDate } from '@/utils/format'
|
||||||
import { tagOf } from '@/utils/tag'
|
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`),权限把关全在服务端;
|
* 2. **租户列表是跨租户的**(`sys_tenant` 没有 `tenant_id`),权限把关全在服务端;
|
||||||
* 学校管理员调它会拿到 40301,左侧菜单也不会给他这一项。
|
* 学校管理员调它会拿到 40301,左侧菜单也不会给他这一项。
|
||||||
*
|
*
|
||||||
* ⚠️ 列表**没有操作列**:不提供编辑 / 停用入口(客户 2026-10 走查确认的口径)。
|
* ⚠️ **口径变更(本轮,覆盖 2026-10 走查的旧结论)**:列表**有操作列**了 ——
|
||||||
|
* 详情 / 编辑 / 停用·启用 三个入口。
|
||||||
|
* **旧口径**是"列表不提供编辑 / 停用入口";推翻它的原因是运营的真实诉求:
|
||||||
|
* ① 学校到期日变了,运营要能自己改,而不是每次找开发;
|
||||||
|
* ② 客户欠费 / 停止合作时要能**当场停掉**(停用会立刻挡住这所学校的
|
||||||
|
* 学生与老师登录,服务端还会为此单独记一条 WARN 日志);
|
||||||
|
* ③ 每周核对"谁快到期了"时,光看列表不够,要能点进详情看用量。
|
||||||
|
* 所以旧注释不是 rebase 丢的,是**被本轮需求明确改掉的**。
|
||||||
|
* 权限门控:`tenant:view` 管「详情」,`tenant:manage` 管「编辑 / 停用 / 启用 / 开通」;
|
||||||
|
* 拿不到 `tenant:manage` 的人操作列显示"只读",而不是留一个点了报 403 的按钮。
|
||||||
*/
|
*/
|
||||||
defineOptions({ name: 'Tenant' })
|
defineOptions({ name: 'Tenant' })
|
||||||
|
|
||||||
const message = useMessage()
|
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 loading = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
@@ -48,6 +70,28 @@ const total = ref(0)
|
|||||||
const statusFilter = ref<number | undefined>(undefined)
|
const statusFilter = ref<number | undefined>(undefined)
|
||||||
const keyword = ref('')
|
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 query = reactive({ pageNum: 1, pageSize: 10 })
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
@@ -55,6 +99,32 @@ const created = ref<TenantCreateResult | null>(null)
|
|||||||
const createdName = ref('')
|
const createdName = ref('')
|
||||||
const formRef = ref<FormInst | null>(null)
|
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() {
|
function emptyForm() {
|
||||||
return {
|
return {
|
||||||
tenantName: '',
|
tenantName: '',
|
||||||
@@ -65,7 +135,7 @@ function emptyForm() {
|
|||||||
// 用 null 而不是 '':n-date-picker 清空时会回传 null,
|
// 用 null 而不是 '':n-date-picker 清空时会回传 null,
|
||||||
// 声明成 string 会在 v-model 上产生类型打架(运行期没事,但 vue-tsc 会红)
|
// 声明成 string 会在 v-model 上产生类型打架(运行期没事,但 vue-tsc 会红)
|
||||||
expireTime: null as string | null,
|
expireTime: null as string | null,
|
||||||
adminUsername: 'admin',
|
adminUsername: '',
|
||||||
adminRealName: '',
|
adminRealName: '',
|
||||||
adminPhone: ''
|
adminPhone: ''
|
||||||
}
|
}
|
||||||
@@ -114,6 +184,27 @@ const statusChips = computed(() => [
|
|||||||
{ label: '已停用', value: 0 }
|
{ 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> = [
|
const columns: DataTableColumns<TenantVO> = [
|
||||||
{
|
{
|
||||||
title: '单位名称',
|
title: '单位名称',
|
||||||
@@ -134,8 +225,16 @@ const columns: DataTableColumns<TenantVO> = [
|
|||||||
{
|
{
|
||||||
title: '到期',
|
title: '到期',
|
||||||
key: 'expireTime',
|
key: 'expireTime',
|
||||||
width: 120,
|
width: 130,
|
||||||
render: (row) => fmtDate(row.expireTime)
|
/**
|
||||||
|
* 到期列的颜色提示:已过期(红)/ 30 天内到期(橙)/ 正常(灰)。
|
||||||
|
*
|
||||||
|
* 为什么只上色、不加文字:这一列只有 130px,塞进"还有 12 天"会把编码列挤走;
|
||||||
|
* 而"快到期了"这件事运营是扫一眼列表找的,颜色足够,细节在详情抽屉里。
|
||||||
|
* ⚠️ 阈值必须与工具条「即将到期」的 30 天一致,否则会出现
|
||||||
|
* "按钮筛出来的行里有一半不是橙的"这种自相矛盾的画面。
|
||||||
|
*/
|
||||||
|
render: (row) => h('span', { class: expireClass(row.expireTime) }, fmtDate(row.expireTime))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
@@ -145,9 +244,87 @@ const columns: DataTableColumns<TenantVO> = [
|
|||||||
const s = tagOf(TENANT_STATUS, row.status)
|
const s = tagOf(TENANT_STATUS, row.status)
|
||||||
return h(NTag, { size: 'small', bordered: false, type: s.type }, { default: () => s.text })
|
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`)——
|
* 分页器统一走 `listPagination`(见 `@/composables/pagination`)——
|
||||||
* 手写 `prefix` 的类型最容易写错(naive 给的 `itemCount` 是 `number | undefined`)。
|
* 手写 `prefix` 的类型最容易写错(naive 给的 `itemCount` 是 `number | undefined`)。
|
||||||
@@ -178,7 +355,8 @@ async function load(): Promise<void> {
|
|||||||
pageNum: query.pageNum,
|
pageNum: query.pageNum,
|
||||||
pageSize: query.pageSize,
|
pageSize: query.pageSize,
|
||||||
keyword: keyword.value.trim() || undefined,
|
keyword: keyword.value.trim() || undefined,
|
||||||
status: statusFilter.value
|
status: statusFilter.value,
|
||||||
|
expireBefore: expireOnly.value ? todayPlus30() : undefined
|
||||||
})
|
})
|
||||||
list.value = page.list ?? []
|
list.value = page.list ?? []
|
||||||
total.value = page.total ?? 0
|
total.value = page.total ?? 0
|
||||||
@@ -192,13 +370,61 @@ function search(): void {
|
|||||||
void load()
|
void load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 点状态筛选:清掉「即将到期」,否则两个维度叠加后结果集小得让人以为数据丢了 */
|
||||||
function setStatus(v: number | undefined): void {
|
function setStatus(v: number | undefined): void {
|
||||||
|
expireOnly.value = false
|
||||||
statusFilter.value = v
|
statusFilter.value = v
|
||||||
search()
|
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 {
|
function onAdd(): void {
|
||||||
Object.assign(form, emptyForm())
|
Object.assign(form, emptyForm())
|
||||||
|
// 每次打开弹窗都重新开始"跟着编码走"的状态,否则第二次打开时人会发现自己改不动账号名
|
||||||
|
adminUsernameTouched.value = false
|
||||||
|
autoUsername = ''
|
||||||
created.value = null
|
created.value = null
|
||||||
visible.value = true
|
visible.value = true
|
||||||
}
|
}
|
||||||
@@ -251,6 +477,23 @@ function closeCreated(): void {
|
|||||||
visible.value = false
|
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)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -267,22 +510,33 @@ onMounted(load)
|
|||||||
工具条:主操作在左、筛选在右(与收集任务页同一口径)。
|
工具条:主操作在左、筛选在右(与收集任务页同一口径)。
|
||||||
同一行里所有控件统一**默认尺寸** —— 全是 28px 的小控件挤在 52px 的一行里,
|
同一行里所有控件统一**默认尺寸** —— 全是 28px 的小控件挤在 52px 的一行里,
|
||||||
白底小按钮贴在白底卡片上几乎看不出是按钮。
|
白底小按钮贴在白底卡片上几乎看不出是按钮。
|
||||||
|
|
||||||
|
「即将到期」与状态按钮组是**互斥的两个筛选维度**(点一个清另一个),
|
||||||
|
所以它们之间用一条竖线分开,右侧再用一行小字说明"当前在按什么筛"——
|
||||||
|
光靠按钮高亮区分不够,用户会以为两组是叠加的。
|
||||||
-->
|
-->
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<NSpace :size="8">
|
<NSpace :size="8">
|
||||||
<NButton type="primary" @click="onAdd">
|
<NButton v-if="canManage" type="primary" @click="onAdd">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<AppIcon name="plus" :size="16" />
|
<AppIcon name="plus" :size="16" />
|
||||||
</template>
|
</template>
|
||||||
开通租户
|
开通租户
|
||||||
</NButton>
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
:type="expireOnly ? 'primary' : 'default'"
|
||||||
|
@click="toggleExpireOnly"
|
||||||
|
>
|
||||||
|
即将到期
|
||||||
|
</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
<NSpace :size="8" align="center">
|
<NSpace :size="8" align="center">
|
||||||
|
<span v-if="filterHint" class="tenant__filter-hint">{{ filterHint }}</span>
|
||||||
<NButton
|
<NButton
|
||||||
v-for="c in statusChips"
|
v-for="c in statusChips"
|
||||||
:key="String(c.value)"
|
:key="String(c.value)"
|
||||||
:type="statusFilter === c.value ? 'primary' : 'default'"
|
:type="!expireOnly && statusFilter === c.value ? 'primary' : 'default'"
|
||||||
@click="setStatus(c.value)"
|
@click="setStatus(c.value)"
|
||||||
>
|
>
|
||||||
{{ c.label }}
|
{{ c.label }}
|
||||||
@@ -302,6 +556,8 @@ onMounted(load)
|
|||||||
<!--
|
<!--
|
||||||
⚠️ Naive 的表格**没有** `scroll` prop(那是 Arco 的 API):
|
⚠️ Naive 的表格**没有** `scroll` prop(那是 Arco 的 API):
|
||||||
横向用 `:scroll-x`(数值),纵向靠 `flex-height` + 高度链。
|
横向用 `:scroll-x`(数值),纵向靠 `flex-height` + 高度链。
|
||||||
|
加了「操作」列(200px)后总宽从 800 涨到 1040,`:scroll-x` 要跟着改 ——
|
||||||
|
不改的话窄屏下按钮会被压成竖排的两三个字。
|
||||||
-->
|
-->
|
||||||
<NDataTable
|
<NDataTable
|
||||||
flex-height
|
flex-height
|
||||||
@@ -311,7 +567,7 @@ onMounted(load)
|
|||||||
:data="list"
|
:data="list"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:row-key="row => row.id"
|
:row-key="row => row.id"
|
||||||
:scroll-x="800"
|
:scroll-x="1040"
|
||||||
:pagination="pagination"
|
:pagination="pagination"
|
||||||
size="small"
|
size="small"
|
||||||
@update:page="onPage"
|
@update:page="onPage"
|
||||||
@@ -319,6 +575,15 @@ onMounted(load)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<NModal
|
||||||
v-model:show="visible"
|
v-model:show="visible"
|
||||||
@@ -399,7 +664,11 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
<div class="tenant__grid tenant__grid--3">
|
<div class="tenant__grid tenant__grid--3">
|
||||||
<NFormItem label="登录账号" path="adminUsername">
|
<NFormItem label="登录账号" path="adminUsername">
|
||||||
<NInput v-model:value="form.adminUsername" clearable />
|
<NInput
|
||||||
|
v-model:value="form.adminUsername"
|
||||||
|
placeholder="按租户编码自动生成,如 admin_jxjd"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="姓名" path="adminRealName">
|
<NFormItem label="姓名" path="adminRealName">
|
||||||
<NInput v-model:value="form.adminRealName" clearable />
|
<NInput v-model:value="form.adminRealName" clearable />
|
||||||
@@ -456,6 +725,30 @@ onMounted(load)
|
|||||||
width: 220px;
|
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,
|
.tenant__id,
|
||||||
:deep(.tenant__id) {
|
:deep(.tenant__id) {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
+71
-1
@@ -21,7 +21,7 @@
|
|||||||
逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表,
|
逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表,
|
||||||
而不是靠下次再想一遍)。
|
而不是靠下次再想一遍)。
|
||||||
|
|
||||||
## 查九件事
|
## 查十件事
|
||||||
|
|
||||||
| # | 查什么 | 没接上的后果 |
|
| # | 查什么 | 没接上的后果 |
|
||||||
|:--:|---|---|
|
|:--:|---|---|
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
| 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) |
|
| 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) |
|
||||||
| 8 | PC 端模板里的 `<n-*>` 组件有没有对应 `import` | 被当未知元素渲染:不报错,样式整块丢(见下) |
|
| 8 | PC 端模板里的 `<n-*>` 组件有没有对应 `import` | 被当未知元素渲染:不报错,样式整块丢(见下) |
|
||||||
| 9 | **`.svg` 资产是不是合法 XML** | 图标裂开,控制台一句话都不报(见下) |
|
| 9 | **`.svg` 资产是不是合法 XML** | 图标裂开,控制台一句话都不报(见下) |
|
||||||
|
| 10 | **后端会写的日志动作码 ↔ PC 端 `OPERATION_ACTIONS` 的文案** | 日志页原样回显英文码(见下) |
|
||||||
|
|
||||||
### 第 7 项是 2026-10-09 补的(自己踩了一次)
|
### 第 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 里没有的(**应当保持为空** ——
|
# PC 端引用的权限码:PermissionDef 里没有的(**应当保持为空** ——
|
||||||
# 写错一个码的后果是"那个判断恒为 false",整块界面静默消失,见文件头第 7 项)
|
# 写错一个码的后果是"那个判断恒为 false",整块界面静默消失,见文件头第 7 项)
|
||||||
FE_PERM_EXCEPTION = {}
|
FE_PERM_EXCEPTION = {}
|
||||||
|
|
||||||
|
# 第 ⑩ 项:后端会写的日志动作码,却**有意**不在 PC 端给中文文案的。
|
||||||
|
# 现在没有例外 —— 少一个文案就会在日志页上原样回显英文码,没有"故意不翻译"的理由。
|
||||||
|
ACTION_TEXT_EXCEPTION = {}
|
||||||
ERROR_CODE_UNUSED = {
|
ERROR_CODE_UNUSED = {
|
||||||
'ID_CARD_CHECKSUM_FAILED': 'M4 识别与校验(等 OCR 选型,见 docs/01 D1)',
|
'ID_CARD_CHECKSUM_FAILED': 'M4 识别与校验(等 OCR 选型,见 docs/01 D1)',
|
||||||
'ID_CARD_MISMATCH_ROSTER': '同上(M4)',
|
'ID_CARD_MISMATCH_ROSTER': '同上(M4)',
|
||||||
@@ -642,6 +661,57 @@ def main():
|
|||||||
if not svg_bad:
|
if not svg_bad:
|
||||||
say(' ✓ 全部能被解析为合法 XML')
|
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)
|
print('\n' + '=' * 88)
|
||||||
if notes:
|
if notes:
|
||||||
|
|||||||
+94
-2
@@ -16,7 +16,14 @@ M0 验收测试。
|
|||||||
|
|
||||||
退出码:0 = 全部通过;1 = 有失败项(可直接用于 CI)。
|
退出码: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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -24,6 +31,7 @@ import argparse
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -35,9 +43,18 @@ if hasattr(sys.stdout, "reconfigure"):
|
|||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
|
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] = []
|
PASSED: list[str] = []
|
||||||
FAILED: list[tuple[str, str]] = []
|
FAILED: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
#: 本脚本造出来的东西。放模块级 —— 脚本中途抛异常时也要能被清掉。
|
||||||
|
CREATED_TENANT_IDS: list[int] = []
|
||||||
|
CREATED_ORG_IDS: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
# ── HTTP ──────────────────────────────────────────────
|
# ── HTTP ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -129,6 +146,58 @@ def finish() -> int:
|
|||||||
return 0 if not FAILED else 1
|
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:
|
def main() -> int:
|
||||||
@@ -216,6 +285,8 @@ def main() -> int:
|
|||||||
expect_code("B2 平台运营开通租户成功", b, 0)
|
expect_code("B2 平台运营开通租户成功", b, 0)
|
||||||
created = data_of(b)
|
created = data_of(b)
|
||||||
new_tenant_id = created.get("id")
|
new_tenant_id = created.get("id")
|
||||||
|
if isinstance(new_tenant_id, int):
|
||||||
|
CREATED_TENANT_IDS.append(new_tenant_id) # 登记,收尾与崩溃路径都要清它
|
||||||
new_pwd = created.get("adminInitialPassword")
|
new_pwd = created.get("adminInitialPassword")
|
||||||
check("B3 返回初始密码(仅此一次)", bool(new_pwd), json.dumps(created, ensure_ascii=False)[:200])
|
check("B3 返回初始密码(仅此一次)", bool(new_pwd), json.dumps(created, ensure_ascii=False)[:200])
|
||||||
check("B4 返回小程序码参数", created.get("miniProgramScene") == f"t{new_tenant_id}",
|
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}")
|
b = mk_org(demo_token, dept_id, f"E2E 24数控1班 {stamp}", 3, f"CLS{stamp}")
|
||||||
expect_code("C3 建班级节点", b, 0)
|
expect_code("C3 建班级节点", b, 0)
|
||||||
class_id = b.get("data")
|
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)",
|
expect_code("C4 同级重复组织编码被拒(40001)",
|
||||||
mk_org(demo_token, school_id, "重复编码", 2, f"DEPT{stamp}"), 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,
|
check("E4 参数校验失败返回 40001 且带字段级信息", b.get("code") == 40001,
|
||||||
f"code={b.get('code')}")
|
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()
|
return finish()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
# 崩溃也要清:否则每次失败的运行都往库里丢一个 e2e 租户和一个叫 admin 的账号
|
||||||
|
# (跨租户同名账号就是这么攒出来的,见文件头 ⚠️)
|
||||||
|
try:
|
||||||
|
_code = main()
|
||||||
|
except BaseException:
|
||||||
|
print("\n ⚠ 脚本异常退出,正在清理已造出的测试数据…")
|
||||||
|
cleanup_all()
|
||||||
|
raise
|
||||||
|
raise SystemExit(_code)
|
||||||
|
|||||||
+133
-12
@@ -14,8 +14,20 @@
|
|||||||
所以这条比第 ① 条更重要。
|
所以这条比第 ① 条更重要。
|
||||||
|
|
||||||
跑法:python scripts/e2e-m1-tenant-domain.py
|
跑法:python scripts/e2e-m1-tenant-domain.py
|
||||||
前提:后端 8080 在跑,且 `fill.tenant-domain` 已启用(dev 默认
|
前提:后端 8080 在跑;能 `docker exec xt-pg`(造/清"跨租户同名账号"夹具)。
|
||||||
enabled=true / base=localhost —— 只有 *.localhost 会走这条路,不影响其它域名)。
|
开关开与关都能跑(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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -24,16 +36,26 @@ import base64
|
|||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
|
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] = []
|
PASSED: list[str] = []
|
||||||
FAILED: list[tuple[str, str]] = []
|
FAILED: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
#: 本脚本造出来的「跨租户同名账号」夹具租户 id。模块级 —— 脚本中途抛异常也要能清掉。
|
||||||
|
FIXTURE_TENANT_IDS: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
def ok(name: str) -> None:
|
def ok(name: str) -> None:
|
||||||
PASSED.append(name)
|
PASSED.append(name)
|
||||||
@@ -53,6 +75,93 @@ def check(name: str, cond: bool, detail: str = "") -> bool:
|
|||||||
return cond
|
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:
|
def rsa_encrypt(plain: str) -> str:
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric import padding
|
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)
|
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 = argparse.ArgumentParser(description="租户域名 E2E")
|
||||||
ap.add_argument("--password", default="Admin@123")
|
ap.add_argument("--password", default="Admin@123")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
@@ -144,9 +253,10 @@ def run_on() -> int:
|
|||||||
check("拿 t1 的 token 访问 t1 域名 → 正常",
|
check("拿 t1 的 token 访问 t1 域名 → 正常",
|
||||||
call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t1.localhost", token1).get("code") == 0,
|
call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t1.localhost", token1).get("code") == 0,
|
||||||
"同租户被误拒")
|
"同租户被误拒")
|
||||||
# 14 号租户是 e2e 建的(status=1,存在且启用),所以 t14.localhost 能被解析成一个真实租户
|
# 夹具租户就是"存在、启用、且不是 t1"的另一个真实租户,
|
||||||
cross = call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t14.localhost", token1)
|
# 所以 t{夹具id}.localhost 能被解析成一个真实租户
|
||||||
check("**拿 t1 的 token 访问 t14 域名 → 403(拒绝)**",
|
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
|
cross.get("code") == 40301 or cross.get("_http") == 403
|
||||||
or "另一个单位" in (cross.get("msg") or ""), f"{cross}")
|
or "另一个单位" in (cross.get("msg") or ""), f"{cross}")
|
||||||
check("拒绝时给出可理解的提示(不是一句「无权限」)",
|
check("拒绝时给出可理解的提示(不是一句「无权限」)",
|
||||||
@@ -164,6 +274,8 @@ def run_on() -> int:
|
|||||||
check("无关域名(example.com)不解析",
|
check("无关域名(example.com)不解析",
|
||||||
login("example.com", None).get("code") == 40012, "无关域名被误当租户")
|
login("example.com", None).get("code") == 40012, "无关域名被误当租户")
|
||||||
|
|
||||||
|
cleanup_and_verify()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 76)
|
print("=" * 76)
|
||||||
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||||||
@@ -190,7 +302,7 @@ def detect_mode() -> str:
|
|||||||
raise SystemExit(f" ✗ 探测失败(既不是 0 也不是 40012):{r}")
|
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}")
|
d.get("code") == 40012, f"{d}")
|
||||||
|
|
||||||
token = (b.get("data") or {}).get("token")
|
token = (b.get("data") or {}).get("token")
|
||||||
e = call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t14.localhost", token)
|
e = call("GET", "/api/v1/users?pageNum=1&pageSize=1", f"t{fixture_tid}.localhost", token)
|
||||||
check("关掉之后**不引入任何新校验**:带着 t1 的 token 访问 t14 域名照常通过",
|
check(f"关掉之后**不引入任何新校验**:带着 t1 的 token 访问 t{fixture_tid} 域名照常通过",
|
||||||
e.get("code") == 0,
|
e.get("code") == 0,
|
||||||
f"{e}(这是关闭前的既有行为;开启态下会被 403 拒掉,见 run_on)")
|
f"{e}(这是关闭前的既有行为;开启态下会被 403 拒掉,见 run_on)")
|
||||||
|
|
||||||
ok("要验开启态:以 XT_TENANT_DOMAIN_ENABLED=true 重启后端,再跑本脚本")
|
ok("要验开启态:以 XT_TENANT_DOMAIN_ENABLED=true 重启后端,再跑本脚本")
|
||||||
|
|
||||||
|
cleanup_and_verify()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 76)
|
print("=" * 76)
|
||||||
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||||||
@@ -232,10 +346,17 @@ def run_off() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
# 夹具必须在**探测模式之前**造好:detect_mode() 正是靠"不填租户码会不会 40012"
|
||||||
|
# 判断开关的,而没有同名账号时那条路会直接登录成功 → 关闭态被误判成开启态(见文件头 ⚠️)
|
||||||
|
fixture_tid = create_duplicate_fixture()
|
||||||
|
if fixture_tid is None:
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
mode = detect_mode()
|
mode = detect_mode()
|
||||||
if mode == "ON":
|
return run_on(fixture_tid) if mode == "ON" else run_off(fixture_tid)
|
||||||
return run_on()
|
finally:
|
||||||
return run_off()
|
# 中途崩溃也要清 —— 否则"清残留"这件事自己开始制造残留
|
||||||
|
cleanup_fixture()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+143
-2
@@ -14,6 +14,11 @@
|
|||||||
断言要看的是"**它承认自己没接**",而不是"字段存在"。
|
断言要看的是"**它承认自己没接**",而不是"字段存在"。
|
||||||
③ **上传凭证的 `existed=true` 时不返回 uploadUrl**。
|
③ **上传凭证的 `existed=true` 时不返回 uploadUrl**。
|
||||||
返回了而前端不用,会让"到底传没传"变成两处判断 —— 迟早不一致。
|
返回了而前端不用,会让"到底传没传"变成两处判断 —— 迟早不一致。
|
||||||
|
④ **租户用量必须只算"这一所学校"**。平台运营的 `tenantId` 是 0,而租户拦截器
|
||||||
|
对它**整张表关掉过滤**(`FillTenantLineHandler.ignoreTable`)—— 于是
|
||||||
|
`/tenants/{id}/usage` 会算成**全平台**的学生数与存储量,挂在某一所学校的名下。
|
||||||
|
⚠️ 这个 bug 在"库里只有一所学校有数据"时**与正确值完全相等**,光断言
|
||||||
|
"有值、非 null"永远发现不了 —— 所以本脚本要**先造出别的租户的数据**再断言。
|
||||||
|
|
||||||
## 还有一条不能省的边界
|
## 还有一条不能省的边界
|
||||||
|
|
||||||
@@ -24,6 +29,7 @@
|
|||||||
跑法:python scripts/e2e-m8-orgfile.py
|
跑法:python scripts/e2e-m8-orgfile.py
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import datetime
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -38,6 +44,8 @@ PASSED = []
|
|||||||
FAILED = []
|
FAILED = []
|
||||||
CREATED_TASK_IDS = []
|
CREATED_TASK_IDS = []
|
||||||
CREATED_ORG_NAMES = []
|
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_ROSTER = os.path.join(ROOT, "scripts", "_e2e-of-roster.xlsx")
|
||||||
TMP_ORG = os.path.join(ROOT, "scripts", "_e2e-of-orgs.xlsx")
|
TMP_ORG = os.path.join(ROOT, "scripts", "_e2e-of-orgs.xlsx")
|
||||||
TINY_JPEG = os.path.join(ROOT, "scripts", "_e2e-of.jpg")
|
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),
|
check("学生看租户用量 → 403", b.get("code") in (40101, 40301, 403),
|
||||||
"code=%s" % b.get("code"))
|
"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. 文件夹具 ──
|
# ── 3. 文件夹具 ──
|
||||||
print("\n[3] 摆文件夹具(一个任务 + 一份草稿 + 一个材料文件)")
|
print("\n[3] 摆文件夹具(一个任务 + 一份草稿 + 一个材料文件)")
|
||||||
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
|
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
|
||||||
@@ -476,6 +581,24 @@ def _make_jpeg():
|
|||||||
f.write(base64.b64decode(b64))
|
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():
|
def cleanup():
|
||||||
for tid in CREATED_TASK_IDS:
|
for tid in CREATED_TASK_IDS:
|
||||||
R.sql("delete from collect_validation_issue where task_id=%d;" % tid)
|
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)
|
R.sql("delete from collect_task where id=%d;" % tid)
|
||||||
for name in CREATED_ORG_NAMES:
|
for name in CREATED_ORG_NAMES:
|
||||||
R.sql("delete from sys_org where org_name = '%s';" % name)
|
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);")
|
R.sql("delete from collect_message where user_id in (997201, 997202, 997299);")
|
||||||
for p in (TMP_ROSTER, TMP_ORG, TINY_JPEG):
|
for p in (TMP_ROSTER, TMP_ORG, TINY_JPEG):
|
||||||
if os.path.exists(p):
|
if os.path.exists(p):
|
||||||
os.remove(p)
|
os.remove(p)
|
||||||
print(" 已清理:任务 / 材料文件 / 测试组织 / 租户联系人复原 / 临时文件")
|
print(" 已清理:任务 / 材料文件 / 测试组织 / E2E 租户与用量夹具 / "
|
||||||
|
"租户联系人复原 / 临时文件")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user