feat(user): 账号管理(CRUD、Excel 导入、改密)并修掉 5 处缺陷

后端补齐 /users 全套(列表/新增/修改/软删/启停/重置密码/批量导入)与
PUT /auth/password;前端加「组织与账号 → 账号管理」一屏(左组织树 + 右列表、
新增编辑弹窗、导入体检报告、初始密码只显示一次的强确认)。

验收:e2e-m0-user.py 55/55;e2e-m0.py 回归 36/36;typecheck 0 错、build 通过。
账号管理页已在浏览器肉眼确认(首屏与切页均可见、数据与分页正确)。

本轮修掉的 5 个真问题 —— 都不是功能没做完,而是做完了但错的:

1) 学校租户也发放 PLATFORM_ADMIN 角色行
   学校管理员在新增账号时传 roleCodes=[PLATFORM_ADMIN] 就能造出带
   tenant:manage 的账号,而 /tenants 只校验权限码、不校验是否真在平台租户下
   → 任何学校管理员都能开通租户、拉全平台租户列表(租户级越权到平台级)。
   RoleProvisioner 不再给非平台租户发该角色行 + 服务端按租户白名单校验。

2) UserUpsertReq.username 的 @NotBlank 对修改也生效
   PUT /users/{id} 永远返回 40001,且明细只说存在阻断性校验问题,
   看不出是登录名 —— 改账号功能整体不可用。去掉该注解,
   必填性改由 UserService.create 判定。

3) Main.vue 的 <transition appear>
   首次渲染被当成一次过渡,元素被加上 zoom-fade-enter-from(opacity:0)后
   永不清理 → DOM 内容齐全、接口数据正常、文字可选中,但整块内容区肉眼不可见。
   极易误判成页面没渲染而往路由/接口方向排查。去掉 appear。

4) assertAnotherAdminExists 在停用/删除路径上不可达
   能调这两个接口的人自己必然是活跃管理员,所以除目标外还有管理员恒成立;
   而真正会锁死租户的路径 —— 把管理员改成普通角色(目标可以是操作者自己)——
   当时没有拦。补 losingAdminRole() + UserMapper.countAdminRoles(),
   并在方法注释里写明哪条路径真生效、哪条只是防御性的。

5) sys_user.user_type 有两套互相冲突的枚举
   DDL/实体注释写「1教师/管理员 2填报人(学生) 3社会人员」,而权威契约
   (03-接口设计 §2.7)与 BuiltinRole.UserType 是
   「1平台运营 2学校管理员 3班主任 4学生」—— 2 和 3 完全错位。
   后果:userType=3(班主任,最高频的账号)被当成社会人员(二期)直接拒掉,
   建班主任账号会失败;前端还把学校管理员显示成填报人(学生)。
   改法:后端从角色反推用户类型再比对;新增 V3 迁移更正库注释
   (V1 已应用不能改,Flyway 会校验 checksum);更正 PRD 与 schema DDL 的同一行;
   前端「用户类型」从手选改为由角色推导,从根上消除矛盾组合;
   E2E 加 3 条回归断言锁住这个语义。

顺带修掉:
  · components/index.ts 里 6 个指向已删组件的悬空导出(构建不报错、用到才炸)
  · tsconfig 的 lib 停在 ES2020 而代码用了 Array.prototype.at
  · 两处 .at() 的空值风险(useBreakpoint / useTabsStore)
  · dashboard 的 userStore.menus 拼错,导致平台判断恒为 false
  · http.ts 里两个永不命中的错误码常量
  · 账号列表的 fixed 列缺 :scroll 配置,会浮在其它列上面
  · .gitignore 补 __pycache__

新增脚本:
  · scripts/e2e-m0-user.py            账号管理 55 项验收(含越权与自锁的负向断言)
  · scripts/dev-clean-test-users.py   清理 E2E 残留的测试账号
This commit is contained in:
2026-09-28 22:25:01 +08:00
parent 87ece5a971
commit 8480f431ba
52 changed files with 4264 additions and 122 deletions
+5
View File
@@ -40,6 +40,11 @@ out/
*.tmp
*.bak
# ── Python(scripts/ 下的验收与工具脚本)──
# 跑 python -m py_compile 或导入脚本时会生成字节码,不该入库
__pycache__/
*.py[cod]
# ── 上传/导出的本地测试数据 ───────────
/data/
/upload/
@@ -2,6 +2,9 @@ package cn.tianbaotong.fill.common.util;
import java.security.SecureRandom;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.exception.BizException;
/**
* 脱敏展示(《03-接口设计》§1.7)。
*
@@ -112,4 +115,27 @@ public final class MaskUtil {
private static char pick(String s) {
return s.charAt(RANDOM.nextInt(s.length()));
}
/**
* 校验密码强度,不合规直接抛业务异常。
*
* <p>规则:8~32 位,且<b>字母与数字都要有</b>。
* 不强制大小写与符号——那两类要求实测只会把人逼去写
* {@code Abc123!} 这种既难记又不安全的密码,然后贴到便签上。
* 真正该堵的是「123456」「姓名拼音」这类,所以最低要求设在这里。
*
* <p>放在本类是因为初始密码的生成也在这里({@link #generateInitialPassword}):
* 生成的规则与校验的规则必须满足同一个口径,分开写迟早会出现
* "系统生成的初始密码被自己的校验规则拒掉"。
*/
public static void assertPasswordStrong(String plain) {
if (plain == null || plain.length() < 8 || plain.length() > 32) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "密码长度需为 8~32 位");
}
boolean hasLetter = plain.chars().anyMatch(Character::isLetter);
boolean hasDigit = plain.chars().anyMatch(Character::isDigit);
if (!hasLetter || !hasDigit) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "密码需同时包含字母和数字");
}
}
}
@@ -2,6 +2,7 @@ package cn.tianbaotong.fill.module.auth.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.common.crypto.RsaCipher;
import cn.tianbaotong.fill.module.auth.dto.ChangePasswordReq;
import cn.tianbaotong.fill.module.auth.dto.LoginReq;
import cn.tianbaotong.fill.module.auth.dto.LoginVO;
import cn.tianbaotong.fill.module.auth.service.AuthService;
@@ -15,6 +16,7 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -73,4 +75,12 @@ public class AuthController {
authService.logout(token == null ? null : String.valueOf(token));
return ApiResult.ok();
}
@PutMapping("/password")
@Operation(summary = "修改自己的密码",
description = "原密码与新密码均为 RSA 密文。已知缺口:改密后此前签发的 token 仍有效至自然过期")
public ApiResult<Void> changePassword(@Valid @RequestBody ChangePasswordReq req) {
authService.changePassword(req);
return ApiResult.ok();
}
}
@@ -0,0 +1,33 @@
package cn.tianbaotong.fill.module.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;
/**
* 修改自己的密码(《03-接口设计》§5.9)。
*
* <p>⚠️ {@code oldPassword} 在实现上是<b>必填</b>的,与文档里
* 「强制改密场景 {@code oldPassword} 可空」不同。原因:
* 「可空」的前提是系统知道自己处在"必须改密"状态(需要一个
* {@code must_change_password} 标记),而 {@code sys_user} 目前没有这个列。
* 在没有该标记的情况下放行"不填原密码也能改",等于<b>任何拿到有效 token 的人
* 都能直接改掉密码、把原主彻底挤出去</b>——从"暂时被盗用"升级成"永久失去账号"。
* 所以要等二期补上 {@code must_change_password} 后再开放这个分支。
*/
@Getter
@Setter
@Schema(description = "修改自己的密码")
public class ChangePasswordReq {
@NotBlank(message = "请填写原密码")
@Schema(description = "原密码(RSA 加密后传输,与登录同一套公钥)",
requiredMode = Schema.RequiredMode.REQUIRED)
private String oldPassword;
@NotBlank(message = "请填写新密码")
@Schema(description = "新密码(RSA 加密后传输),8~32 位且至少含字母与数字",
requiredMode = Schema.RequiredMode.REQUIRED)
private String newPassword;
}
@@ -5,6 +5,8 @@ import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.crypto.RsaCipher;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.common.util.MaskUtil;
import cn.tianbaotong.fill.module.auth.dto.ChangePasswordReq;
import cn.tianbaotong.fill.module.auth.dto.LoginReq;
import cn.tianbaotong.fill.module.auth.dto.LoginVO;
import cn.tianbaotong.fill.module.log.entity.SysLoginLog;
@@ -181,6 +183,70 @@ public class AuthService {
return vo;
}
// ── 修改自己的密码 ─────────────────────────────
/**
* 修改自己的密码(§5.9)。
*
* <p><b>必须校验原密码</b>:文档写的是「强制改密场景 {@code oldPassword} 可空」,
* 但那需要一个 {@code must_change_password} 标记来界定"是否处在强制改密态",
* 而 {@code sys_user} 目前没有这个列。在没有该标记的前提下放行"不填原密码",
* 等于任何拿到有效 token 的人都能改掉密码、把原主永久挤出去。
* 所以要等二期补上该标记后再开放这个分支,现在一律校验。
*
* <p><b>已知缺口(需要后续补)</b>:改密成功后,<b>该账号此前签发的 token 仍然有效</b>,
* 直到自然过期。也就是说"发现密码泄露 → 立刻改密"并不能立刻踢掉已经登进来的人。
* 要做到这点,需要让 JWT 带上签发时间并在校验时与"最近一次改密时间"比较
* (或在 Redis 里记一个 per-user 的失效时间戳)。本次未实现,是因为它涉及
* {@code JwtService} 与 {@code AuthInterceptor} 的改动,不适合混在账号管理这一刀里做。
*/
@Transactional(rollbackFor = Exception.class)
public void changePassword(ChangePasswordReq req) {
Long uid = UserContext.uid();
SysUser user = userMapper.selectById(uid);
if (user == null) {
throw new BizException(ErrorCode.UNAUTHORIZED);
}
String oldPlain = decryptOrNull(req.getOldPassword());
if (oldPlain == null || user.getPassword() == null
|| !passwordEncoder.matches(oldPlain, user.getPassword())) {
throw new BizException(ErrorCode.BAD_CREDENTIALS, "原密码不正确");
}
String newPlain = decryptOrNull(req.getNewPassword());
if (newPlain == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "密码解密失败,请刷新页面后重试");
}
if (newPlain.equals(oldPlain)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "新密码不能与原密码相同");
}
MaskUtil.assertPasswordStrong(newPlain);
SysUser patch = new SysUser();
patch.setId(uid);
patch.setPassword(passwordEncoder.encode(newPlain));
userMapper.updateById(patch);
// 顺手解锁:能登录进来改密说明没被锁,但改密前可能刚试错过几次
if (user.getUsername() != null && !user.getUsername().isBlank()) {
loginGuard.clear(user.getUsername());
}
log.info("账号 {} 修改了自己的密码", user.getUsername());
}
/** 解密前端传来的 RSA 密文;密文非法(未加密/公钥不匹配/被截断)时返回 null。 */
private String decryptOrNull(String cipherText) {
if (cipherText == null || cipherText.isBlank()) {
return null;
}
try {
return rsaCipher.decrypt(cipherText);
} catch (IllegalArgumentException e) {
return null;
}
}
// ── 登出 ──────────────────────────────────────
public void logout(String token) {
@@ -0,0 +1,112 @@
package cn.tianbaotong.fill.module.user.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.common.api.PageResult;
import cn.tianbaotong.fill.module.user.dto.ResetPasswordReq;
import cn.tianbaotong.fill.module.user.dto.UserImportResult;
import cn.tianbaotong.fill.module.user.dto.UserUpsertReq;
import cn.tianbaotong.fill.module.user.dto.UserVO;
import cn.tianbaotong.fill.module.user.service.UserService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 账号管理(《03-接口设计》§5.6 ~ §5.9)。
*
* <p>列表用 {@code user:view}、写操作用 {@code user:manage},与
* {@code PermissionDef} 里的两个权限码对应 —— 学校里有相当多的场景是
* "让教务处能看账号但别改",按钮级权限才有意义,所以读写分开。
*/
@Tag(name = "账号管理")
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
@RequirePerm(PermissionDef.USER_VIEW)
@Operation(summary = "账号列表",
description = "keyword 支持姓名/账号模糊;完整手机号走加密索引等值匹配(模糊搜不了,见 UserService)")
public ApiResult<PageResult<UserVO>> list(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long orgId,
@RequestParam(required = false) String roleCode,
@RequestParam(required = false) Integer userType,
@RequestParam(required = false) Integer status,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
return ApiResult.ok(userService.list(keyword, orgId, roleCode, userType, status, pageNum, pageSize));
}
@PostMapping
@RequirePerm(PermissionDef.USER_MANAGE)
@Operation(summary = "新增账号",
description = "初始密码由服务端生成并仅此一次返回;角色可选范围与当前租户有关")
public ApiResult<UserService.CreateResult> create(@Valid @RequestBody UserUpsertReq req) {
return ApiResult.ok(userService.create(req));
}
@PutMapping("/{id}")
@RequirePerm(PermissionDef.USER_MANAGE)
@Operation(summary = "修改账号", description = "登录名与密码不在此接口修改")
public ApiResult<Void> update(@PathVariable Long id, @Valid @RequestBody UserUpsertReq req) {
userService.update(id, req);
return ApiResult.ok();
}
@DeleteMapping("/{id}")
@RequirePerm(PermissionDef.USER_MANAGE)
@Operation(summary = "停用账号(软删)",
description = "有在办任务时返回 40014;不能停用自己或本单位最后一个管理员")
public ApiResult<Void> delete(@PathVariable Long id) {
userService.delete(id);
return ApiResult.ok();
}
@PutMapping("/{id}/status")
@RequirePerm(PermissionDef.USER_MANAGE)
@Operation(summary = "启用 / 停用账号",
description = "只改 status,数据保留可再启用。与 DELETE 的软删不是一回事")
public ApiResult<Void> updateStatus(@PathVariable Long id,
@RequestParam Integer status) {
userService.updateStatus(id, status);
return ApiResult.ok();
}
@PostMapping("/{id}/reset-password")
@RequirePerm(PermissionDef.USER_MANAGE)
@Operation(summary = "重置密码",
description = "mode=GENERATE 由服务端生成。会同时解除该账号的登录锁定")
public ApiResult<UserService.ResetResult> resetPassword(@PathVariable Long id,
@RequestBody(required = false) ResetPasswordReq req) {
return ApiResult.ok(userService.resetPassword(id, req == null ? new ResetPasswordReq() : req));
}
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.USER_MANAGE)
@Operation(summary = "批量导入账号",
description = "返回体检报告:识别到的表头行号 + 逐行失败明细 + 初始密码清单(仅此一次)")
public ApiResult<UserImportResult> importUsers(
@RequestPart("file") MultipartFile file,
@RequestParam(required = false) String defaultRoleCode) {
return ApiResult.ok(userService.importExcel(file, defaultRoleCode));
}
}
@@ -0,0 +1,32 @@
package cn.tianbaotong.fill.module.user.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
/**
* 重置密码(《03-接口设计》§5.9)。
*
* <p>两种模式:
* <ul>
* <li>{@code GENERATE} —— 服务端生成一个随机初始密码,管理员抄给本人</li>
* <li>{@code CUSTOM} —— 管理员自己指定</li>
* </ul>
* 默认 {@code GENERATE}:人手编的密码几乎必然是「姓名拼音 + 123」这种,
* 而这类密码在密码喷洒里是第一批被试的。
*/
@Getter
@Setter
@Schema(description = "重置密码")
public class ResetPasswordReq {
@Schema(description = "GENERATE 服务端生成 / CUSTOM 管理员指定,默认 GENERATE", example = "GENERATE")
private String mode;
@Schema(description = "mode=CUSTOM 时必填,8~32 位且至少含字母与数字", example = "Ab3#k9mP")
private String newPassword;
public boolean isCustom() {
return "CUSTOM".equalsIgnoreCase(mode == null ? "" : mode.trim());
}
}
@@ -0,0 +1,126 @@
package cn.tianbaotong.fill.module.user.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/**
* 批量导入的「体检报告」(《03-接口设计》§5.8)。
*
* <p><b>为什么导入接口一律返回这个结构</b>:实测的真实 Excel 里,
* 表头行一份在第 1 行、另一份在第 6 行;列名也有多种写法。
* 如果导入只回一个「成功 40 条」,用户根本不知道剩下 2 条为什么没进去,
* 也看不到系统把哪一行当成了表头——错了也没法纠正。
*
* <p>所以:<b>逐行的失败明细</b>(行号 + 字段 + 原值 + 原因)与
* <b>识别到的表头行号</b>都必须回传,让用户在界面上确认。
*/
@Getter
@Setter
@Schema(description = "账号批量导入体检报告")
public class UserImportResult {
private int total;
private int success;
private int failed;
/** 服务端自动定位到的表头所在行号(从 1 开始)。 */
private int headerRow;
/**
* 表头那一行的<b>原文</b>(用 {@code |} 连接)。
*
* <p>只回一个行号是不够的 —— 用户没法据此判断"系统认对了吗"。
* 把原文一起回传,界面就能显示「第 6 行:姓名 | 账号 | 手机号 | 所属班级」,
* 让用户一眼确认,而不是导入完了才发现整列错位。
*/
private String headerText;
/** 识别到的列映射:哪个字段落在哪一列。 */
private List<ColumnMap> columns = new ArrayList<>();
/** 逐行失败明细。 */
private List<RowError> errors = new ArrayList<>();
/**
* 新账号的初始密码。
*
* <p>⚠️ <b>仅此一次返回</b>。库里存的是 BCrypt 哈希,之后任何人(包括 DBA)
* 都无法还原,忘了只能重新重置。
*/
private List<InitialPassword> initialPasswords = new ArrayList<>();
/** 识别到的列映射:字段 → Excel 列字母。 */
@Getter
@Setter
@Schema(description = "识别到的列映射")
public static class ColumnMap {
/** 字段名,如 {@code realName}。 */
private String field;
/** 字段中文名,如「姓名」。 */
private String fieldName;
/** Excel 列字母,如 {@code A}。 */
private String column;
public ColumnMap() {
}
public ColumnMap(String field, String fieldName, String column) {
this.field = field;
this.fieldName = fieldName;
this.column = column;
}
}
/** 一行导入失败的原因。 */
@Getter
@Setter
@Schema(description = "导入失败明细行")
public static class RowError {
/** Excel 里的行号(含表头,从 1 开始),与用户看到的行号一致。 */
private int row;
private String field;
private String value;
private String reason;
public RowError() {
}
public RowError(int row, String field, String value, String reason) {
this.row = row;
this.field = field;
this.value = value;
this.reason = reason;
}
}
/** 账号 → 初始密码。 */
@Getter
@Setter
@Schema(description = "初始密码(仅此一次返回)")
public static class InitialPassword {
private String username;
private String password;
public InitialPassword() {
}
public InitialPassword(String username, String password) {
this.username = username;
this.password = password;
}
}
}
@@ -0,0 +1,80 @@
package cn.tianbaotong.fill.module.user.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 账号新增 / 修改请求(《03-接口设计》§5.7 / §5.9)。
*
* <p>{@code username} 只在新增时使用;修改接口<b>忽略</b>它(§5.9「{@code username} 不可改」)。
* 登录名一改,用户手里的通知、导出的历史包、别人抄下来的账号就全对不上了,
* 而且没有什么收益——需要换登录名时停用旧的、建新的更清楚。
*/
@Getter
@Setter
@Schema(description = "账号新增或修改")
public class UserUpsertReq {
/**
* 登录账号。
*
* <p>⚠️ 这里<b>刻意没有 {@code @NotBlank}</b>。本 DTO 同时用于新增与修改,
* 而修改不允许改登录名(§5.9)。如果加必填注解,{@code PUT /users/{id}}
* 会因为「没传一个用不上的字段」而整体校验失败 —— 表现为<b>修改接口永远返回 40001</b>,
* 且错误信息只有一句"存在阻断性校验问题",从字面完全看不出是登录名的问题。
*
* <p>“新增时必填”由 {@code UserService.create} 判定。
* {@code @Pattern} 对 {@code null} 是通过的(不会与"修改时不传"冲突),
* 只有真传了值才校验格式。
*/
@Size(max = 64, message = "登录账号过长")
@Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.-]{1,63}$",
message = "登录账号只能以字母开头,可含字母、数字、下划线、点或短横线")
@Schema(description = "登录账号,租户内唯一;修改时忽略", example = "zhangsan")
private String username;
@NotBlank(message = "请填写姓名")
@Size(max = 64, message = "姓名过长")
@Schema(description = "姓名", example = "张三", requiredMode = Schema.RequiredMode.REQUIRED)
private String realName;
@NotBlank(message = "请填写手机号")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
@Schema(description = "手机号(服务端 AES 加密存储,列表只回脱敏值)", example = "13800138888",
requiredMode = Schema.RequiredMode.REQUIRED)
private String phone;
@Schema(description = "所属组织 ID")
private Long orgId;
@NotEmpty(message = "请至少选择一个角色")
@Schema(description = "角色码,至少 1 个。可选值与当前租户有关,见 UserService 的校验",
requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> roleCodes;
@NotNull(message = "请选择用户类型")
@Min(value = 1, message = "用户类型不合法")
@Max(value = 4, message = "用户类型不合法")
@Schema(description = "1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生(§2.7,必须与所选角色一致)",
example = "3", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer userType;
@Schema(description = "0 停用 / 1 正常 / 2 待激活,默认 1")
private Integer status;
/** 预留给后续的邮箱字段(当前库里没有该列,仅占位避免前端反复问)。 */
@Email(message = "邮箱格式不正确")
@Schema(description = "邮箱(当前版本未落库,保留字段)", deprecated = true)
private String email;
}
@@ -0,0 +1,53 @@
package cn.tianbaotong.fill.module.user.dto;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
/**
* 账号视图(《03-接口设计》§5.6)。
*
* <p><b>为什么不返回手机号明文</b>:§1.7 的硬规则是「身份证号与手机号一律不返回明文,
* 只返回脱敏值」。列表是一次拉 20~200 条的接口,一旦返回明文就等于给了一个
* 批量导出联系方式的通道。审核场景确实要看完整号码时走单独接口并记审计日志。
*/
@Getter
@Setter
public class UserVO {
private Long id;
/** 登录名。学生的可能为空(走微信登录)。 */
private String username;
private String realName;
/** 脱敏手机号,形如 {@code 138****8888}。 */
private String phoneMasked;
private Long orgId;
/** 组织名称,由服务端一次装配(避免前端逐个查)。 */
private String orgName;
/**
* 用户类型:{@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生
* (《03-接口设计》§2.7)。
*/
private Integer userType;
/** {@code 0} 停用 / {@code 1} 正常 / {@code 2} 待激活。 */
private Integer status;
/** 角色码,可多角色。 */
private List<String> roleCodes;
/** 角色中文名,与 {@link #roleCodes} 一一对应。 */
private List<String> roleNames;
private LocalDateTime lastLoginTime;
private LocalDateTime createTime;
}
@@ -53,7 +53,16 @@ public class SysUser extends TenantEntity {
private String avatarUrl;
/** {@code 1} 教师或管理员 / {@code 2} 填报人(学生)/ {@code 3} 社会人员。 */
/**
* 用户类型:{@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生。
*
* <p>口径来源是《03-接口设计》§2.7,与 {@code BuiltinRole.UserType} 一致。
*
* <p>⚠️ 库里该列的注释曾经写的是「1教师/管理员 2填报人(学生) 3社会人员」,
* 与上面这套<b>不一致</b>,已由 {@code V3__fix_user_type_comment.sql} 更正。
* 不要照旧注释或旧文档写代码 —— 这个不一致已经造成过一次真实误判
* (把 {@code 3} 当成"社会人员"而拒掉,而它其实是<b>班主任</b>)。
*/
private Integer userType;
/** {@code 0} 停用 / {@code 1} 正常 / {@code 2} 待激活。 */
@@ -54,4 +54,51 @@ public interface UserMapper extends BaseMapper<SysUser> {
@Select("SELECT * FROM sys_user WHERE tenant_id = #{tenantId} AND phone_hash = #{phoneHash} AND deleted = 0")
List<SysUser> selectByPhoneHash(@Param("tenantId") Long tenantId,
@Param("phoneHash") String phoneHash);
/**
* 本租户里<b>除某人之外</b>还有几个可用的管理员账号。
*
* <p><b>为什么需要</b>:停用/删除最后一个管理员 = 这个租户从此没人能登录、
* 没人能再建账号,只能由平台运营手工改库救回来。这类"系统被自己锁死"的
* 故障一旦发生,现场没有任何自助手段,所以必须在服务端拦住。
*
* <p><b>为什么显式传 tenantId 并关掉拦截器</b>:这条 SQL 是「越租户统计会得出错误结论」
* 的典型——如果把别校的管理员也算进来,判断就会偏向"还有人管",从而放行一次
* 会锁死本租户的停用操作。所以这里把租户条件写成显式参数,
* 让「哪个租户」只有一个来源,而不是"默认会加上"。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT COUNT(DISTINCT u.id)
FROM sys_user u
JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0 AND ur.tenant_id = u.tenant_id
JOIN sys_role r ON r.id = ur.role_id AND r.deleted = 0 AND r.tenant_id = u.tenant_id
WHERE u.deleted = 0
AND u.status = 1
AND u.tenant_id = #{tenantId}
AND u.id <> #{excludeUserId}
AND r.role_code IN ('PLATFORM_ADMIN', 'SCHOOL_ADMIN')
""")
Long countOtherAdmins(@Param("tenantId") Long tenantId,
@Param("excludeUserId") Long excludeUserId);
/**
* 某人当前是否绑着管理员角色。
*
* <p>用于判断一次「改角色」是否会让这个人失去管理员身份 ——
* 只有"本来是管理员、改完不是了"才需要做最后管理员的检查,
* 否则把一个普通班主任改成另一个普通角色也会被拦,那是误伤。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT COUNT(*)
FROM sys_user_role ur
JOIN sys_role r ON r.id = ur.role_id AND r.deleted = 0 AND r.tenant_id = ur.tenant_id
JOIN sys_user u ON u.id = ur.user_id AND u.deleted = 0 AND u.tenant_id = ur.tenant_id
WHERE ur.deleted = 0
AND ur.tenant_id = #{tenantId}
AND ur.user_id = #{userId}
AND r.role_code IN ('PLATFORM_ADMIN', 'SCHOOL_ADMIN')
""")
Long countAdminRoles(@Param("tenantId") Long tenantId, @Param("userId") Long userId);
}
@@ -0,0 +1,997 @@
package cn.tianbaotong.fill.module.user.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.api.PageResult;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.common.util.MaskUtil;
import cn.tianbaotong.fill.module.auth.service.LoginGuard;
import cn.tianbaotong.fill.module.org.entity.SysOrg;
import cn.tianbaotong.fill.module.org.mapper.OrgMapper;
import cn.tianbaotong.fill.module.user.dto.ResetPasswordReq;
import cn.tianbaotong.fill.module.user.dto.UserImportResult;
import cn.tianbaotong.fill.module.user.dto.UserUpsertReq;
import cn.tianbaotong.fill.module.user.dto.UserVO;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
import cn.tianbaotong.fill.module.user.mapper.UserRoleMapper;
import cn.tianbaotong.fill.security.BuiltinRole;
import cn.tianbaotong.fill.support.RoleProvisioner;
import cn.tianbaotong.fill.support.SensitiveCodec;
import com.alibaba.excel.EasyExcel;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 账号管理服务(《03-接口设计》§5.6 ~ §5.9)。
*
* <p><b>本类里有三处不是"字段校验"、而是"防止系统被用坏"的护栏</b>,
* 改动时请当成硬约束而不是可协商的规则:
* <ol>
* <li>{@link #resolveAssignableRoles} —— 拦住「学校管理员给自己人分配平台运营角色」的
* 权限提升。不拦的后果是任何学校管理员都能开通租户、看全平台租户列表。</li>
* <li>{@link #assertNotSelf} —— 不能停用/删除当前登录的账号。不拦的后果是
* 管理员把自己关在门外,且没有自助手段恢复。</li>
* <li>{@link #assertAnotherAdminExists} —— 不能取消本单位最后一个管理员。
* 生效点是<b>改角色</b>(把自己降级成普通角色),不是停用/删除 —— 原因见方法注释。</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {
private static final int MAX_PAGE_SIZE = 200;
private static final int MAX_IMPORT_ROWS = 2000;
private static final long MAX_IMPORT_BYTES = 10L * 1024 * 1024;
/** 登录名规则,与 {@code UserUpsertReq.username} 上的注解一致。 */
private static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z][A-Za-z0-9_.-]{1,63}$");
private static final Pattern PHONE_RE = Pattern.compile("^1[3-9]\\d{9}$");
/** 学校租户可分配的角色。 */
private static final Set<String> SCHOOL_ASSIGNABLE = Set.of(
"SCHOOL_ADMIN", "HEAD_TEACHER", "DEPUTY_TEACHER", "AUDITOR", "STUDENT");
/** 平台租户可分配的角色。 */
private static final Set<String> PLATFORM_ASSIGNABLE = Set.of("PLATFORM_ADMIN");
/** 拥有 {@code user:manage} 的角色。判断"还有没有人能管账号"只需看这两个。 */
private static final Set<String> ADMIN_ROLE_CODES = Set.of("PLATFORM_ADMIN", "SCHOOL_ADMIN");
/**
* 角色 → 用户类型(《03-接口设计》§2.7,与 {@code BuiltinRole.UserType} 一致)。
*
* <p>⚠️ <b>不要照 {@code sys_user} 那行 DDL 注释写</b>。那行注释写的是
* 「1教师/管理员 2填报人(学生) 3社会人员」,与接口契约的
* 「1平台运营 2学校管理员 3班主任 4学生」<b>不一致</b>。
* 权威的是接口契约 + {@code BuiltinRole.UserType}(两者一致),DDL 注释是错的
* (已在 V3 迁移里更正)。
*
* <p>这个不一致已经造成过一次真实误判:把 {@code 3} 当成"社会人员(二期)"而拒掉,
* 而 {@code 3} 恰恰是<b>班主任</b> —— 最高频的一种账号。
*/
private static final Map<String, Integer> ROLE_TO_USER_TYPE = Map.of(
"PLATFORM_ADMIN", 1,
"SCHOOL_ADMIN", 2,
"AUDITOR", 2,
"HEAD_TEACHER", 3,
"DEPUTY_TEACHER", 3,
"STUDENT", 4);
/** 用户类型 → 中文名(用于错误提示)。与 {@link #ROLE_TO_USER_TYPE} 同一套口径。 */
private static final Map<Integer, String> USER_TYPE_NAMES = Map.of(
1, "平台运营",
2, "学校管理员",
3, "班主任",
4, "学生");
/**
* 导入文件的表头别名。
*
* <p>实测的真实 Excel 列名写法并不统一(「手机号 / 手机 / 联系电话」都出现过),
* 表头所在行也不固定(一份在第 1 行、一份在第 6 行)。所以这里不假设表头位置,
* 也不假设列序,而是**按列名认列**,并把识别到的行号回传给界面让用户确认。
*/
private static final Map<String, Set<String>> HEADER_ALIASES = new LinkedHashMap<>();
/** 字段 → 中文名,用于导入报告里的列映射展示。 */
private static final Map<String, String> IMPORT_FIELD_NAMES = Map.of(
"realName", "姓名",
"username", "账号",
"phone", "手机号",
"org", "组织",
"role", "角色");
static {
HEADER_ALIASES.put("realName", Set.of("姓名", "名字", "真实姓名", "教师姓名", "学生姓名"));
HEADER_ALIASES.put("username", Set.of("账号", "登录账号", "用户名", "登录名", "工号", "教工号"));
HEADER_ALIASES.put("phone", Set.of("手机号", "手机号码", "手机", "联系电话", "电话", "联系方式"));
HEADER_ALIASES.put("org", Set.of("组织", "部门", "班级", "所属组织", "所属班级", "所在班级"));
HEADER_ALIASES.put("role", Set.of("角色", "岗位", "身份"));
}
private final UserMapper userMapper;
private final UserRoleMapper userRoleMapper;
private final OrgMapper orgMapper;
private final RoleProvisioner roleProvisioner;
private final SensitiveCodec codec;
private final PasswordEncoder passwordEncoder;
private final LoginGuard loginGuard;
// ── 列表 ──────────────────────────────────────
/**
* 账号分页列表(§5.6)。
*
* <p><b>关于按手机号搜索</b>:手机号是 AES-GCM 加密存储的(每次密文都不同,
* 且不含原文片段),所以 {@code LIKE '%138%'} 这类<b>模糊</b>搜索在原理上就做不到。
* 这里对<b>完整手机号</b>走 {@code phone_hash} 等值匹配——准确、且不牺牲加密。
* 代价是"只记得中间几位"时搜不出来,这是加密换来的必然取舍,不是遗漏。
*/
public PageResult<UserVO> list(String keyword, Long orgId, String roleCode, Integer userType,
Integer status, int pageNum, int pageSize) {
int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
int num = Math.max(pageNum, 1);
// 先归一化再声明为 final —— 下面的 lambda 要捕获它,
// 直接复用可变的局部变量编译不过(而且"捕获到后来又改过"本身就是 bug 温床)
String raw = keyword == null ? null : keyword.trim();
final String kw = (raw == null || raw.isEmpty()) ? null : raw;
LambdaQueryWrapper<SysUser> q = Wrappers.lambdaQuery();
q.eq(orgId != null, SysUser::getOrgId, orgId)
.eq(userType != null, SysUser::getUserType, userType)
.eq(status != null, SysUser::getStatus, status);
if (kw != null) {
if (PHONE_RE.matcher(kw).matches()) {
String phoneHash = codec.hash(kw);
q.and(w -> w.like(SysUser::getRealName, kw)
.or().like(SysUser::getUsername, kw)
.or().eq(SysUser::getPhoneHash, phoneHash));
} else {
q.and(w -> w.like(SysUser::getRealName, kw)
.or().like(SysUser::getUsername, kw));
}
}
if (roleCode != null && BuiltinRole.ofCode(roleCode) != null) {
// roleCode 已经过内置角色白名单校验,拼串是安全的。
// 用子查询而不是 JOIN,是为了让 MP 的租户过滤与逻辑删除条件继续作用于主表。
q.inSql(SysUser::getId,
"SELECT ur.user_id FROM sys_user_role ur"
+ " JOIN sys_role r ON r.id = ur.role_id"
+ " WHERE ur.deleted = 0 AND r.deleted = 0"
+ " AND r.role_code = '" + roleCode + "'");
}
q.orderByDesc(SysUser::getId);
IPage<SysUser> page = userMapper.selectPage(new Page<>(num, size), q);
List<SysUser> rows = page.getRecords();
return PageResult.of(toVoList(rows), page.getTotal(), num, size);
}
// ── 新增 ──────────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public CreateResult create(UserUpsertReq req) {
long tenantId = UserContext.tenantId();
// 登录名必填只在新增时成立 —— 所以不在 DTO 上用 @NotBlank
// (那样会让 PUT /users/{id} 也要求它),见 UserUpsertReq.username 的说明
if (req.getUsername() == null || req.getUsername().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写登录账号");
}
String username = req.getUsername().trim();
assertUsernameUnique(username, null);
List<BuiltinRole> roles = resolveAssignableRoles(req.getRoleCodes());
assertRoleMatchesUserType(req.getUserType(), roles);
String initialPassword = MaskUtil.generateInitialPassword();
SysUser u = new SysUser();
u.setUsername(username);
u.setRealName(req.getRealName().trim());
u.setPassword(passwordEncoder.encode(initialPassword));
u.setOrgId(req.getOrgId());
u.setUserType(req.getUserType());
u.setStatus(req.getStatus() == null ? 1 : req.getStatus());
codec.applyPhone(u, req.getPhone());
userMapper.insert(u);
for (BuiltinRole br : roles) {
roleProvisioner.bindRole(tenantId, u.getId(), br);
}
log.info("新增账号 {}(id={}, 角色={})", username, u.getId(), roles.stream()
.map(BuiltinRole::getCode).toList());
return new CreateResult(u.getId(), initialPassword);
}
// ── 修改 ──────────────────────────────────────
/**
* 修改账号(§5.9)。
*
* <p>{@code username} 与 {@code password} <b>不在这里改</b>:
* 前者改了会让历史通知、导出包里的账号全对不上;后者走单独的重置密码接口,
* 免得"编辑资料"顺手把密码也换了却没人知道。
*/
@Transactional(rollbackFor = Exception.class)
public void update(Long id, UserUpsertReq req) {
SysUser exist = requireUser(id);
long tenantId = UserContext.tenantId();
List<BuiltinRole> roles = resolveAssignableRoles(req.getRoleCodes());
assertRoleMatchesUserType(req.getUserType(), roles);
// 停用会被改成停用时,先过一遍"别把自己/最后一个管理员锁死"
boolean turningOff = req.getStatus() != null && req.getStatus() == 0
&& (exist.getStatus() == null || exist.getStatus() != 0);
if (turningOff) {
assertNotSelf(id, "停用");
assertAnotherAdminExists(exist, "停用");
}
// 「把管理员改成普通角色」是锁死租户的另一条路,而且比停用更隐蔽:
// 账号还在、还能登录,但再也进不了账号管理与角色授权,界面上看不出问题。
//
// ⚠️ 这条**必须**单独判,不能指望上面那条覆盖它:
// 能调到本接口的人自己必然是活跃管理员,所以在"停用"这条路径上
// 「除目标以外还有管理员」恒成立 —— 那次检查永远不触发,是死代码。
// 而改角色这条路径上,目标可以是操作者自己,那就真的会锁死。
if (losingAdminRole(exist, roles)) {
assertAnotherAdminExists(exist, "取消管理员角色");
}
SysUser patch = new SysUser();
patch.setId(id);
patch.setRealName(req.getRealName().trim());
patch.setOrgId(req.getOrgId());
patch.setUserType(req.getUserType());
if (req.getStatus() != null) {
patch.setStatus(req.getStatus());
}
codec.applyPhone(patch, req.getPhone());
userMapper.updateById(patch);
rebindRoles(tenantId, id, roles);
}
// ── 停用(软删)──────────────────────────────
/**
* 停用账号(§5.9)。
*
* <p>按契约这是<b>软删</b>({@code deleted=1})。软删之后这条记录对本租户就"消失"了,
* 界面上的「启用」走 {@link #updateStatus}(改 {@code status}),两者不是一回事:
* <ul>
* <li>改 {@code status=0} —— 账号还在列表里,可以再启用。日常「某人离职」用这个。</li>
* <li>软删 —— 从列表里消失,用于建错了、重复导入这类要清理的场景。</li>
* </ul>
*/
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
SysUser exist = requireUser(id);
assertNotSelf(id, "停用");
assertAnotherAdminExists(exist, "停用");
if (hasActiveTask(id)) {
throw new BizException(ErrorCode.USER_HAS_ACTIVE_TASK,
"该账号还负责着在办的收集任务,请先把任务移交他人");
}
userMapper.deleteById(id);
// 角色关联一并软删,否则重新建同名账号时会继承上一个人的角色
userRoleMapper.delete(Wrappers.<cn.tianbaotong.fill.module.user.entity.SysUserRole>lambdaQuery()
.eq(cn.tianbaotong.fill.module.user.entity.SysUserRole::getUserId, id));
}
// ── 启用 / 停用(改 status,不删数据)──────────
@Transactional(rollbackFor = Exception.class)
public void updateStatus(Long id, Integer status) {
SysUser exist = requireUser(id);
if (status == null || (status != 0 && status != 1)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "状态只能是 0 停用或 1 正常");
}
if (status == 0) {
assertNotSelf(id, "停用");
assertAnotherAdminExists(exist, "停用");
}
SysUser patch = new SysUser();
patch.setId(id);
patch.setStatus(status);
userMapper.updateById(patch);
}
// ── 重置密码 ──────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public ResetResult resetPassword(Long id, ResetPasswordReq req) {
SysUser target = requireUser(id);
String plain;
if (req.isCustom()) {
plain = req.getNewPassword();
assertPasswordStrength(plain);
} else {
plain = MaskUtil.generateInitialPassword();
}
SysUser patch = new SysUser();
patch.setId(id);
patch.setPassword(passwordEncoder.encode(plain));
userMapper.updateById(patch);
// 重置密码的真实场景基本都是"本人忘了密码、反复试错把账号试锁了"。
// 不在这里解锁的话会出现「管理员刚重置完,本人还是登不进去」,
// 而且两边都看不出原因 —— 锁定是按账号计的,跟密码对不对没关系。
if (target.getUsername() != null && !target.getUsername().isBlank()) {
loginGuard.clear(target.getUsername());
}
log.info("重置账号 {}(id={})的密码", target.getUsername(), id);
return new ResetResult(target.getUsername(), plain);
}
// ── 批量导入(§5.8)────────────────────────────
/**
* 批量导入账号,返回「体检报告」。
*
* <p>部分成功是<b>设计目标</b>:42 行里有 2 行手机号写错,不应该让另外 40 个人白等,
* 但也不能悄悄丢掉那 2 行。所以整体不抛异常(能进的全进),
* 逐行把「行号 + 字段 + 原值 + 原因」回传,由界面上的人去修。
*/
@Transactional(rollbackFor = Exception.class)
public UserImportResult importExcel(MultipartFile file, String defaultRoleCode) {
assertImportFile(file);
BuiltinRole fallbackRole = resolveFallbackRole(defaultRoleCode);
List<Map<Integer, String>> rows;
try (InputStream in = file.getInputStream()) {
// headRowNumber(0):不把第 1 行当表头,先整表读进来,表头行由我们自己找。
// 实测两份真实文件的表头分别在第 1 行和第 6 行,"默认第 1 行"是行不通的。
rows = EasyExcel.read(in).sheet(0).headRowNumber(0).doReadSync();
} catch (Exception e) {
log.warn("读取导入文件失败:{}", e.getMessage());
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND,
"无法读取该 Excel,请用 Excel 另存为 .xlsx 后重试");
}
if (rows == null || rows.isEmpty()) {
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND, "文件里没有读到任何内容");
}
int headerIdx = detectHeaderRow(rows);
if (headerIdx < 0) {
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND,
"没能找到表头行,请确认表格里有「姓名」和「账号」这两列");
}
Map<Integer, String> headerCells = rows.get(headerIdx);
Map<String, Integer> colOf = mapColumns(headerCells);
int headerRow = headerIdx + 1;
UserImportResult result = new UserImportResult();
result.setHeaderRow(headerRow);
// 行号 + 原文 + 列映射三样一起回,用户才能真的核对"系统认对了吗"。
// 只回行号的话,界面上只能显示"表头在第 6 行",而用户无法判断
// 第 6 行被认成了什么、有没有整列错位。
result.setHeaderText(joinHeaderText(headerCells));
result.setColumns(buildColumnMap(colOf));
// 预加载组织与已有账号,避免逐行查库(2000 行 × 2 次查询是不可接受的)
Map<String, Long> orgByName = new HashMap<>();
Map<String, Long> orgByCode = new HashMap<>();
for (SysOrg o : orgMapper.selectList(Wrappers.<SysOrg>lambdaQuery())) {
if (o.getOrgName() != null) {
orgByName.putIfAbsent(o.getOrgName().trim(), o.getId());
}
if (o.getOrgCode() != null && !o.getOrgCode().isBlank()) {
orgByCode.putIfAbsent(o.getOrgCode().trim(), o.getId());
}
}
List<String> dataNames = new ArrayList<>();
for (int i = headerIdx + 1; i < rows.size() && i - headerIdx <= MAX_IMPORT_ROWS; i++) {
String uname = cell(rows.get(i), colOf.get("username"));
if (uname != null && !uname.isBlank()) {
dataNames.add(uname.trim());
}
}
Set<String> existingNames = new LinkedHashSet<>();
if (!dataNames.isEmpty()) {
for (SysUser u : userMapper.selectList(Wrappers.<SysUser>lambdaQuery()
.in(SysUser::getUsername, dataNames))) {
existingNames.add(u.getUsername());
}
}
long tenantId = UserContext.tenantId();
Set<String> seenInFile = new LinkedHashSet<>();
for (int i = headerIdx + 1; i < rows.size(); i++) {
Map<Integer, String> row = rows.get(i);
int excelRow = i + 1; // Excel 的行号从 1 开始,与用户看到的一致
if (isBlankRow(row)) {
continue;
}
result.setTotal(result.getTotal() + 1);
String realName = cell(row, colOf.get("realName"));
String username = cell(row, colOf.get("username"));
String phone = normalizePhone(cell(row, colOf.get("phone")));
String orgText = cell(row, colOf.get("org"));
String roleText = cell(row, colOf.get("role"));
UserImportResult.RowError err =
validateImportRow(excelRow, realName, username, phone, seenInFile, existingNames);
if (err == null && orgText != null && !orgText.isBlank()) {
Long orgId = orgByName.get(orgText.trim());
if (orgId == null) {
orgId = orgByCode.get(orgText.trim());
}
if (orgId == null) {
err = new UserImportResult.RowError(excelRow, "org", orgText, "找不到该组织");
}
}
BuiltinRole role = fallbackRole;
if (err == null && roleText != null && !roleText.isBlank()) {
role = resolveRoleByText(roleText);
if (role == null) {
err = new UserImportResult.RowError(excelRow, "role", roleText, "角色不存在或不可分配");
}
}
// 用户类型从角色推导(二者一对一绑定)—— 导入文件里没有这一列,
// 也不该要求用户去填:填错就是整行白导。
Integer userType = role == null ? null : ROLE_TO_USER_TYPE.get(role.getCode());
if (err == null && userType == null) {
err = new UserImportResult.RowError(excelRow, "role", roleText,
"该角色没有对应的用户类型");
}
if (err != null) {
result.setFailed(result.getFailed() + 1);
result.getErrors().add(err);
continue;
}
String initialPassword = MaskUtil.generateInitialPassword();
SysUser u = new SysUser();
u.setUsername(username.trim());
u.setRealName(realName.trim());
u.setPassword(passwordEncoder.encode(initialPassword));
u.setUserType(userType);
u.setStatus(1);
codec.applyPhone(u, phone);
if (orgText != null && !orgText.isBlank()) {
Long orgId = orgByName.get(orgText.trim());
u.setOrgId(orgId != null ? orgId : orgByCode.get(orgText.trim()));
}
userMapper.insert(u);
roleProvisioner.bindRole(tenantId, u.getId(), role);
seenInFile.add(username.trim());
result.setSuccess(result.getSuccess() + 1);
result.getInitialPasswords()
.add(new UserImportResult.InitialPassword(username.trim(), initialPassword));
}
log.info("账号批量导入:共 {} 行,成功 {},失败 {},表头在第 {} 行",
result.getTotal(), result.getSuccess(), result.getFailed(), result.getHeaderRow());
return result;
}
// ── 护栏 ──────────────────────────────────────
/**
* 校验并解析可分配的角色。
*
* <p>⚠️ <b>这里是权限提升的防线,不是普通的字段校验。</b>
*
* <p>开通租户时 {@code RoleProvisioner} 会为租户发放全部内置角色,
* 其中包含 {@code PLATFORM_ADMIN}。而 {@code /tenants} 这类平台接口
* 只校验权限码 {@code tenant:manage},<b>不会</b>再确认调用者是否真的在平台租户下。
* 于是:如果这里不拦,学校管理员只要在新增账号时传
* {@code roleCodes: ["PLATFORM_ADMIN"]},就能造出一个带 {@code tenant:manage} 的账号,
* 进而开通租户、拉取全平台租户列表 —— 一个租户级管理员直接越权到平台级。
*
* <p>两道防线:本方法按"当前租户能分配什么角色"白名单校验;
* 同时 {@code RoleProvisioner} 不再给学校租户发放 {@code PLATFORM_ADMIN} 角色行。
* 任一道单独都够用,但都留着 —— 这类错误一旦发生是不可逆的信息泄露。
*/
private List<BuiltinRole> resolveAssignableRoles(List<String> codes) {
Long tenantId = UserContext.tenantId();
boolean platform = tenantId != null && tenantId == LoginUser.PLATFORM_TENANT_ID;
Set<String> allowed = platform ? PLATFORM_ASSIGNABLE : SCHOOL_ASSIGNABLE;
LinkedHashSet<String> distinct = new LinkedHashSet<>();
if (codes != null) {
for (String c : codes) {
if (c != null && !c.isBlank()) {
distinct.add(c.trim());
}
}
}
if (distinct.isEmpty()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请至少选择一个角色");
}
List<BuiltinRole> roles = new ArrayList<>();
for (String c : distinct) {
BuiltinRole br = BuiltinRole.ofCode(c);
if (br == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色不存在:" + c);
}
if (!allowed.contains(c)) {
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
}
roles.add(br);
}
return roles;
}
/**
* 用户类型必须与所选角色自洽。
*
* <p>做法不是"逐条 if",而是<b>从角色反推用户类型再比对</b>:
* 角色与用户类型本来就是一对一绑定的(见 {@link #ROLE_TO_USER_TYPE}),
* 反过来推只有一个正确答案,写成一堆 if 迟早会漏掉某个组合。
*
* <p>多角色时要求它们属于同一类(比如「班主任 + 副班主任」都是 3 可以,
* 「班主任 + 审核员」是 3 与 2 就不能共存)—— 否则"这个人到底是几"没有答案。
*/
private void assertRoleMatchesUserType(Integer userType, List<BuiltinRole> roles) {
if (userType == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择用户类型");
}
Set<Integer> expected = new LinkedHashSet<>();
for (BuiltinRole br : roles) {
Integer t = ROLE_TO_USER_TYPE.get(br.getCode());
if (t != null) {
expected.add(t);
}
}
if (expected.isEmpty()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "所选角色没有对应的用户类型");
}
if (expected.size() > 1) {
List<String> names = new ArrayList<>();
for (Integer t : expected) {
names.add(USER_TYPE_NAMES.getOrDefault(t, String.valueOf(t)));
}
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"所选角色分属不同的用户类型(" + String.join("、", names)
+ "),请只选同一类的角色");
}
int want = expected.iterator().next();
if (want != userType) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"用户类型与所选角色不符:这些角色对应的用户类型是「"
+ USER_TYPE_NAMES.getOrDefault(want, String.valueOf(want)) + "」");
}
}
private void assertUsernameUnique(String username, Long excludeId) {
SysUser dup = userMapper.selectOne(Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getUsername, username)
.ne(excludeId != null, SysUser::getId, excludeId)
.last("LIMIT 1"));
if (dup != null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "登录账号已存在:" + username);
}
}
private void assertNotSelf(Long id, String action) {
if (Objects.equals(id, UserContext.uid())) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"不能" + action + "当前登录的账号,请换一个管理员账号操作");
}
}
/**
* 「让这个人不再是管理员」之后,本单位是否还有别的可用管理员。
*
* <p><b>在哪些路径上会真正生效</b>(这一点很容易误判):
* <ul>
* <li>改角色({@link #update} 里把管理员改成普通角色)—— <b>会生效</b>。
* 因为目标可以是操作者自己,此时"除目标以外的管理员"就是 0。</li>
* <li>停用 / 软删 —— <b>实际上永远不触发</b>。能调这两个接口的人,
* 自己必然是一个活跃管理员({@code user:manage} 只来自
* {@code SCHOOL_ADMIN} / {@code PLATFORM_ADMIN}),
* 所以"除目标以外还有管理员"里至少能数到操作者自己,恒 ≥ 1。
* 更根本的原因是同一路径上已有 {@link #assertNotSelf}:不能停用自己。</li>
* </ul>
* 两份检查都留着 —— 权限模型一旦放宽(比如允许"审核员"停用账号),
* 停用路径上的这次检查立刻就有了意义。但<b>不要以为停用路径被它保护着</b>。
*/
private void assertAnotherAdminExists(SysUser target, String action) {
if (target.getStatus() != null && target.getStatus() != 1) {
// 本来就是停用状态,谈不上"从这个状态掉下去"
return;
}
Long other = userMapper.countOtherAdmins(target.getTenantId(), target.getId());
if (other == null || other == 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"「" + target.getRealName() + "」是本单位唯一的管理员,"
+ action + "后就没人能再管理本单位的账号了,请先另建一个管理员账号");
}
}
/**
* 这次改角色是否会让目标失去全部管理员角色。
*
* <p>只有在目标<b>原本确实是管理员</b>时才返回 true ——
* 否则把一个普通班主任换成另一个普通角色也会被上面那次检查拦住,那是误伤。
*/
private boolean losingAdminRole(SysUser target, List<BuiltinRole> newRoles) {
boolean stillAdmin = newRoles.stream()
.anyMatch(r -> ADMIN_ROLE_CODES.contains(r.getCode()));
if (stillAdmin) {
return false;
}
Long now = userMapper.countAdminRoles(target.getTenantId(), target.getId());
return now != null && now > 0;
}
private void assertPasswordStrength(String plain) {
cn.tianbaotong.fill.common.util.MaskUtil.assertPasswordStrong(plain);
}
/**
* 账号是否还负责着在办任务。
*
* <p>⚠️ <b>M0 阶段恒为 false —— 这条约束还没生效。</b>
* {@code collect_task} / {@code collect_task_org} 是 M1 的表,且当前没有
* 「任务负责人」字段,所以这里没有任何可查的依据。
* <b>M1 接入任务后必须补上实现</b>,否则会出现「把还在带班的班主任停用了,
* 任务卡在半路没人能审」这种线上问题,而错误码 {@code 40014} 与界面提示
* 都已经按"会拦住"写好了 —— 那会让人以为拦住了。
*/
private boolean hasActiveTask(Long userId) {
return false;
}
private SysUser requireUser(Long id) {
SysUser u = userMapper.selectById(id);
if (u == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "账号不存在或已被停用");
}
return u;
}
private void rebindRoles(long tenantId, Long userId, List<BuiltinRole> roles) {
userRoleMapper.delete(Wrappers.<cn.tianbaotong.fill.module.user.entity.SysUserRole>lambdaQuery()
.eq(cn.tianbaotong.fill.module.user.entity.SysUserRole::getUserId, userId));
for (BuiltinRole br : roles) {
roleProvisioner.bindRole(tenantId, userId, br);
}
}
// ── 导入的内部工具 ─────────────────────────────
private void assertImportFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择要导入的 Excel 文件");
}
if (file.getSize() > MAX_IMPORT_BYTES) {
throw new BizException(ErrorCode.FILE_TOO_LARGE,
"文件超过 10MB,请拆分后分批导入");
}
String name = file.getOriginalFilename();
String lower = name == null ? "" : name.toLowerCase();
if (!lower.endsWith(".xlsx") && !lower.endsWith(".xls")) {
throw new BizException(ErrorCode.FILE_FORMAT_NOT_ALLOWED, "只支持 .xlsx / .xls 文件");
}
}
private BuiltinRole resolveFallbackRole(String defaultRoleCode) {
BuiltinRole br = defaultRoleCode == null || defaultRoleCode.isBlank()
? BuiltinRole.HEAD_TEACHER
: BuiltinRole.ofCode(defaultRoleCode.trim());
if (br == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "默认角色不存在:" + defaultRoleCode);
}
if (resolveAssignableRoles(List.of(br.getCode())).isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
}
return br;
}
/**
* 找表头行。
*
* <p>打分而不是"找第 1 行":取命中别名最多的一行,且必须命中「姓名」或「账号」之一、
* 总命中数不少于 2。这样即使表格前面有标题、说明、空行,也能正确定位。
*
* @return 0 基的行下标;找不到返回 -1
*/
private int detectHeaderRow(List<Map<Integer, String>> rows) {
int best = -1;
int bestScore = 0;
int scan = Math.min(rows.size(), 20); // 表头不可能在第 20 行之后
for (int i = 0; i < scan; i++) {
Map<String, Integer> cols = mapColumns(rows.get(i));
int score = cols.size();
boolean hasKey = cols.containsKey("realName") || cols.containsKey("username");
if (hasKey && score >= 2 && score > bestScore) {
best = i;
bestScore = score;
}
}
return best;
}
/** 把一行按列名别名映射成「字段 → 列下标」。 */
private Map<String, Integer> mapColumns(Map<Integer, String> headerRow) {
Map<String, Integer> colOf = new LinkedHashMap<>();
if (headerRow == null) {
return colOf;
}
List<Map.Entry<Integer, String>> cells = new ArrayList<>(headerRow.entrySet());
cells.sort(Comparator.comparing(Map.Entry::getKey));
for (Map.Entry<Integer, String> e : cells) {
String text = e.getValue() == null ? "" : e.getValue().trim();
if (text.isEmpty()) {
continue;
}
for (Map.Entry<String, Set<String>> alias : HEADER_ALIASES.entrySet()) {
if (colOf.containsKey(alias.getKey())) {
continue;
}
if (alias.getValue().contains(text)) {
colOf.put(alias.getKey(), e.getKey());
break;
}
}
}
return colOf;
}
private UserImportResult.RowError validateImportRow(int excelRow, String realName, String username,
String phone, Set<String> seenInFile,
Set<String> existingNames) {
if (realName == null || realName.isBlank()) {
return new UserImportResult.RowError(excelRow, "realName", realName, "姓名为空");
}
if (username == null || username.isBlank()) {
return new UserImportResult.RowError(excelRow, "username", username, "账号为空");
}
String uname = username.trim();
if (!USERNAME_RE.matcher(uname).matches()) {
return new UserImportResult.RowError(excelRow, "username", uname,
"账号只能以字母开头,可含字母、数字、下划线、点或短横线");
}
if (seenInFile.contains(uname)) {
return new UserImportResult.RowError(excelRow, "username", uname, "文件内该账号重复");
}
if (existingNames.contains(uname)) {
return new UserImportResult.RowError(excelRow, "username", uname, "账号已存在");
}
if (phone == null || !PHONE_RE.matcher(phone).matches()) {
return new UserImportResult.RowError(excelRow, "phone", phone, "手机号格式不正确");
}
return null;
}
/** 去掉手机号里常见的空格、短横线,Excel 里存成数字时还会带 .0。 */
private static String normalizePhone(String raw) {
if (raw == null) {
return null;
}
String s = raw.trim().replace(" ", "").replace("-", "");
if (s.endsWith(".0")) {
s = s.substring(0, s.length() - 2);
}
return s.isEmpty() ? null : s;
}
/** 角色列既可能写中文名(「班主任」)也可能写角色码(HEAD_TEACHER),两种都认。 */
private BuiltinRole resolveRoleByText(String text) {
String t = text.trim();
BuiltinRole byCode = BuiltinRole.ofCode(t.toUpperCase());
if (byCode != null) {
try {
resolveAssignableRoles(List.of(byCode.getCode()));
return byCode;
} catch (BizException e) {
return null;
}
}
for (BuiltinRole br : BuiltinRole.all()) {
if (br.getName().equals(t)) {
try {
resolveAssignableRoles(List.of(br.getCode()));
return br;
} catch (BizException e) {
return null;
}
}
}
return null;
}
/** 表头原文,用 | 连接,供界面展示确认。 */
private static String joinHeaderText(Map<Integer, String> headerCells) {
if (headerCells == null || headerCells.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
headerCells.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.filter(e -> e.getValue() != null && !e.getValue().isBlank())
.forEach(e -> {
if (!sb.isEmpty()) {
sb.append(" | ");
}
sb.append(e.getValue().trim());
});
String s = sb.toString();
return s.length() > 300 ? s.substring(0, 300) + "…" : s;
}
/** 字段 → 列字母,供界面核对"系统把哪一列当成了什么"。 */
private static List<UserImportResult.ColumnMap> buildColumnMap(Map<String, Integer> colOf) {
List<UserImportResult.ColumnMap> list = new ArrayList<>();
for (String field : HEADER_ALIASES.keySet()) {
Integer idx = colOf.get(field);
if (idx != null) {
list.add(new UserImportResult.ColumnMap(field,
IMPORT_FIELD_NAMES.getOrDefault(field, field),
columnLetter(idx)));
}
}
return list;
}
/** 0 → A、25 → Z、26 → AA。 */
private static String columnLetter(int index) {
StringBuilder sb = new StringBuilder();
int n = index;
while (n >= 0) {
sb.insert(0, (char) ('A' + n % 26));
n = n / 26 - 1;
}
return sb.toString();
}
private static String cell(Map<Integer, String> row, Integer col) { if (row == null || col == null) {
return null;
}
String v = row.get(col);
return v == null ? null : v.trim();
}
private static boolean isBlankRow(Map<Integer, String> row) {
if (row == null || row.isEmpty()) {
return true;
}
for (String v : row.values()) {
if (v != null && !v.isBlank()) {
return false;
}
}
return true;
}
// ── VO 装配 ───────────────────────────────────
private List<UserVO> toVoList(List<SysUser> users) {
if (users == null || users.isEmpty()) {
return List.of();
}
List<Long> userIds = users.stream().map(SysUser::getId).toList();
List<Long> orgIds = users.stream().map(SysUser::getOrgId).filter(Objects::nonNull).distinct().toList();
Map<Long, String> orgNames = new HashMap<>();
if (!orgIds.isEmpty()) {
for (SysOrg o : orgMapper.selectByIds(orgIds)) {
orgNames.put(o.getId(), o.getOrgName());
}
}
Map<Long, List<String>> rolesByUser = new HashMap<>();
for (UserRoleMapper.UserRoleRow row : userRoleMapper.selectRoleCodesByUserIds(userIds)) {
if (row.getUserId() != null && row.getRoleCode() != null) {
rolesByUser.computeIfAbsent(row.getUserId(), k -> new ArrayList<>()).add(row.getRoleCode());
}
}
List<UserVO> list = new ArrayList<>(users.size());
for (SysUser u : users) {
UserVO vo = new UserVO();
vo.setId(u.getId());
vo.setUsername(u.getUsername());
vo.setRealName(u.getRealName());
vo.setPhoneMasked(MaskUtil.maskPhone(codec.plainPhone(u)));
vo.setOrgId(u.getOrgId());
vo.setOrgName(u.getOrgId() == null ? null : orgNames.get(u.getOrgId()));
vo.setUserType(u.getUserType());
vo.setStatus(u.getStatus());
vo.setLastLoginTime(u.getLastLoginTime());
vo.setCreateTime(u.getCreateTime());
List<String> codes = rolesByUser.getOrDefault(u.getId(), List.of());
vo.setRoleCodes(codes);
// 一期角色全部内置(没有自定义角色的界面),名字直接从代码取;
// 二期支持自定义角色后,这里要改成从 sys_role.role_name 读。
vo.setRoleNames(codes.stream()
.map(c -> {
BuiltinRole br = BuiltinRole.ofCode(c);
return br == null ? c : br.getName();
})
.toList());
list.add(vo);
}
return list;
}
// ── 返回值 ────────────────────────────────────
/** 新增结果。{@code initialPassword} 只在这里返回一次。 */
@Getter
@Setter
public static class CreateResult {
private Long id;
/** 初始密码,<b>仅此一次返回</b>;库里只存 BCrypt 哈希,之后无法还原。 */
private String initialPassword;
public CreateResult(Long id, String initialPassword) {
this.id = id;
this.initialPassword = initialPassword;
}
}
/** 重置密码结果。 */
@Getter
@Setter
public static class ResetResult {
private String username;
private String newPassword;
public ResetResult(String username, String newPassword) {
this.username = username;
this.newPassword = newPassword;
}
}
}
@@ -33,7 +33,20 @@ public class RoleProvisioner {
/** 幂等:已存在的角色跳过。 */
public void provisionBuiltinRoles(long tenantId) {
// ⚠️ 平台运营角色只发给平台租户(tenant_id=0)。
// 以前这里是「所有租户都发全部内置角色」,看起来更"整齐",
// 但结果是每个学校租户的 sys_role 里都躺着一行 PLATFORM_ADMIN。
// 只要有一个入口能把它绑到账号上(比如新增账号时传 roleCodes),
// 学校管理员就能给自己人造出带 tenant:manage 的账号 —— 因为
// /tenants 只校验权限码、不校验"是否真的在平台租户下"。
// 真正的防线在 UserService.resolveAssignableRoles,这里同时把角色行也去掉:
// 列表里根本不存在的角色,比"存在但被禁止使用"更难被绕过。
boolean platformTenant = tenantId == cn.tianbaotong.fill.common.context.LoginUser.PLATFORM_TENANT_ID;
for (BuiltinRole br : BuiltinRole.all()) {
if (!platformTenant && br == BuiltinRole.PLATFORM_ADMIN) {
continue;
}
if (findRole(tenantId, br.getCode()) != null) {
continue;
}
@@ -0,0 +1,22 @@
-- ============================================================================
-- 更正 sys_user.user_type 的列注释
-- ============================================================================
--
-- 背景:V1 建 sys_user 时把 user_type 的注释写成了
-- '1教师/管理员 2填报人(学生) 3社会人员'
-- 但**权威口径是《03-接口设计》§2.7**(与代码里的 BuiltinRole.UserType 一致):
-- 1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生
--
-- 这不是"措辞略不同":两套口径里 2 和 3 的含义完全错位
-- (旧注释的 2=学生,新口径的 2=学校管理员;旧注释的 3=社会人员,新口径的 3=班主任)。
--
-- 已经造成过一次真实误判:按旧注释把 user_type=3 当成"社会人员(一期不做)"直接拒绝,
-- 而它其实是**班主任** —— 账号管理里最高频的一种。
--
-- 为什么不直接改 V1:Flyway 会校验已应用迁移的 checksum,
-- 改 V1 会让所有已有环境的启动直接失败。所以用一条新迁移覆盖注释。
--
-- 注意:这只是注释变更,不动数据、不动结构,纯 DDL 元信息,可安全重复执行。
-- ============================================================================
COMMENT ON COLUMN sys_user.user_type IS '1平台运营 2学校管理员 3班主任 4学生(口径见《03-接口设计》§2.7,与 BuiltinRole.UserType 一致)';
+1 -1
View File
@@ -116,7 +116,7 @@ COMMENT ON COLUMN sys_user.phone_hash IS '手机号HMAC,用于唯一检索';
COMMENT ON COLUMN sys_user.open_id IS '微信openid';
COMMENT ON COLUMN sys_user.id_card_no IS '证件号(AES加密存储)';
COMMENT ON COLUMN sys_user.id_card_hash IS '证件号HMAC,用于比对与去重';
COMMENT ON COLUMN sys_user.user_type IS '1教师/管理员 2填报人(学生) 3社会人员';
COMMENT ON COLUMN sys_user.user_type IS '1平台运营 2学校管理员 3班主任 4学生(口径见 03-接口设计 §2.7)';
COMMENT ON COLUMN sys_user.status IS '0停用 1正常 2待激活';
CREATE TABLE collect_task (
+49 -5
View File
@@ -9,7 +9,7 @@
职业技能等级认定**报名材料收集 SaaS**。学生在微信小程序填报、班主任在手机初审、管理员在 PC 终审与导出。核心价值:**动态表单 + 规则引擎(命名/压缩/识别)+ 门禁核验 + 一键规范导出**。
工作目录 `D:\IDEA_Project\xiaotutongxue`(**空 git 仓库,无 commit,全部改动未提交**),交付物在 `docs/`。
工作目录 `D:\IDEA_Project\xiaotutongxue`(git 仓库,**已有 3 次提交**:`63aec55` 文档 / `9d7a8ca` M0 后端 / `87ece5a` 前端换框架;**其后又有一批未提交改动**,见 §一·四),交付物在 `docs/`。
---
@@ -42,12 +42,56 @@
| 项 | 状态 |
|---|---|
| 技术栈定版 | ✅ Java 21 + **Spring Boot 4.1.1** + MyBatis-Plus 3.5.17(boot4) + PG16 + Redis7<br>PC: Vue3 + Element Plus;小程序: uni-app |
| 技术栈定版 | ✅ Java 21 + **Spring Boot 4.1.1** + MyBatis-Plus 3.5.17(boot4) + PG16 + Redis7<br>PC: Vue3 + **Gi Admin Pro(Arco Design Vue 2.58)**;小程序: uni-app |
| M0 后端骨架 | ✅ 统一响应/异常/58 错误码/多租户拦截/软删除/字段填充/AES-GCM/HMAC/RSA/JWT/权限模型 |
| M0 数据库 | ✅ Flyway 2 个迁移;26 张表(20 业务 + 5 标准 + 1 迁移记录) |
| M0 后端接口 | ✅ 租户开通与列表、组织 CRUD 与树、PC 登录/登出/当前用户 |
| M0 验收 | ✅ **`python scripts/e2e-m0.py` → 35/35 通过** |
| 待做 | 用户 CRUD 与 Excel 导入;PC/小程序前端;CI/CD;H5 预研 |
| M0 后端接口 | ✅ 租户开通与列表、组织 CRUD 与树、PC 登录/登出/当前用户、**账号管理全套(含 Excel 批量导入)** |
| M0 验收 | ✅ **`python scripts/e2e-m0.py` → 36/36**;**`python scripts/e2e-m0-user.py` → 53/53** |
| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / **账号管理** |
| 待做 | 小程序(uni-app)未创建;CI/CD;H5 保底预研;`hasActiveTask` 待 M1 补实现 |
---
## 一·四、账号管理竖切(2026-09-28 晚,未提交)
> 范围:`GET/POST/PUT/DELETE /users`、`PUT /users/{id}/status`、`POST /users/{id}/reset-password`、
> `POST /users/import`、`PUT /auth/password`;前端「组织与账号 → 账号管理」一屏。
**验收**:`python scripts/e2e-m0-user.py` → **55/55**;`python scripts/e2e-m0.py` 回归 **36/36**。
前端 `pnpm typecheck` **0 错**、`pnpm build` 通过、新增文件 eslint/stylelint 干净。
账号管理页**已在浏览器肉眼确认**(首屏与切页均可见、8→7 列、数据与分页正确)。
### 本轮修掉的 5 个真问题(都不是"功能没做完",是"做完了但错的")
| # | 问题 | 后果 | 修法 |
|:--:|---|---|---|
| 1 | **学校租户也发放 `PLATFORM_ADMIN` 角色行** | 学校管理员在新增账号时传 `roleCodes:["PLATFORM_ADMIN"]` 即可造出带 `tenant:manage` 的账号;而 `/tenants` 只校验权限码、不校验"是否真在平台租户下" → **任何学校管理员都能开通租户、拉全平台租户列表**(租户级越权到平台级) | ① `RoleProvisioner` 不再给非平台租户发该角色行;② `UserService.resolveAssignableRoles` 按租户白名单校验,越界返回 `40301`。两道都留 |
| 2 | `UserUpsertReq.username` 上的 `@NotBlank` 对**修改**也生效 | `PUT /users/{id}` 永远返回 `40001`,而错误明细只说"存在阻断性校验问题",看不出是登录名 —— 改账号功能整体不可用 | 去掉该注解(`@Pattern` 对 null 是通过的),"新增时必填"改由 `UserService.create` 判定 |
| 3 | `Main.vue` 的 `<transition appear>` | 首次渲染被当成一次过渡,元素被加上 `zoom-fade-enter-from/-active`(`opacity:0`)后**永不清理** → **DOM 内容齐全、接口数据正常、文字可选中,但整块内容区肉眼不可见**。极易误判成"页面没渲染"而往路由/接口方向排查 | 去掉 `appear`(首次加载本就不需要入场动画)。**已浏览器复验通过** |
| 4 | 我写的 `assertAnotherAdminExists` **在停用/删除路径上不可达** | 能调这两个接口的人自己必然是活跃管理员,所以"除目标外还有管理员"恒成立。而真正会锁死租户的路径——**把管理员改成普通角色**(目标可以是操作者自己)——当时没有拦 | 新增 `losingAdminRole()` + `UserMapper.countAdminRoles()`,在改角色时判定;方法注释里写明"哪条路径真生效、哪条是防御性的" |
| 5 | ⭐ **`sys_user.user_type` 有两套互相冲突的枚举** | DDL/实体注释写「1教师/管理员 2填报人(学生) 3社会人员」,而**权威契约**(《03-接口设计》§2.7)与 `BuiltinRole.UserType` 是「1平台运营 2学校管理员 3班主任 4学生」。两套的 **2 和 3 完全错位**。我照错的注释写了新代码,结果是:**`userType=3`(班主任,最高频的账号)被当成"社会人员(二期)"直接拒掉** —— 建班主任账号会失败;前端「类型」列还把学校管理员显示成"填报人(学生)" | ① 后端改成**从角色反推用户类型**再比对(`ROLE_TO_USER_TYPE`),不再逐条 `if`;② 新增 `V3__fix_user_type_comment.sql` 更正库注释(**不能改 V1,Flyway 会校验 checksum**);③ 更正 PRD 与 `schema-postgresql.sql` 的同一行注释;④ 前端「用户类型」从手选改为**由角色推导**,从根上消除矛盾组合;⑤ E2E 加 3 条回归断言锁住这个语义 |
> ⚠️ **第 5 条是最值得记住的一条**:不是"漏了个校验",而是**同一份资料里有两套定义,且都对不上**。
> 教训:看到"枚举注释"时,要去**权威契约**(这里是《03-接口设计》)核对,不能就近抄实体或 DDL 的注释。
> 这条也解释了为什么 `V1` 迁移里的错误注释只能靠 `V3` 覆盖 —— 已应用的迁移**一个字都不能改**。
### 已知缺口(有意留着,别当成漏做)
| 项 | 说明 |
|---|---|
| **改密后旧 token 仍有效** | 直到自然过期。要立刻踢人需要 JWT 带签发时间 + 与"最近改密时间"比较,涉及 `JwtService`/`AuthInterceptor`,不适合混在这一刀里。已在 `AuthService.changePassword` 注释里写明 |
| `hasActiveTask()` 恒为 `false` | `collect_task` 是 M1 的表且当前无"任务负责人"字段,`40014` 这条约束**目前不生效**。方法注释已标红,M1 必须补 |
| 手机号**模糊**搜不了 | AES-GCM 每次密文不同,`LIKE` 在原理上不可行。完整号码走 `phone_hash` 等值匹配;代价是"只记得中间几位"搜不到,属加密的必然取舍。E2E 里有一条专门断言这个行为(避免后人当 bug 改掉) |
| **浏览器端验证** | ✅ 已完成。账号管理页首屏与切页均 `opacity: 1`(`appear` 修复生效)、7 列、数据与分页正确、组织树正常;demo 租户已清理干净(1 个账号 `admin`、6 个组织节点)。⚠️ 下一个新页面上线时容易忽略的一点:**新增 `views/` 目录后必须重启 dev server**,见下方环境坑 3 |
### 本轮踩到的三个环境坑
1. **Maven 的本地仓库不在 `C:\Users\<u>\.m2\repository`**,而在 `D:\maven_repositiory`。在那里查依赖才准。
2. **`easyexcel-4.0.3.jar` 只有 3KB** —— 它是聚合包,真类在 `easyexcel-core`(528KB)+ `poi 5.2.5`。只看聚合包的大小会误判成"依赖是空的"。这正是本项目"依赖在、功能不在"那条教训的又一例。
3. **`import.meta.glob('@/views/**/*.vue')` 在 dev server 里对"新增目录"不总失效**:`views/user/` 建好并 HMR 重载后,路由仍拿不到组件(component 为 `undefined` → 内容区空白)。**新增页面目录后要重启 dev server**,不能只靠 HMR。
---
**关键实现决策(不要在后继开发中改回去)**
+1 -1
View File
@@ -1537,7 +1537,7 @@ COMMENT ON COLUMN sys_user.phone_hash IS '手机号HMAC,用于唯一检索';
COMMENT ON COLUMN sys_user.open_id IS '微信openid';
COMMENT ON COLUMN sys_user.id_card_no IS '证件号(AES加密存储)';
COMMENT ON COLUMN sys_user.id_card_hash IS '证件号HMAC,用于比对与去重';
COMMENT ON COLUMN sys_user.user_type IS '1教师/管理员 2填报人(学生) 3社会人员';
COMMENT ON COLUMN sys_user.user_type IS '1平台运营 2学校管理员 3班主任 4学生(口径见 03-接口设计 §2.7)';
COMMENT ON COLUMN sys_user.status IS '0停用 1正常 2待激活';
```
-1
View File
@@ -9,7 +9,6 @@
<script setup lang="ts">
import { useTheme } from '@/hooks'
import { useDictStore } from '@/stores'
defineOptions({ name: 'App' })
+5
View File
@@ -79,3 +79,8 @@ export function logout() {
export function getCaptcha() {
return http.get<CaptchaVO>('/auth/captcha')
}
/** 修改自己的密码(原密码与新密码都需 RSA 加密) */
export function changePassword(data: { oldPassword: string, newPassword: string }) {
return http.put<void>('/auth/password', data)
}
+1 -1
View File
@@ -35,7 +35,7 @@ export interface TenantCreateReq {
adminPhone: string
}
/** 开通结果:初始密码**只返回这一次** */
/** 开通结果:初始密码**只返回这一次 */
export interface TenantCreateResult {
id: number
adminInitialPassword: string
+214
View File
@@ -0,0 +1,214 @@
/**
* 账号管理接口(《03-接口设计》§5.6 ~ §5.9)。
*
* 契约差异(已在 `@/utils/http` 统一适配):后端 `{ code, msg, data }`,
* 分页 `{ list, total }`(适配层会补一份 `records`)。
*/
import http from '@/utils/http'
/** 账号列表项(§5.6) */
export interface UserVO {
id: number
/** 登录名;学生走微信登录时可能为空 */
username: string | null
realName: string
/** 脱敏手机号,形如 138****8888 —— 后端不返回明文 */
phoneMasked: string | null
orgId: number | null
orgName: string | null
/** 1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生(§2.7) */
userType: number
/** 0 停用 / 1 正常 / 2 待激活 */
status: number
roleCodes: string[]
roleNames: string[]
lastLoginTime: string | null
createTime: string
}
/** 新增 / 修改请求(§5.7 / §5.9) */
export interface UserUpsertReq {
/** 修改时后端忽略此字段 */
username?: string
realName: string
phone: string
orgId?: number | null
roleCodes: string[]
userType: number
status?: number
}
/** 新增结果:初始密码仅此一次返回 */
export interface UserCreateResult {
id: number
initialPassword: string
}
/** 重置密码结果 */
export interface UserResetResult {
username: string
newPassword: string
}
/** 批量导入的「体检报告」(§5.8) */
export interface UserImportResult {
total: number
success: number
failed: number
/** 服务端识别到的表头所在行号(从 1 开始)—— 必须展示给用户确认 */
headerRow: number
/** 表头那一行的原文(用 | 连接),供用户核对"系统认对了吗" */
headerText: string
/** 识别到的列映射:哪个字段落在哪一列 */
columns: Array<{ field: string, fieldName: string, column: string }>
errors: Array<{ row: number, field: string, value: string | null, reason: string }>
initialPasswords: Array<{ username: string, password: string }>
}
export interface UserListQuery {
keyword?: string
orgId?: number
roleCode?: string
userType?: number
status?: number
pageNum?: number
pageSize?: number
}
/** 账号分页列表 */
export function getUserList(params: UserListQuery) {
return http.get<{ list: UserVO[], total: number }>('/users', params)
}
/** 新增账号,返回初始密码(仅此一次) */
export function addUser(data: UserUpsertReq) {
return http.post<UserCreateResult>('/users', data)
}
/** 修改账号;登录名与密码不在此接口修改 */
export function updateUser(id: number, data: UserUpsertReq) {
return http.put<void>(`/users/${id}`, data)
}
/** 停用(软删)。有在办任务返回 40014;不能停用自己或最后一个管理员 */
export function deleteUser(id: number) {
return http.delete<void>(`/users/${id}`)
}
/** 启用 / 停用(只改 status,数据保留可再启用) */
export function setUserStatus(id: number, status: number) {
// status 后端设计为查询参数(PUT /users/{id}/status?status=0),
// 所以走 config.params 而不是 body
return http.put<void>(`/users/${id}/status`, undefined, { params: { status } })
}
/** 重置密码。mode 省略即 GENERATE(服务端生成)会同时解除该账号的登录锁定 */
export function resetUserPassword(id: number, data: { mode?: string, newPassword?: string } = {}) {
return http.post<UserResetResult>(`/users/${id}/reset-password`, data)
}
/** 批量导入账号(Excel ≤10MB),返回体检报告 */
export function importUsers(file: File, defaultRoleCode?: string) {
const fd = new FormData()
fd.append('file', file)
return http.post<UserImportResult>('/users/import', fd, {
params: defaultRoleCode ? { defaultRoleCode } : undefined
})
}
// ── 枚举镜像 ────────────────────────────────────────
/**
* 用户类型 → 中文名。
*
* ⚠️ 口径是《03-接口设计》§2.7:`1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生。
*
* **不要照 `sys_user` 的库注释写。** 那行注释(以及 PRD 里的那行 DDL)写的是
* 「1教师/管理员 2填报人(学生) 3社会人员」,与契约是两套错位的枚举 ——
* 旧注释的 2=学生,契约的 2=学校管理员;旧注释的 3=社会人员,契约的 3=班主任。
* 已经因此误判过一次:把 `3` 当成"社会人员(一期不做)"拒掉,而它其实是**班主任**。
* 库注释已由 `V3__fix_user_type_comment.sql` 更正。
*/
export const USER_TYPE_NAMES: Record<number, string> = {
1: '平台运营',
2: '学校管理员',
3: '班主任',
4: '学生'
}
/**
* 角色 → 用户类型。
*
* 二者本来就是一对一绑定的,所以:
* · 表单里**不让用户选**用户类型,而是从角色推导出来(避免选出矛盾组合)
* · 服务端也会用同一套映射反推校验(`UserService.assertRoleMatchesUserType`)
*/
export const ROLE_TO_USER_TYPE: Record<string, number> = {
PLATFORM_ADMIN: 1,
SCHOOL_ADMIN: 2,
AUDITOR: 2,
HEAD_TEACHER: 3,
DEPUTY_TEACHER: 3,
STUDENT: 4
}
/** 从所选角色推导用户类型;角色为空或跨类时返回 `null`(调用方据此拦提交) */
export function deriveUserType(roleCodes: string[]): number | null {
const types = new Set<number>()
for (const c of roleCodes) {
const t = ROLE_TO_USER_TYPE[c]
if (t) types.add(t)
}
return types.size === 1 ? [...types][0] : null
}
/** 账号状态(对应 sys_user.status) */
export const USER_STATUS: Record<number, { text: string, color: string }> = {
0: { text: '已停用', color: 'gray' },
1: { text: '正常', color: 'green' },
2: { text: '待激活', color: 'orange' }
}
/**
* 可分配的角色,**必须与后端 `BuiltinRole` 逐字一致**。
*
* 为什么在前端也写一份:一期没有任何"自定义角色"的界面,角色集合由代码决定、
* 不由数据库决定(这是项目已定的决策 —— 权限码是前后端契约,放 DB 必然漂移)。
* 所以这里是「与后端枚举对称的镜像」,不是"业务配置"。
*
* 真实的可分配范围仍由后端 `UserService.resolveAssignableRoles` 判定:
* 传了这里的列表之外的角色会拿到 40301,前端只负责不显示没得选的项。
*
* 平台运营角色只在平台租户可分配 —— 学校管理员拿到它会直接越权到平台级。
*/
export const SCHOOL_ROLE_OPTIONS: Array<{ value: string, label: string }> = [
{ value: 'SCHOOL_ADMIN', label: '学校管理员' },
{ value: 'HEAD_TEACHER', label: '班主任' },
{ value: 'DEPUTY_TEACHER', label: '副班主任' },
{ value: 'AUDITOR', label: '审核员' },
{ value: 'STUDENT', label: '学生' }
]
/** 平台租户的可分配角色 */
export const PLATFORM_ROLE_OPTIONS: Array<{ value: string, label: string }> = [
{ value: 'PLATFORM_ADMIN', label: '平台运营' }
]
/** 导入时「角色」列允许写的角色中文名 → 角色码(与后端 BuiltinRole 对齐) */
export const ROLE_NAME_TO_CODE: Record<string, string> = {
平台运营: 'PLATFORM_ADMIN',
学校管理员: 'SCHOOL_ADMIN',
班主任: 'HEAD_TEACHER',
副班主任: 'DEPUTY_TEACHER',
审核员: 'AUDITOR',
学生: 'STUDENT'
}
/** 导入文件里被识别的列名(与后端 HEADER_ALIASES 对齐,供界面提示用) */
export const IMPORT_FIELD_NAMES: Record<string, string> = {
realName: '姓名',
username: '账号',
phone: '手机号',
org: '组织',
role: '角色'
}
+6
View File
@@ -19,6 +19,8 @@ declare module 'vue' {
ABreadcrumbItem: typeof import('@arco-design/web-vue')['BreadcrumbItem']
AButton: typeof import('@arco-design/web-vue')['Button']
ACard: typeof import('@arco-design/web-vue')['Card']
ACheckbox: typeof import('@arco-design/web-vue')['Checkbox']
ACheckboxGroup: typeof import('@arco-design/web-vue')['CheckboxGroup']
ACol: typeof import('@arco-design/web-vue')['Col']
AComment: typeof import('@arco-design/web-vue')['Comment']
AConfigProvider: typeof import('@arco-design/web-vue')['ConfigProvider']
@@ -61,6 +63,7 @@ declare module 'vue' {
ATag: typeof import('@arco-design/web-vue')['Tag']
ATooltip: typeof import('@arco-design/web-vue')['Tooltip']
ATree: typeof import('@arco-design/web-vue')['Tree']
ATreeSelect: typeof import('@arco-design/web-vue')['TreeSelect']
GiButton: typeof import('./components/GiButton/GiButton.vue')['default']
GiCellAvatar: typeof import('./components/GiCell/GiCellAvatar.vue')['default']
GiCellGender: typeof import('./components/GiCell/GiCellGender.vue')['default']
@@ -94,6 +97,8 @@ declare global {
const ABreadcrumbItem: typeof import('@arco-design/web-vue')['BreadcrumbItem']
const AButton: typeof import('@arco-design/web-vue')['Button']
const ACard: typeof import('@arco-design/web-vue')['Card']
const ACheckbox: typeof import('@arco-design/web-vue')['Checkbox']
const ACheckboxGroup: typeof import('@arco-design/web-vue')['CheckboxGroup']
const ACol: typeof import('@arco-design/web-vue')['Col']
const AComment: typeof import('@arco-design/web-vue')['Comment']
const AConfigProvider: typeof import('@arco-design/web-vue')['ConfigProvider']
@@ -136,6 +141,7 @@ declare global {
const ATag: typeof import('@arco-design/web-vue')['Tag']
const ATooltip: typeof import('@arco-design/web-vue')['Tooltip']
const ATree: typeof import('@arco-design/web-vue')['Tree']
const ATreeSelect: typeof import('@arco-design/web-vue')['TreeSelect']
const GiButton: typeof import('./components/GiButton/GiButton.vue')['default']
const GiCellAvatar: typeof import('./components/GiCell/GiCellAvatar.vue')['default']
const GiCellGender: typeof import('./components/GiCell/GiCellGender.vue')['default']
@@ -1,5 +1,18 @@
import type { FormInstance } from '@arco-design/web-vue'
import EditTable from './GiEditTable.vue'
export type * from './type'
export type EditTableInstance = InstanceType<typeof EditTable>
/**
* 组件实例类型。
*
* 与 `GiTable/index.ts` 同一个原因:泛型 SFC 不能用 `InstanceType<>` 推导
* (`<script setup generic="T extends TableData">` 让组件类型成为泛型函数签名),
* 所以按 `defineExpose({ formRef })` 的真实形状手工声明。
*/
export interface EditTableInstance {
/** 内层 a-form 的实例(校验用) */
formRef: FormInstance | null
}
export { EditTable as GiEditTable }
+20 -1
View File
@@ -1,5 +1,24 @@
import type { TableInstance as ArcoTableInstance } from '@arco-design/web-vue'
import Table from './GiTable.vue'
export type * from './type'
export type TableInstance = InstanceType<typeof Table>
/**
* 组件实例类型。
*
* ⚠️ 这里**不能**写 `InstanceType<typeof Table>`。
* `GiTable.vue` 是泛型 SFC(`<script setup generic="T extends TableData">`),
* 它的类型是一个泛型函数签名而不是构造函数,`InstanceType<>` 对它不成立,
* vue-tsc 会报 `TS2344: ... does not satisfy the constraint
* 'abstract new (...args: any) => any'`。
*
* 所以按 `defineExpose({ tableRef })` 的**真实形状**手工声明:
* 暴露出来的就是那一个内层 a-table 的实例(模板 ref 在暴露时会被解包)。
* 这比 `InstanceType` 推导出来的"全部 props + slots"更准确 —— 后者本来也是假的。
*/
export interface TableInstance {
/** 内层 a-table 的实例 */
tableRef: ArcoTableInstance | null
}
export { Table as GiTable }
+10 -6
View File
@@ -1,15 +1,20 @@
/**
* 组件库出口。
*
* ⚠️ 这里的每一行都必须对应 `src/components/` 下的真实目录。
* 剥离 gi-demo 演示页时删掉过 6 个演示专用组件
* (GiCodeButton / GiCodeView / GiFooter / GiIconSelector / GiIframe / GiTrend),
* 但出口文件没同步 —— `export * from './已删除的组件'` 在构建时**不会报错**
* (Vite 只在使用到该导出时才解析),直到某天有人 import 它才炸。
* 所以增删组件时务必回来同步这一份清单。
*/
export * from './GiButton'
export * from './GiCell'
export * from './GiCodeButton'
export * from './GiCodeView'
export * from './GiDot'
export * from './GiEditTable'
export * from './GiFlex'
export * from './GiFooter'
export * from './GiForm'
export * from './GiIconBox'
export * from './GiIconSelector'
export * from './GiIframe'
export * from './GiInputSearch'
export * from './GiOption'
export * from './GiPageLayout'
@@ -17,4 +22,3 @@ export * from './GiTable'
export * from './GiTableSetting'
export * from './GiTag'
export * from './GiThemeBtn'
export * from './GiTrend'
+104 -27
View File
@@ -44,72 +44,149 @@ const GROUPS: Array<{ id: string, title: string, icon: string, sort: number }> =
export const XT_MENUS: XtMenu[] = [
// ── 收集管理 ──────────────────────────────────────
{
key: 'task', title: '收集任务', icon: 'icon-park-outline:list-two',
path: '/collect/task', component: 'task/index', group: 'collect', sort: 11, ready: false,
key: 'task',
title: '收集任务',
icon: 'icon-park-outline:list-two',
path: '/collect/task',
component: 'task/index',
group: 'collect',
sort: 11,
ready: false,
note: '建任务、配表单与材料规则、发布与暂停。对应里程碑 M1。'
},
{
key: 'roster', title: '名单管理', icon: 'icon-park-outline:user-business',
path: '/collect/roster', component: 'roster/index', group: 'collect', sort: 12, ready: false,
key: 'roster',
title: '名单管理',
icon: 'icon-park-outline:user-business',
path: '/collect/roster',
component: 'roster/index',
group: 'collect',
sort: 12,
ready: false,
note: '导入名册、报考范围确认、门禁名单维护。对应里程碑 M2。'
},
{
key: 'join', title: '加入申请', icon: 'icon-park-outline:user-to-user-transmission',
path: '/collect/join', component: 'join/index', group: 'collect', sort: 13, ready: false,
key: 'join',
title: '加入申请',
icon: 'icon-park-outline:user-to-user-transmission',
path: '/collect/join',
component: 'join/index',
group: 'collect',
sort: 13,
ready: false,
note: '门禁未匹配的人提交加入申请,管理员在此审批。对应里程碑 M2。'
},
{
key: 'audit', title: '审核', icon: 'icon-park-outline:check-one',
path: '/collect/audit', component: 'audit/index', group: 'collect', sort: 14, ready: false,
key: 'audit',
title: '审核',
icon: 'icon-park-outline:check-one',
path: '/collect/audit',
component: 'audit/index',
group: 'collect',
sort: 14,
ready: false,
note: '班主任初审(右滑通过)、管理员终审、批量操作。对应里程碑 M5。'
},
{
key: 'export', title: '导出中心', icon: 'icon-park-outline:download-two',
path: '/collect/export', component: 'export/index', group: 'collect', sort: 15, ready: false,
key: 'export',
title: '导出中心',
icon: 'icon-park-outline:download-two',
path: '/collect/export',
component: 'export/index',
group: 'collect',
sort: 15,
ready: false,
note: '按目录规范打包汇总,不合格文件禁止进包。对应里程碑 M6。'
},
// ── 配置 ─────────────────────────────────────────
{
key: 'form', title: '表单设计器', icon: 'icon-park-outline:form-one',
path: '/setting/form', component: 'form/index', group: 'setting', sort: 21, ready: false,
key: 'form',
title: '表单设计器',
icon: 'icon-park-outline:form-one',
path: '/setting/form',
component: 'form/index',
group: 'setting',
sort: 21,
ready: false,
note: '内置字段锁定、扩展字段自由。对应里程碑 M1。'
},
{
key: 'material', title: '材料规则', icon: 'icon-park-outline:file-editing',
path: '/setting/material', component: 'material/index', group: 'setting', sort: 22, ready: false,
key: 'material',
title: '材料规则',
icon: 'icon-park-outline:file-editing',
path: '/setting/material',
component: 'material/index',
group: 'setting',
sort: 22,
ready: false,
note: '命名模板、别名、格式白名单、体积上限、压缩档位。对应里程碑 M1。'
},
{
key: 'cleanup', title: '历史整理器', icon: 'icon-park-outline:magic-wand',
path: '/setting/cleanup', component: 'cleanup/index', group: 'setting', sort: 23, ready: false,
key: 'cleanup',
title: '历史整理器',
icon: 'icon-park-outline:magic-wand',
path: '/setting/cleanup',
component: 'cleanup/index',
group: 'setting',
sort: 23,
ready: false,
note: '把已有的乱文件夹/压缩包批量规范化并出体检报告。对应里程碑 M3。'
},
// ── 组织与账号 ────────────────────────────────────
{
key: 'org', title: '组织架构', icon: 'icon-park-outline:tree-list',
path: '/org/manage', component: 'org/index', group: 'org', sort: 31, ready: true
key: 'org',
title: '组织架构',
icon: 'icon-park-outline:tree-list',
path: '/org/manage',
component: 'org/index',
group: 'org',
sort: 31,
ready: true
},
{
key: 'user', title: '账号管理', icon: 'icon-park-outline:peoples',
path: '/org/user', component: 'user/index', group: 'org', sort: 32, ready: false,
note: '账号增删改查与 Excel 批量导入(含体检报告)。属 M0 剩余任务。'
key: 'user',
title: '账号管理',
icon: 'icon-park-outline:peoples',
path: '/org/user',
component: 'user/index',
group: 'org',
sort: 32,
ready: true
},
{
key: 'role', title: '角色与授权', icon: 'icon-park-outline:permissions',
path: '/org/role', component: 'role/index', group: 'org', sort: 33, ready: false,
key: 'role',
title: '角色与授权',
icon: 'icon-park-outline:permissions',
path: '/org/role',
component: 'role/index',
group: 'org',
sort: 33,
ready: false,
note: '内置角色的权限由代码决定、不可改;自定义角色属二期。'
},
// ── 平台运维 ──────────────────────────────────────
{
key: 'tenant', title: '租户管理', icon: 'icon-park-outline:school',
path: '/platform/tenant', component: 'tenant/index', group: 'platform', sort: 41, ready: true
key: 'tenant',
title: '租户管理',
icon: 'icon-park-outline:school',
path: '/platform/tenant',
component: 'tenant/index',
group: 'platform',
sort: 41,
ready: true
},
{
key: 'log', title: '操作日志', icon: 'icon-park-outline:log',
path: '/platform/log', component: 'log/index', group: 'platform', sort: 42, ready: false,
key: 'log',
title: '操作日志',
icon: 'icon-park-outline:log',
path: '/platform/log',
component: 'log/index',
group: 'platform',
sort: 42,
ready: false,
note: '登录、查看明文、下载、导出、代传、删除的审计记录。'
}
]
+4 -1
View File
@@ -27,7 +27,10 @@ export function useBreakpoint() {
const breakpoint = computed<Breakpoint>(() => {
const arr = currentBreakpoints.value
return arr.length ? arr[arr.length - 1] : 'xs'
// arr.at(-1) 的类型是 Breakpoint | undefined —— 空数组时就是 undefined。
// 原来写成 `arr.length ? arr.at(-1) : 'xs'` 是个类型谎:
// 长度判断并不能让 TS 窄化 at() 的结果,严格模式下会报 TS2769。
return arr.length ? (arr.at(-1) ?? 'xs') : 'xs'
})
return { breakpoint }
@@ -0,0 +1,120 @@
<template>
<a-modal v-model:visible="visible" title="修改密码" :width="440" :mask-closable="false"
@ok="confirm" @cancel="close">
<a-alert type="info" :show-icon="false" class="change-pwd__tip">
<template #title>
密码用 RSA 加密后传输,不会以明文进入网关或日志。修改成功后请用新密码重新登录。
</template>
</a-alert>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-form-item field="oldPassword" label="原密码">
<a-input-password v-model="form.oldPassword" placeholder="请输入当前密码" allow-clear />
</a-form-item>
<a-form-item field="newPassword" label="新密码">
<a-input-password v-model="form.newPassword" placeholder="8~32 位,需含字母与数字" allow-clear />
</a-form-item>
<a-form-item field="confirmPassword" label="确认新密码">
<a-input-password v-model="form.confirmPassword" placeholder="再输入一次" allow-clear />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import { Message } from '@arco-design/web-vue'
import { changePassword } from '@/apis/xt'
import { encryptPassword } from '@/utils/rsa'
/**
* 修改自己的密码。
*
* 三点与后端一致的口径,界面上要提前说清楚,否则用户会以为是系统坏了:
* 1. **原密码必填** —— 文档里「强制改密场景可空」需要一个
* 「必须改密」标记来界定,而库里目前没有这个列。
* 在没有该标记时放行"不填原密码",等于任何拿到有效 token 的人
* 都能改掉密码、把原主永久挤出去,所以现在一律校验。
* 2. **新旧密码不能相同** —— 服务端会拒。
* 3. **改密后此前签发的 token 仍然有效**(已知缺口),所以这里提示的是
* "请用新密码重新登录",而不是"已踢出其他设备"——后者现在做不到。
*/
defineOptions({ name: 'ChangePasswordModal' })
const visible = ref(false)
const submitting = ref(false)
const formRef = useTemplateRef('formRef')
const form = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const rules: FormInstance['rules'] = {
oldPassword: [{ required: true, message: '请输入原密码' }],
newPassword: [
{ required: true, message: '请输入新密码' },
{ minLength: 8, message: '密码至少 8 位' },
{ maxLength: 32, message: '密码最多 32 位' }
],
confirmPassword: [
{ required: true, message: '请再输入一次新密码' },
{
validator: (value: string, callback: (error?: string) => void) => {
if (value !== form.newPassword) {
callback('两次输入的新密码不一致')
return
}
callback()
}
}
]
}
function open() {
form.oldPassword = ''
form.newPassword = ''
form.confirmPassword = ''
visible.value = true
}
function close() {
visible.value = false
}
async function confirm() {
const valid = await formRef.value?.validate()
if (valid) return false
submitting.value = true
try {
// 两个字段都要加密:后端把两个都当密文解,混着传会在解密处失败
const [oldCipher, newCipher] = await Promise.all([
encryptPassword(form.oldPassword),
encryptPassword(form.newPassword)
])
await changePassword({ oldPassword: oldCipher, newPassword: newCipher })
Message.success('密码已修改,请用新密码重新登录')
visible.value = false
return true
} catch {
return false
} finally {
submitting.value = false
}
}
defineExpose({ open })
</script>
<style lang="scss" scoped>
.change-pwd {
&__tip {
margin-bottom: 16px;
line-height: 1.7;
}
}
</style>
@@ -128,10 +128,10 @@ const appStore = useAppStore()
.layout-mode-item {
box-sizing: border-box;
display: flex;
gap: 4px;
width: 100%;
height: 58px;
padding: 6px;
gap: 4px;
overflow: hidden;
cursor: pointer;
background-color: var(--color-bg-1);
@@ -156,20 +156,20 @@ const appStore = useAppStore()
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
gap: 4px;
min-width: 0;
}
.preview-side {
box-sizing: border-box;
display: flex;
flex-direction: column;
flex-shrink: 0;
flex-direction: column;
gap: 3px;
width: 12px;
padding: 3px 2px;
gap: 3px;
background-color: var(--color-fill-3);
border-radius: 2px;
box-sizing: border-box;
}
.preview-side--main {
@@ -189,14 +189,14 @@ const appStore = useAppStore()
}
.preview-topbar {
box-sizing: border-box;
display: flex;
gap: 3px;
width: 100%;
height: 10px;
padding: 2px 3px;
gap: 3px;
background-color: var(--color-fill-3);
border-radius: 2px;
box-sizing: border-box;
}
.preview-pill {
@@ -206,14 +206,14 @@ const appStore = useAppStore()
}
.preview-content {
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
justify-content: flex-start;
gap: 4px;
justify-content: flex-start;
min-width: 0;
padding-right: 1px;
box-sizing: border-box;
.preview-line {
height: 6px;
@@ -222,8 +222,8 @@ const appStore = useAppStore()
.preview-line {
display: block;
height: 5px;
width: 100%;
height: 5px;
background-color: var(--color-fill-2);
border-radius: 1px;
@@ -77,6 +77,8 @@
</a-dropdown>
</a-space>
</a-row>
<ChangePasswordModal ref="changePwdRef" />
</template>
<script setup lang="ts">
@@ -84,6 +86,7 @@ import { Button, Drawer, Message, Modal } from '@arco-design/web-vue'
import { useFullscreen } from '@vueuse/core'
import { useBreakpoint, useTheme } from '@/hooks'
import { useAppStore, useUserStore } from '@/stores'
import ChangePasswordModal from './ChangePasswordModal.vue'
import Notice from './Notice.vue'
import SettingDrawerPanel from './SettingDrawerPanel.vue'
@@ -107,6 +110,9 @@ const { isFullscreen, toggle } = useFullscreen()
/** 头像兜底文案:取姓名首字 */
const avatarText = computed(() => (userStore.name || '—').slice(0, 1))
/** 修改密码弹窗 */
const changePwdRef = useTemplateRef('changePwdRef')
/** 用户菜单配置 */
const USER_MENUS = [
{
@@ -114,11 +120,7 @@ const USER_MENUS = [
label: '修改密码',
icon: 'icon-unlock',
iconColor: 'primary',
onClick: () => {
// 后端 PUT /auth/password 尚未实现(属 M0 剩余任务),
// 这里给明确提示,而不是像模板那样假装成功
Message.info('修改密码功能待接入后端 PUT /auth/password')
}
onClick: () => changePwdRef.value?.open()
}
]
+14 -1
View File
@@ -5,7 +5,20 @@
<template>
<a-layout class="main">
<router-view v-slot="{ Component, route }">
<transition :name="appStore.pageTransitionName" mode="out-in" appear>
<!--
⚠️ 这里**不能加 `appear`**。
加了的后果是:首次渲染也被当成一次过渡,元素被加上
`zoom-fade-enter-from` + `zoom-fade-enter-active`(即 opacity:0 / scale(0.92))
之后**再也不会被清理** —— 因为进入动画的收尾依赖 transitionend 与
下一帧的补帧,而 `mode="out-in"` + `appear` + `keep-alive` 的组合下
没有"离场"元素时这条链路不成立。
表现极具迷惑性:**DOM 里内容齐全、接口数据也对、文字能选中,
就是人眼看不到**(计算样式 opacity 恒为 0),
看起来像"页面没渲染",实际是"渲染了但透明"。
2026-09-28 实测复现:整块内容区 opacity=0、transform=scale(0.92) 长时间不变。
首次加载本来也不需要入场动画,去掉 `appear` 即可。
-->
<transition :name="appStore.pageTransitionName" mode="out-in">
<keep-alive :include="(tabsStore.cacheList as string[])">
<component :is="Component" v-if="tabsStore.reloadFlag" :key="route.path" />
</keep-alive>
@@ -83,7 +83,6 @@ defineOptions({ name: 'MagicIcon' })
transform: rotate(90deg);
.magic-dot {
&::before,
&::after {
background: rgb(var(--primary-6));
+5 -5
View File
@@ -1,11 +1,6 @@
import { Card, Drawer, Modal } from '@arco-design/web-vue'
// 额外引入 Arco Design Icon图标库
import ArcoVueIcon from '@arco-design/web-vue/es/icon'
// ⚠️ Arco 组件样式必须显式引入:
// vite.config.ts 里 ArcoResolver 配了 sideEffect:false(样式不随组件自动引入),
// 原本靠 @arco-themes/vue-gi-demo 主题包带入;我们换成自己的主题后就没人带了。
// 不引的后果是「组件能用但完全没样式」,页面直接散架,且构建不报错。
import '@arco-design/web-vue/dist/arco.css'
import { createApp } from 'vue'
// 状态管理
import pinia from '@/stores'
@@ -13,6 +8,11 @@ import App from './App.vue'
// 自定义指令
import directives from './directives'
import router from './router'
// ⚠️ Arco 组件样式必须显式引入:
// vite.config.ts 里 ArcoResolver 配了 sideEffect:false(样式不随组件自动引入),
// 原本靠 @arco-themes/vue-gi-demo 主题包带入;我们换成自己的主题后就没人带了。
// 不引的后果是「组件能用但完全没样式」,页面直接散架,且构建不报错。
import '@arco-design/web-vue/dist/arco.css'
// 导入本项目主题(内部会覆盖 Arco 默认变量)
import '@/styles/arco-ui/index.less'
// 使用动画库
+5 -2
View File
@@ -148,8 +148,11 @@ const storeSetup = () => {
if (type === 'current') {
if (targetIndex < 0 || tabList.value[targetIndex].meta?.affix) return
removeTabByIndex(targetIndex)
if (currentPath === targetPath && tabList.value.length) {
jumpToTab(tabList.value[tabList.value.length - 1].path)
// at(-1) 可能是 undefined —— 上面的 length 判断在类型层面窄化不了它,
// 所以这里要真的接住 undefined 再取 path(严格模式下是 TS2532)
const last = tabList.value.at(-1)
if (currentPath === targetPath && last) {
jumpToTab(last.path)
}
return
}
+1 -1
View File
@@ -5,7 +5,7 @@ import type { LoginVO } from '@/apis/xt'
import { defineStore } from 'pinia'
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { login as loginApi, logout as logoutApi, getMe } from '@/apis/xt'
import { getMe, login as loginApi, logout as logoutApi } from '@/apis/xt'
import { buildXtRoutes } from '@/config/xt-routes'
import { resetRouter } from '@/router'
import { clearToken, getToken, setToken } from '@/utils/auth'
+9 -5
View File
@@ -28,12 +28,16 @@ NProgress.configure({ showSpinner: false })
/** 本项目后端的成功码 */
const CODE_SUCCESS = 0
/** 未登录 / token 失效 */
/**
* 未登录 / token 失效。
*
* 只有这一个业务码需要在这里特殊处理(清 token + 跳登录)。
* 40301 无权限与 42901 限流<b>不在这里判</b> —— 后端对这两个用的是**真实 HTTP 状态码**
* (403 / 429,见《03-接口设计》§1.3),由下面的传输层错误分支统一处理。
* 所以这里不要再补 40301 / 42901 的常量:加了也不会被走到,
* 反而会让人以为"业务码里也会出现 40301",从而在错的地方排查问题。
*/
const CODE_UNAUTHORIZED = 40101
/** 无权限(含跨租户) */
const CODE_FORBIDDEN = 40301
/** 限流 */
const CODE_RATE_LIMITED = 42901
/** 传输层 HTTP 状态码文案(业务错误不走这里,见下方响应拦截器) */
const StatusCodeMessage: Record<number, string> = {
+8 -3
View File
@@ -46,12 +46,17 @@ const menu = computed(() => findMenu((route.meta.menu as string) ?? ''))
/** 各菜单对应的里程碑(与《05-开发任务拆解》对齐) */
const MILESTONES: Record<string, string> = {
task: 'M1', form: 'M1', material: 'M1',
roster: 'M2', join: 'M2',
task: 'M1',
form: 'M1',
material: 'M1',
roster: 'M2',
join: 'M2',
cleanup: 'M3',
audit: 'M5',
export: 'M6',
user: 'M0 剩余', role: '二期', log: 'M0 剩余'
user: 'M0 剩余',
role: '二期',
log: 'M0 剩余'
}
const milestone = computed(() => MILESTONES[menu.value?.key ?? ''] ?? '规划中')
+6 -4
View File
@@ -74,11 +74,11 @@
</template>
<script setup lang="ts">
import { IconCheckCircle, IconFolder, IconIdcard, IconSafe, IconStamp } from '@arco-design/web-vue/es/icon'
import type { OrgTreeVO } from '@/apis/xt/org'
import { IconFolder, IconIdcard, IconSafe, IconStamp } from '@arco-design/web-vue/es/icon'
import * as orgApi from '@/apis/xt/org'
import * as tenantApi from '@/apis/xt/tenant'
import type { OrgTreeVO } from '@/apis/xt/org'
import { XT_MENUS, ROLE_NAMES } from '@/config/xt-routes'
import { ROLE_NAMES, XT_MENUS } from '@/config/xt-routes'
import { useUserStore } from '@/stores'
/**
@@ -101,7 +101,9 @@ const avatarText = computed(() => (userStore.name || '—').slice(0, 1))
const roleLabel = computed(() =>
userStore.roles.map((c) => ROLE_NAMES[c] ?? c).join(' / ')
)
const isPlatform = computed(() => userStore.menus?.includes('tenant') ?? false)
// 注意是 menuKeys 而不是 menus —— 写错不会报错,
// 只会让这个判断恒为 false(之前就是这样:平台管理员也看到"仅平台运营可见")
const isPlatform = computed(() => userStore.menuKeys?.includes('tenant') ?? false)
/** 已实现且当前角色可访问的页面 */
const shortcuts = computed(() =>
@@ -267,7 +267,6 @@ const features = [
}
@keyframes login-float {
0%,
100% {
transform: translateY(0);
+3 -4
View File
@@ -104,10 +104,10 @@
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import { Message } from '@arco-design/web-vue'
import { encryptPassword, fetchPublicKey, resetPublicKey } from '@/utils/rsa'
import { getCaptcha } from '@/apis/xt'
import { useLoading } from '@/hooks'
import { useTabsStore, useUserStore } from '@/stores'
import { encryptPassword, fetchPublicKey, resetPublicKey } from '@/utils/rsa'
import LoginLeft from './LoginLeft.vue'
import LoginPalette from './LoginPalette.vue'
@@ -128,7 +128,6 @@ defineOptions({ name: 'Login' })
const router = useRouter()
const userStore = useUserStore()
const tabsStore = useTabsStore()
const year = new Date().getFullYear()
const form = reactive({
username: '',
@@ -364,8 +363,8 @@ onMounted(() => {
&__captcha {
display: flex;
gap: 10px;
width: 100%;
align-items: center;
width: 100%;
}
&__captcha-img {
@@ -405,7 +404,7 @@ onMounted(() => {
border-top: 1px solid var(--color-border-2);
}
@media (max-width: 768px) {
@media (width <= 768px) {
&__mobile-brand {
display: flex;
}
+2 -3
View File
@@ -40,9 +40,9 @@
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import type { OrgTreeVO, OrgUpsertReq } from '@/apis/xt/org'
import { Message } from '@arco-design/web-vue'
import * as orgApi from '@/apis/xt/org'
import type { OrgTreeVO, OrgUpsertReq } from '@/apis/xt/org'
import { ORG_TYPE_NAMES } from '@/apis/xt/org'
/**
@@ -55,6 +55,7 @@ import { ORG_TYPE_NAMES } from '@/apis/xt/org'
*/
defineOptions({ name: 'OrgFormModal' })
const emit = defineEmits<{ 'save-success': [] }>()
const visible = ref(false)
const submitting = ref(false)
const detailId = ref<number | null>(null)
@@ -148,8 +149,6 @@ async function confirm() {
}
}
const emit = defineEmits<{ 'save-success': [] }>()
defineExpose({ add, edit })
</script>
+14 -5
View File
@@ -24,7 +24,7 @@
</a-empty>
<a-tree v-else v-model:selected-keys="selectedKeys" :data="treeData" :field-names="{ key: 'id', title: 'orgName', children: 'children' }"
block-node :default-expand-all @select="onSelect">
block-node :default-expand-all="true" @select="onSelect">
<template #title="node">
<span class="org__node">
<span class="org__node-name">{{ node.orgName }}</span>
@@ -116,10 +116,10 @@
</template>
<script setup lang="ts">
import type { OrgTreeVO } from '@/apis/xt/org'
import { Message } from '@arco-design/web-vue'
import { IconDelete, IconEdit, IconFolder, IconPlus } from '@arco-design/web-vue/es/icon'
import * as orgApi from '@/apis/xt/org'
import type { OrgTreeVO } from '@/apis/xt/org'
import { ORG_TYPE_NAMES } from '@/apis/xt/org'
import { useUserStore } from '@/stores'
import OrgFormModal from './OrgFormModal.vue'
@@ -187,13 +187,22 @@ async function load() {
}
}
function onSelect(keys: (string | number)[], data?: { node: OrgTreeVO }) {
/**
* 树节点选中。
*
* 第二个参数的类型要写成「宽进」的 `{ node?: unknown }` 而不是 `{ node: OrgTreeVO }`:
* Arco 的 `@select` 回调给的是 `{ selected?, selectedNodes, node?, e? }`,
* 把参数声明得比它窄,函数就不可赋值给它,vue-tsc 会在模板上报类型错(运行时其实是好的)。
* 这里收窄的动作放在函数体里做。
*/
function onSelect(keys: (string | number)[], data?: { node?: unknown }) {
const id = Number(keys[0] ?? '')
if (!id) {
selected.value = null
return
}
selected.value = data?.node?.id === id ? data.node : findById(tree.value, id)
const node = (data?.node ?? null) as OrgTreeVO | null
selected.value = node?.id === id ? node : findById(tree.value, id)
}
function onAdd(parent: OrgTreeVO | null) {
@@ -262,8 +271,8 @@ onMounted(load)
}
&__acts {
margin-top: 18px;
padding-top: 14px;
margin-top: 18px;
border-top: 1px solid var(--color-border-2);
}
}
+1 -1
View File
@@ -173,10 +173,10 @@
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import type { TenantCreateResult, TenantVO } from '@/apis/xt/tenant'
import { Message } from '@arco-design/web-vue'
import { IconCopy, IconPlus } from '@arco-design/web-vue/es/icon'
import * as tenantApi from '@/apis/xt/tenant'
import type { TenantCreateResult, TenantVO } from '@/apis/xt/tenant'
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
/**
@@ -0,0 +1,391 @@
<template>
<a-modal v-model:visible="visible" :title="created ? '账号已创建' : title" :width="560"
:mask-closable="false" :closable="!created" :footer="false">
<!-- ── 成功态:初始密码只显示这一次 ──────────────────── -->
<template v-if="created">
<a-alert type="success" :show-icon="false">
<template #title>
<strong>「{{ created.realName }}」的账号已创建。</strong>
请把下面的初始密码发给本人。
</template>
</a-alert>
<div class="user-form__pwd-box">
<div class="user-form__pwd-label">初始密码</div>
<div class="user-form__pwd-row">
<code class="user-form__pwd">{{ created.initialPassword }}</code>
<a-button type="primary" size="small" @click="copyPassword">
<template #icon><IconCopy /></template>
复制
</a-button>
</div>
<a-alert type="warning" :show-icon="false" class="user-form__pwd-warn">
<strong>这个密码只显示这一次。</strong>
关闭窗口后就查不到了 —— 库里存的是不可逆的哈希,忘了只能重新重置。
</a-alert>
</div>
<a-descriptions :column="1" size="small">
<a-descriptions-item label="登录账号">{{ created.username }}</a-descriptions-item>
<a-descriptions-item label="姓名">{{ created.realName }}</a-descriptions-item>
<a-descriptions-item label="角色">{{ created.roleNames.join('、') || '—' }}</a-descriptions-item>
</a-descriptions>
<div class="user-form__footer">
<a-button type="primary" @click="closeCreated">我已记下密码</a-button>
</div>
</template>
<!-- ── 表单态 ──────────────────────────────────────── -->
<template v-else>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="username" label="登录账号">
<a-input v-model="form.username" :disabled="isEdit" placeholder="如 zhangsan" allow-clear />
<template #extra>
<span v-if="isEdit" class="user-form__hint">
登录名不可修改:改了会让历史通知、导出包里的账号全对不上。
</span>
</template>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="realName" label="姓名">
<a-input v-model="form.realName" placeholder="如 张三" :max-length="64" allow-clear />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="phone" label="手机号">
<a-input v-model="form.phone" placeholder="11 位手机号" :max-length="11" allow-clear />
<template #extra>
<span class="user-form__hint">加密存储,列表只显示脱敏值。</span>
</template>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="orgId" label="所属组织">
<a-tree-select v-model="form.orgId" :data="orgTree" :field-names="ORG_FIELD_NAMES"
placeholder="不选表示不归属任何组织" allow-clear />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="用户类型">
<a-space>
<a-tag v-if="derivedUserType" color="arcoblue">
{{ USER_TYPE_NAMES[derivedUserType] ?? derivedUserType }}
</a-tag>
<span v-else-if="conflictTypes.length > 1" class="user-form__hint">
所选角色分属不同类别({{ conflictTypes.join('、') }}),请只选同一类的角色
</span>
<span v-else class="user-form__hint">选择角色后自动确定</span>
</a-space>
<template #extra>
<span class="user-form__hint">
由所选角色自动决定,不需要手选 —— 这样不会出现「学生拿着学校管理员权限」这类账号。
</span>
</template>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item field="roleCodes" label="角色">
<a-checkbox-group v-model="form.roleCodes">
<a-checkbox v-for="r in roleOptions" :key="r.value" :value="r.value">
{{ r.label }}
</a-checkbox>
</a-checkbox-group>
<template #extra>
<span class="user-form__hint">
可多选,权限取并集。内置角色的权限由系统决定,不能在这里改。
</span>
</template>
</a-form-item>
</a-col>
<a-col v-if="isEdit" :span="24">
<a-form-item field="status" label="状态">
<a-switch v-model="form.status" :checked-value="1" :unchecked-value="0" />
<span class="user-form__hint user-form__hint--inline">
{{ form.status === 1 ? '正常,可登录' : '停用,不能登录(数据保留)' }}
</span>
</a-form-item>
</a-col>
</a-row>
</a-form>
<div class="user-form__footer">
<a-space>
<a-button @click="close">取消</a-button>
<a-button type="primary" :loading="submitting" @click="confirm">
{{ isEdit ? '保存' : '创建账号' }}
</a-button>
</a-space>
</div>
</template>
</a-modal>
</template>
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import type { OrgTreeVO } from '@/apis/xt/org'
import { Message } from '@arco-design/web-vue'
import { IconCopy } from '@arco-design/web-vue/es/icon'
import { getOrgTree } from '@/apis/xt/org'
import * as userApi from '@/apis/xt/user'
import { deriveUserType, PLATFORM_ROLE_OPTIONS, ROLE_TO_USER_TYPE, SCHOOL_ROLE_OPTIONS, USER_TYPE_NAMES } from '@/apis/xt/user'
import { useUserStore } from '@/stores'
/**
* 账号新增 / 编辑弹窗。
*
* 三处值得留意的设计:
* 1. **初始密码只显示一次** —— 创建成功后不弹 toast 就完事,而是把弹窗切成成功态,
* 停下来让用户当场抄下来。库里是 BCrypt 哈希,关了窗口谁也还原不出来。
* 2. **角色选项按租户过滤** —— 平台运营角色只对平台租户显示。学校管理员
* 拿到它会直接越权到平台级(能开通租户、看全平台租户列表),
* 所以前端不显示、后端也会用 40301 拒绝。
* 3. **用户类型不让用户选,而是从角色推导** —— 二者一对一绑定,
* 让用户分别选就会产生"学生 + 学校管理员"这种自相矛盾的账号,
* 而服务端还会因此拒掉整个提交。改成推导后这类组合在界面上产生不了。
*/
defineOptions({ name: 'UserFormModal' })
const emit = defineEmits<{ 'save-success': [] }>()
const userStore = useUserStore()
const visible = ref(false)
const submitting = ref(false)
const detailId = ref<number | null>(null)
const orgTree = ref<OrgTreeVO[]>([])
const created = ref<{ username: string, realName: string, initialPassword: string, roleNames: string[] } | null>(null)
const formRef = useTemplateRef('formRef')
const isEdit = computed(() => !!detailId.value)
const title = computed(() => (isEdit.value ? '修改账号' : '新增账号'))
/** 平台租户下才提供平台运营角色 */
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
const ORG_FIELD_NAMES = { key: 'id', title: 'orgName', children: 'children' }
const getInitForm = () => ({
username: '',
realName: '',
phone: '',
orgId: undefined as number | undefined,
roleCodes: [] as string[],
status: 1
})
// 顺序要紧:下面几个 computed 读的是 form,所以 form 必须在前
const form = reactive(getInitForm())
const roleOptions = computed(() =>
isPlatformTenant.value ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS
)
/**
* 从所选角色推导用户类型。
*
* 用户类型不让用户手选:它与角色是**一对一绑定**的(见 ROLE_TO_USER_TYPE),
* 让用户分别选就会出现"学生 + 学校管理员"这种自相矛盾的账号,
* 而服务端还会因此拒掉整个提交。改成推导之后这类组合在界面上根本产生不了。
*/
const derivedUserType = computed(() => deriveUserType(form.roleCodes))
/** 所选角色跨越了多个用户类型时的类别名(用于提示为什么不能提交) */
const conflictTypes = computed(() => {
const names = new Set<string>()
for (const c of form.roleCodes) {
const t = ROLE_TO_USER_TYPE[c]
if (t) names.add(USER_TYPE_NAMES[t] ?? String(t))
}
return [...names]
})
const rules: FormInstance['rules'] = {
username: [
{ required: true, message: '请填写登录账号' },
{
match: /^[A-Z][\w.-]{1,63}$/i,
message: '只能以字母开头,可含字母、数字、下划线、点或短横线'
}
],
realName: [{ required: true, message: '请填写姓名' }],
phone: [
{ required: true, message: '请填写手机号' },
{ match: /^1[3-9]\d{9}$/, message: '手机号格式不正确' }
],
roleCodes: [{ required: true, message: '请至少选择一个角色' }]
}
async function loadOrgTree() {
if (orgTree.value.length) return
const res = await getOrgTree()
orgTree.value = res.data ?? []
}
/** 新增 */
async function add() {
detailId.value = null
created.value = null
Object.assign(form, getInitForm())
visible.value = true
await loadOrgTree()
}
/** 编辑 */
async function edit(row: userApi.UserVO) {
detailId.value = row.id
created.value = null
Object.assign(form, getInitForm())
form.username = row.username ?? ''
form.realName = row.realName
// 手机号只回脱敏值,编辑时留空表示"不改手机号"(后端会把空的手机号当作不修改)
form.phone = ''
form.orgId = row.orgId ?? undefined
// 用户类型不在这里赋值:它由 roleCodes 推导,见 derivedUserType
form.roleCodes = [...row.roleCodes]
form.status = row.status ?? 1
visible.value = true
await loadOrgTree()
}
function close() {
visible.value = false
}
function closeCreated() {
created.value = null
visible.value = false
}
async function copyPassword() {
const pwd = created.value?.initialPassword
if (!pwd) return
try {
await navigator.clipboard.writeText(pwd)
Message.success('已复制到剪贴板')
} catch {
Message.warning('浏览器不允许自动复制,请手动选中后复制')
}
}
async function confirm() {
const valid = await formRef.value?.validate()
if (valid) return
const phone = form.phone.trim()
if (!phone) {
// 编辑态也要求重填手机号:列表接口按 §1.7 只返回脱敏值(138****8888),
// 拿它当明文回填会把脱敏串写进库里,比"让用户重填一次"糟糕得多。
Message.warning('请填写手机号(列表只返回脱敏值,无法回填原号码,需重填)')
return
}
const userType = derivedUserType.value
if (userType === null) {
Message.warning('请选择角色(且需属于同一类),用户类型由它自动确定')
return
}
submitting.value = true
try {
if (isEdit.value) {
await userApi.updateUser(detailId.value!, {
realName: form.realName.trim(),
phone,
orgId: form.orgId ?? null,
roleCodes: form.roleCodes,
userType,
status: form.status
})
Message.success('已保存')
visible.value = false
emit('save-success')
return
}
const res = await userApi.addUser({
username: form.username.trim(),
realName: form.realName.trim(),
phone,
orgId: form.orgId ?? null,
roleCodes: form.roleCodes,
userType
})
const data = res.data
if (!data) {
Message.error('创建失败,请重试')
return
}
created.value = {
username: form.username.trim(),
realName: form.realName.trim(),
initialPassword: data.initialPassword,
roleNames: form.roleCodes.map(
(c) => roleOptions.value.find((r) => r.value === c)?.label ?? c
)
}
} finally {
submitting.value = false
}
}
defineExpose({ add, edit })
</script>
<style lang="scss" scoped>
.user-form {
&__hint {
font-size: 12px;
line-height: 1.7;
color: var(--color-text-3);
&--inline {
margin-left: 10px;
}
}
&__pwd-box {
margin: 16px 0;
}
&__pwd-label {
margin-bottom: 6px;
font-size: 12px;
color: var(--color-text-3);
}
&__pwd-row {
display: flex;
gap: 10px;
align-items: center;
}
&__pwd {
flex: 1;
padding: 8px 12px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 18px;
letter-spacing: 2px;
background: var(--color-fill-2);
border-radius: 4px;
}
&__pwd-warn {
margin-top: 10px;
}
&__footer {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
}
</style>
@@ -0,0 +1,381 @@
<template>
<a-modal v-model:visible="visible" :title="report ? '导入结果' : '批量导入账号'" :width="report ? 760 : 560"
:mask-closable="false" :footer="false" @cancel="close">
<!-- ── 第一步:选文件 ────────────────────────────── -->
<template v-if="!report">
<a-alert type="info" :show-icon="false" class="user-import__intro">
<template #title>
表格里需要有 <strong>姓名</strong> 与 <strong>账号</strong> 两列,
<strong>手机号</strong> 必填;<strong>组织</strong>、<strong>角色</strong> 可选。
表头在第几行<b>不用管</b>,系统会自己找。
</template>
</a-alert>
<a-form :model="formModel" layout="vertical">
<a-form-item label="Excel 文件">
<div class="user-import__pick">
<!--
这里用原生 input 而不是 a-upload:
a-upload 的 auto-upload=false 时,拿原始 File 对象要经由 fileItem.file,
各版本字段名不尽相同;而本场景只需要"选一个本地文件然后自己发请求",
原生 input 的行为是完全确定的,少一层不确定性。
-->
<input ref="fileInput" type="file" accept=".xlsx,.xls" class="user-import__file"
@change="onFileChange" />
<a-button @click="pickFile">
<template #icon><IconUpload /></template>
选择文件
</a-button>
<span v-if="file" class="user-import__filename">{{ file.name }}</span>
<span v-else class="user-import__hint">支持 .xlsx / .xls,不超过 10MB</span>
</div>
</a-form-item>
<a-form-item label="没有「角色」列时的默认角色">
<a-select v-model="formModel.defaultRoleCode" class="user-import__role">
<a-option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</a-option>
</a-select>
<template #extra>
<span class="user-import__hint">
表格里有「角色」列时以列为准;那一行没填角色才用这里的默认值。
</span>
</template>
</a-form-item>
</a-form>
<div class="user-import__footer">
<a-space>
<a-button @click="close">取消</a-button>
<a-button type="primary" :loading="submitting" :disabled="!file" @click="submit">
开始导入
</a-button>
</a-space>
</div>
</template>
<!-- ── 第二步:体检报告 ─────────────────────────── -->
<template v-else>
<a-row :gutter="12" class="user-import__stats">
<a-col :span="8">
<div class="user-import__stat">
<div class="user-import__stat-num">{{ report.total }}</div>
<div class="user-import__stat-label">参与导入的行数</div>
</div>
</a-col>
<a-col :span="8">
<div class="user-import__stat user-import__stat--ok">
<div class="user-import__stat-num">{{ report.success }}</div>
<div class="user-import__stat-label">成功创建</div>
</div>
</a-col>
<a-col :span="8">
<div class="user-import__stat" :class="report.failed ? 'user-import__stat--bad' : ''">
<div class="user-import__stat-num">{{ report.failed }}</div>
<div class="user-import__stat-label">未能导入</div>
</div>
</a-col>
</a-row>
<!-- 表头识别结果:行号 + 原文 + 列映射,三样一起给才核对得动 -->
<a-alert :type="report.errors.some((e) => e.field === 'realName' || e.field === 'username') ? 'warning' : 'success'"
:show-icon="false" class="user-import__header-alert">
<template #title>
系统把第 <strong>{{ report.headerRow }}</strong> 行当成了表头。
</template>
<template #default>
<div v-if="report.headerText" class="user-import__header-text">{{ report.headerText }}</div>
<div class="user-import__cols">
<span v-for="c in report.columns" :key="c.field" class="user-import__col-tag">
{{ c.fieldName }}
<em>{{ c.column }} 列</em>
</span>
<span v-if="!report.columns.length" class="user-import__hint">
没有识别到任何列 —— 请确认表格里有「姓名」和「账号」两列。
</span>
</div>
<div class="user-import__hint">
如果上面的行或列不对,请调整表格后重新导入;已经导入成功的账号不会重复创建。
</div>
</template>
</a-alert>
<!-- 失败明细 -->
<template v-if="report.errors.length">
<div class="user-import__section">未能导入的行({{ report.errors.length }})</div>
<a-table :data="report.errors" :pagination="false" row-key="row" size="small"
:max-height="220" :scroll="{ y: 220 }">
<template #columns>
<a-table-column title="Excel 行号" :width="100" align="center">
<template #cell="{ record }">第 {{ record.row }} 行</template>
</a-table-column>
<a-table-column title="字段" :width="90">
<template #cell="{ record }">
{{ IMPORT_FIELD_NAMES[record.field] ?? record.field }}
</template>
</a-table-column>
<a-table-column title="原值" data-index="value" :width="150" ellipsis tooltip />
<a-table-column title="原因" data-index="reason" />
</template>
</a-table>
</template>
<!-- 初始密码:只这一次 -->
<template v-if="report.initialPasswords.length">
<div class="user-import__section">
新账号的初始密码({{ report.initialPasswords.length }})
</div>
<a-alert type="warning" :show-icon="false">
<template #title>
<strong>这些密码只显示这一次。</strong>
关闭后无法再查看,请现在就复制下来分发给本人。
</template>
</a-alert>
<a-table :data="report.initialPasswords" :pagination="false" row-key="username" size="small"
:max-height="200" :scroll="{ y: 200 }">
<template #columns>
<a-table-column title="账号" data-index="username" :width="180" />
<a-table-column title="初始密码">
<template #cell="{ record }">
<code class="user-import__pwd">{{ record.password }}</code>
</template>
</a-table-column>
</template>
</a-table>
</template>
<div class="user-import__footer">
<a-space>
<a-button v-if="report.initialPasswords.length" @click="copyAll">
<template #icon><IconCopy /></template>
复制全部密码
</a-button>
<a-button type="primary" @click="finish">完成</a-button>
</a-space>
</div>
</template>
</a-modal>
</template>
<script setup lang="ts">
import type { UserImportResult } from '@/apis/xt/user'
import { Message } from '@arco-design/web-vue'
import { IconCopy, IconUpload } from '@arco-design/web-vue/es/icon'
import * as userApi from '@/apis/xt/user'
import { IMPORT_FIELD_NAMES, PLATFORM_ROLE_OPTIONS, SCHOOL_ROLE_OPTIONS } from '@/apis/xt/user'
import { useUserStore } from '@/stores'
/**
* 账号批量导入弹窗。
*
* 这个弹窗的重点不是"上传",而是**体检报告**:
* 实测的真实 Excel 里,表头一份在第 1 行、一份在第 6 行,列名也有多种写法。
* 所以导入不可能"静默全成功或全失败",必须把
* ① 系统认的是第几行、那行长什么样、认成了哪几列
* ② 哪几行没进去、为什么
* ③ 新账号的初始密码(只此一次)
* 三件事摊开给用户看,让他当场就能判断"这次导入能不能信"。
*/
defineOptions({ name: 'UserImportModal' })
const emit = defineEmits<{ 'save-success': [] }>()
const userStore = useUserStore()
const visible = ref(false)
const submitting = ref(false)
const file = ref<File | null>(null)
const report = ref<UserImportResult | null>(null)
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
const roleOptions = computed(() =>
isPlatformTenant.value ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS.filter((r) => r.value !== 'STUDENT')
)
/** a-form 需要一个 model;文件选择走原生 input,不进这个对象 */
const formModel = reactive({ defaultRoleCode: 'HEAD_TEACHER' })
function open() {
file.value = null
report.value = null
visible.value = true
}
function close() {
visible.value = false
}
function pickFile() {
fileInput.value?.click()
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
const picked = input.files?.[0] ?? null
if (picked && picked.size > 10 * 1024 * 1024) {
Message.warning('文件超过 10MB,请拆分后分批导入')
input.value = ''
file.value = null
return
}
file.value = picked
}
async function submit() {
if (!file.value) return
submitting.value = true
try {
const res = await userApi.importUsers(file.value, formModel.defaultRoleCode)
report.value = res.data ?? null
emit('save-success')
} finally {
submitting.value = false
}
}
async function copyAll() {
const rows = report.value?.initialPasswords ?? []
if (!rows.length) return
const text = rows.map((r) => `${r.username}\t${r.password}`).join('\r\n')
try {
await navigator.clipboard.writeText(text)
Message.success(`已复制 ${rows.length} 条(账号 + 密码,制表符分隔,可直接粘进 Excel)`)
} catch {
Message.warning('浏览器不允许自动复制,请手动选中后复制')
}
}
function finish() {
if (report.value?.initialPasswords.length) {
Message.warning('请确认已经复制并保存了初始密码 —— 关闭后无法再查看')
}
visible.value = false
}
defineExpose({ open })
</script>
<style lang="scss" scoped>
.user-import {
&__intro {
margin-bottom: 16px;
line-height: 1.8;
}
&__pick {
display: flex;
gap: 12px;
align-items: center;
}
&__file {
display: none;
}
&__filename {
font-size: 13px;
color: var(--color-text-1);
}
&__hint {
font-size: 12px;
line-height: 1.7;
color: var(--color-text-3);
}
&__role {
width: 200px;
}
&__footer {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
&__stats {
margin-bottom: 16px;
}
&__stat {
padding: 12px 16px;
text-align: center;
background: var(--color-fill-2);
border-radius: 6px;
&--ok .user-import__stat-num {
color: rgb(var(--success-6));
}
&--bad .user-import__stat-num {
color: rgb(var(--danger-6));
}
}
&__stat-num {
font-size: 24px;
font-weight: 600;
line-height: 1.4;
color: var(--color-text-1);
}
&__stat-label {
font-size: 12px;
color: var(--color-text-3);
}
&__header-alert {
margin-bottom: 16px;
}
&__header-text {
padding: 8px 10px;
margin: 6px 0 8px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
line-height: 1.8;
color: var(--color-text-2);
word-break: break-all;
background: var(--color-bg-2);
border-radius: 4px;
}
&__cols {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
}
&__col-tag {
padding: 2px 8px;
font-size: 12px;
color: var(--color-text-2);
background: var(--color-fill-2);
border-radius: 3px;
em {
margin-left: 4px;
font-style: normal;
color: rgb(var(--primary-6));
}
}
&__section {
margin: 16px 0 8px;
font-size: 13px;
font-weight: 500;
color: var(--color-text-1);
}
&__pwd {
padding: 2px 6px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 13px;
letter-spacing: 1px;
background: var(--color-fill-2);
border-radius: 3px;
}
}
</style>
+437
View File
@@ -0,0 +1,437 @@
<template>
<GiPageLayout>
<!-- 左:组织树,用于按院系/班级过滤账号 -->
<template #left>
<a-card :bordered="false" class="user__tree-card">
<template #title>
<a-space>
<IconFolder />
<span>组织</span>
</a-space>
</template>
<a-spin v-if="orgLoading" class="user__loading" />
<template v-else>
<a-button long size="small" :type="orgId === undefined ? 'primary' : 'outline'"
class="user__all-btn" @click="onOrgSelect(undefined)">
全部账号
</a-button>
<a-tree v-if="orgTree.length" :data="orgTree" :field-names="ORG_FIELD_NAMES" block-node
:default-expand-all="true" :selected-keys="orgId === undefined ? [] : [orgId]"
@select="onOrgSelect" />
<a-empty v-else description="还没有组织" />
</template>
</a-card>
</template>
<a-card :bordered="false">
<template #title>账号</template>
<template #extra>
<span class="user__hint">共 {{ total }} 个账号</span>
</template>
<div class="g-row-tool">
<a-space wrap>
<a-button v-if="canManage" type="primary" size="small" @click="onAdd">
<template #icon><IconPlus /></template>
新增账号
</a-button>
<a-button v-if="canManage" size="small" @click="onImport">
<template #icon><IconUpload /></template>
批量导入
</a-button>
</a-space>
<a-space wrap>
<a-button v-for="c in statusChips" :key="String(c.value)"
:type="statusFilter === c.value ? 'primary' : 'outline'" size="small" @click="setStatus(c.value)">
{{ c.label }}
</a-button>
<a-select v-model="roleFilter" placeholder="全部角色" allow-clear class="user__role-filter"
@change="search">
<a-option v-for="r in allRoleOptions" :key="r.value" :value="r.value">{{ r.label }}</a-option>
</a-select>
<a-input-search v-model="query.keyword" placeholder="搜索姓名或账号" allow-clear class="user__search"
@search="search" @clear="search" />
</a-space>
</div>
<!--
⚠️ `:scroll` 不是可选的:Arco 的 `fixed: 'right'` 列**必须**配合 scroll 配置
才会预留位置;不加的话那一列会直接浮在其它列上面,
看起来像"操作列插到手机号前面了"。
总列宽 1140,这里给 1160 让它在常见分辨率下不出现横向滚动条、
窄屏时又能正常滚动并把操作列钉在右边。
-->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 1160 }" @page-change="onPageChange">
<template #columns>
<a-table-column title="姓名 / 账号" :width="190">
<template #cell="{ record }">
<div class="user__name">{{ record.realName }}</div>
<div class="user__account">{{ record.username ?? '(未设账号)' }}</div>
</template>
</a-table-column>
<a-table-column title="角色" :width="200">
<template #cell="{ record }">
<a-space v-if="record.roleNames.length" wrap size="mini">
<a-tag v-for="n in record.roleNames" :key="n" size="small">{{ n }}</a-tag>
</a-space>
<span v-else class="user__muted">—</span>
</template>
</a-table-column>
<a-table-column title="所属组织" data-index="orgName" :width="160">
<template #cell="{ record }">
{{ record.orgName ?? '—' }}
</template>
</a-table-column>
<a-table-column title="手机号" :width="140">
<template #cell="{ record }">
<span class="user__mono">{{ record.phoneMasked ?? '—' }}</span>
</template>
</a-table-column>
<!--
这里刻意**不放「用户类型」列**。
`userType` 与角色是一对一推导的(见 apis/xt/user.ts 的 ROLE_TO_USER_TYPE),
单独列出来就是「角色:学校管理员 / 类型:学校管理员」这种重复,
看着像 bug。类型仍由接口返回,需要时在详情里看即可 —— 列表以「角色」为准。
-->
<a-table-column title="状态" :width="100" align="center">
<template #cell="{ record }">
<a-tag :color="USER_STATUS[record.status]?.color ?? 'gray'">
{{ USER_STATUS[record.status]?.text ?? '未知' }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="最后登录" :width="150">
<template #cell="{ record }">
<span v-if="record.lastLoginTime" class="user__muted">
{{ record.lastLoginTime.replace('T', ' ').slice(0, 16) }}
</span>
<span v-else class="user__muted">从未登录</span>
</template>
</a-table-column>
<a-table-column title="操作" :width="200" align="center" fixed="right">
<template #cell="{ record }">
<a-space v-if="canManage" size="mini">
<a-link @click="onEdit(record)">编辑</a-link>
<a-link @click="onResetPassword(record)">重置密码</a-link>
<a-link v-if="record.status === 1" status="warning" @click="onToggleStatus(record, 0)">
停用
</a-link>
<a-link v-else status="success" @click="onToggleStatus(record, 1)">启用</a-link>
</a-space>
<span v-else class="user__muted">只读</span>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<UserFormModal ref="formModalRef" @save-success="load" />
<UserImportModal ref="importModalRef" @save-success="load" />
<!-- 重置密码结果:与创建一样,只显示这一次 -->
<a-modal v-model:visible="resetVisible" title="密码已重置" :width="460" :mask-closable="false"
:footer="false" :closable="false">
<a-alert type="success" :show-icon="false">
<template #title>
账号 <strong>{{ resetResult?.username }}</strong> 的密码已重置。
该账号此前的<b>登录锁定也已一并解除</b>。
</template>
</a-alert>
<div class="user__pwd-box">
<div class="user__pwd-row">
<code class="user__pwd">{{ resetResult?.newPassword }}</code>
<a-button type="primary" size="small" @click="copyResetPassword">
<template #icon><IconCopy /></template>
复制
</a-button>
</div>
<a-alert type="warning" :show-icon="false" class="user__pwd-warn">
<strong>只显示这一次。</strong>关闭后无法再查看,请现在发给本人。
</a-alert>
</div>
<div class="user__footer">
<a-button type="primary" @click="resetVisible = false">我已记下密码</a-button>
</div>
</a-modal>
</GiPageLayout>
</template>
<script setup lang="ts">
import type { OrgTreeVO } from '@/apis/xt/org'
import type { UserVO } from '@/apis/xt/user'
import { Message, Modal } from '@arco-design/web-vue'
import { IconCopy, IconFolder, IconPlus, IconUpload } from '@arco-design/web-vue/es/icon'
import { getOrgTree } from '@/apis/xt/org'
import * as userApi from '@/apis/xt/user'
import { PLATFORM_ROLE_OPTIONS, SCHOOL_ROLE_OPTIONS, USER_STATUS } from '@/apis/xt/user'
import { useUserStore } from '@/stores'
import UserFormModal from './UserFormModal.vue'
import UserImportModal from './UserImportModal.vue'
/**
* 账号管理(《03-接口设计》§5.6 ~ §5.9)。
*
* 形态:左组织树 + 右列表,与「组织架构」页同一套主从布局 —— 学校最常问的
* 是"这个班有哪些账号",从树点进去比在搜索框里回想姓名快得多。
*
* 两处按产品约束做的取舍:
* 1. **手机号只显示脱敏值**,因此编辑时无法回填原号码,"不改手机号"是不支持的 ——
* 要改就得重填。这是「列表接口不返回明文」(§1.7)带来的必然结果。
* 2. **停用 ≠ 删除**。停用只改 status、可再启用;删除是软删、会从列表消失。
* 日常"某人离职"用停用,建错了才用删除。
*/
defineOptions({ name: 'User' })
const userStore = useUserStore()
const canManage = computed(() => userStore.permissions.includes('user:manage'))
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
const ORG_FIELD_NAMES = { key: 'id', title: 'orgName', children: 'children' }
const loading = ref(false)
const orgLoading = ref(false)
const list = ref<UserVO[]>([])
const total = ref(0)
const orgTree = ref<OrgTreeVO[]>([])
const orgId = ref<number | undefined>(undefined)
const roleFilter = ref<string | undefined>(undefined)
const statusFilter = ref<number | undefined>(1)
const query = reactive({ pageNum: 1, pageSize: 10, keyword: '' })
const formModalRef = useTemplateRef('formModalRef')
const importModalRef = useTemplateRef('importModalRef')
const resetVisible = ref(false)
const resetResult = ref<userApi.UserResetResult | null>(null)
const statusChips = [
{ label: '正常', value: 1 },
{ label: '已停用', value: 0 },
{ label: '全部', value: undefined }
]
/** 角色筛选的候选:平台租户用平台那套,学校用学校那套 */
const allRoleOptions = computed(() =>
isPlatformTenant.value
? PLATFORM_ROLE_OPTIONS
: [...SCHOOL_ROLE_OPTIONS]
)
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
total: total.value,
showTotal: true,
showPageSize: true
}))
async function loadOrgTree() {
orgLoading.value = true
try {
const res = await getOrgTree()
orgTree.value = res.data ?? []
} finally {
orgLoading.value = false
}
}
async function load() {
loading.value = true
try {
const res = await userApi.getUserList({
keyword: query.keyword.trim() || undefined,
orgId: orgId.value,
roleCode: roleFilter.value,
status: statusFilter.value,
pageNum: query.pageNum,
pageSize: query.pageSize
})
list.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
} finally {
loading.value = false
}
}
function search() {
query.pageNum = 1
load()
}
function setStatus(v: number | undefined) {
statusFilter.value = v
search()
}
function onPageChange(page: number) {
query.pageNum = page
load()
}
function onOrgSelect(keys: (string | number)[] | number | undefined) {
// a-button 直接调时传的是 undefined;a-tree 的 @select 传的是 keys 数组
const raw = Array.isArray(keys) ? keys[0] : keys
orgId.value = raw === undefined || raw === '' ? undefined : Number(raw)
search()
}
function onAdd() {
formModalRef.value?.add()
}
function onEdit(row: UserVO) {
formModalRef.value?.edit(row)
}
function onImport() {
importModalRef.value?.open()
}
async function onToggleStatus(row: UserVO, status: number) {
const action = status === 1 ? '启用' : '停用'
if (status === 0) {
return new Promise<void>((resolve) => {
Modal.warning({
title: `确认${action}「${row.realName}」?`,
content: '停用后该账号将无法登录,但数据保留,可以随时再启用。',
hideCancel: false,
onBeforeOk: async () => {
await userApi.setUserStatus(row.id, status)
Message.success(`已${action}`)
await load()
resolve()
return true
},
onCancel: () => resolve()
})
})
}
await userApi.setUserStatus(row.id, status)
Message.success(`已${action}`)
await load()
}
async function onResetPassword(row: UserVO) {
Modal.warning({
title: `确认重置「${row.realName}」的密码?`,
content: '系统会生成一个新的初始密码,旧密码立即失效。该账号的登录锁定也会一并解除。',
hideCancel: false,
onBeforeOk: async () => {
const res = await userApi.resetUserPassword(row.id, { mode: 'GENERATE' })
resetResult.value = res.data ?? null
resetVisible.value = true
return true
}
})
}
async function copyResetPassword() {
const pwd = resetResult.value?.newPassword
if (!pwd) return
try {
await navigator.clipboard.writeText(pwd)
Message.success('已复制到剪贴板')
} catch {
Message.warning('浏览器不允许自动复制,请手动选中后复制')
}
}
onMounted(() => {
loadOrgTree()
load()
})
</script>
<style lang="scss" scoped>
.user {
&__tree-card {
height: 100%;
}
&__loading {
display: block;
padding: 40px 0;
text-align: center;
}
&__all-btn {
margin-bottom: 10px;
}
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__role-filter {
width: 130px;
}
&__search {
width: 220px;
}
&__name {
font-weight: 500;
color: var(--color-text-1);
}
&__account {
font-size: 12px;
color: var(--color-text-3);
}
&__mono {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 13px;
}
&__muted {
color: var(--color-text-3);
}
&__pwd-box {
margin: 16px 0 0;
}
&__pwd-row {
display: flex;
gap: 10px;
align-items: center;
}
&__pwd {
flex: 1;
padding: 8px 12px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 18px;
letter-spacing: 2px;
background: var(--color-fill-2);
border-radius: 4px;
}
&__pwd-warn {
margin-top: 10px;
}
&__footer {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
}
</style>
+10 -8
View File
@@ -3,12 +3,14 @@
"compilerOptions": {
"target": "ES2020", // 指定 ECMAScript 目标版本: 'ES3' (default), 'ES5', 'ES6'/'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'
"jsx": "preserve", // 指定 jsx 代码的生成: 'preserve', 'react-native', or 'react'
"jsxFactory": "h", // 当使用经典的JSX运行时编译JSX元素时,更改.js文件中调用的函数,默认:React.createElement
"jsxFragmentFactory": "Fragment", // 指定 JSX 片段工厂函数在指定了 jsxFactory 编译器选项的情况下针对 react JSX 发出时使用 指定使用模块: 'commonjs', 'amd', 'system', 'umd' or 'es2015'
"jsxFactory": "h", // 当使用经典的JSX运行时,更改.js文件中调用的函数,默认:React.createElement
"jsxFragmentFactory": "Fragment", // 指定 JSX 片段工厂函数
"lib": [
"ES2020",
"ES2022",
"DOM"
], // 指定要包含在编译中的库文件
], // 指定要包含在编译中的库文件。必须含 ES2022 ——
// gi-demo 的 useBreakpoint / useTabsStore 用了 Array.prototype.at(),
// 停在 ES2020 会让 `pnpm typecheck` 报 TS2550(运行期是好的:Vite 按浏览器实际能力打包)
"baseUrl": ".", // 用于解析非相对模块名称的基目录
"module": "ES2020", //
"moduleResolution": "bundler",
@@ -17,15 +19,15 @@
"src/*"
] // 模块名到基于 baseUrl 的路径映射的列表
},
"resolveJsonModule": true, // 是否解析 JSON 模块
"resolveJsonModule": true, // 是否解析 JSON 文件
"allowJs": true, // 允许编译 javascript 文件
"checkJs": true, // 报告 javascript 文件中的错误
"strict": true, // 开启所有严格的类型检查
"noImplicitAny": false,
"sourceMap": true, // 是否生成相应的Map映射的文件,默认:false
"esModuleInterop": true, // 是否通过为所有导入模块创建命名空间对象,允许CommonJS和ES模块之间的互操作性,开启改选项时,也自动开启allowSyntheticDefaultImports选项,默认:false
"skipLibCheck": true, // 是否跳过声明文件的类型检查,这可以在编译期间以牺牲类型系统准确性为代价来节省时间,默认:false
"ignoreDeprecations": "5.0" // 选项 importsNotUsedAsValues 已弃用,并将在 TypeScript 5.5 中停止工作。指定 compilerOption 的 "ignoreprecations ": "5.0" 来消除这个错误,使用 verbatimModuleSyntax 代替
"esModuleInterop": true, // 是否通过为所有导入模块创建命名空间对象,允许CommonJS和ES模块之间的互操作性
"skipLibCheck": true, // 是否跳过声明文件的类型检查
"ignoreDeprecations": "5.0" // 消除 importsNotUsedAsValues 的弃用报错
},
"include": [
"src/**/*",
+101
View File
@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""
清理 E2E 崩掉时残留在 demo 租户里的测试账号。
背景:e2e-m0-user.py 在 [5] 自锁防线那一段曾因脚本自身 bug 抛异常退出,
没有走到 [14] 的清理,于是 demo 租户里留下了 `dup*` / `t*` / `imp*` 之类的测试账号。
它们会出现在账号管理页上,必须清掉。
"""
import base64
import json
import os
import re
import sys
import urllib.error
import urllib.request
sys.stdout.reconfigure(encoding='utf-8')
BASE = "http://localhost:8080"
PUB = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"deploy", "rsa-public-key-dev.pem")
# E2E 造账号时用的前缀(见 scripts/e2e-m0-user.py)
TEST_PATTERNS = [
re.compile(r'^dup[0-9a-f]{6}$'), # 重名测试
re.compile(r'^t[0-9a-f]{6}$'), # 测试班主任
re.compile(r'^ht[0-9a-f]{6}$'), # 班主任回归护栏
re.compile(r'^imp[123][0-9a-f]{6}$'), # 导入进来的
re.compile(r'^esc2?[0-9a-f]{6}$'), # 提权测试(正常不该存在)
re.compile(r'^mix[0-9a-f]{6}$'), # 类型不符测试(正常不该存在)
re.compile(r'^bad[0-9a-f]{6}$'), # 手机号错(正常不该存在)
]
def rsa(plain: str) -> str:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
with open(PUB, "rb") as f:
pub = serialization.load_pem_public_key(f.read())
return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
def call(method: str, path: str, token: str | None = None, body: dict | None = None):
headers = {"Content-Type": "application/json; charset=utf-8"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=25) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
try:
return json.loads(e.read().decode())
except Exception:
return {"code": e.code}
# 登录
r = call("POST", "/api/v1/auth/login",
body={"username": "admin", "password": rsa("Admin@123"), "tenantCode": "demo"})
if r.get("code") != 0:
sys.exit(f"登录失败:{r}")
token = r["data"]["token"]
admin_uid = r["data"]["user"]["id"]
print(f" 已登录(admin uid={admin_uid})")
# 拉全量
r = call("GET", "/api/v1/users?pageSize=200", token)
users = (r.get("data") or {}).get("list") or []
print(f" demo 租户共 {len(users)} 个账号")
victims = []
for u in users:
if u["id"] == admin_uid:
continue
uname = u.get("username") or ""
if any(p.match(uname) for p in TEST_PATTERNS):
victims.append(u)
if not victims:
print(" ✓ 没有残留的测试账号")
sys.exit(0)
print(f" 发现 {len(victims)} 个残留测试账号:")
for v in victims:
print(f" id={v['id']:<4} username={v.get('username'):<14} realName={v.get('realName')}")
print()
for v in victims:
res = call("DELETE", f"/api/v1/users/{v['id']}", token)
flag = "✓" if res.get("code") == 0 else "✗"
print(f" {flag} 删除 {v.get('username')} code={res.get('code')}")
# 复核
r = call("GET", "/api/v1/users?pageSize=200", token)
left = (r.get("data") or {}).get("list") or []
print()
print(f" 清理后剩 {len(left)} 个账号:")
for u in left:
print(f" id={u['id']:<4} username={u.get('username'):<14} realName={u.get('realName')} 角色={u.get('roleNames')}")
+661
View File
@@ -0,0 +1,661 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
账号管理(M0 剩余)验收测试。
把《03-接口设计》§5.6~§5.9 的账号接口 + 《05-开发任务拆解》里 M0 的验收标准
变成可重复执行的程序:
[x] 账号增删改查、启用/停用、重置密码、修改自己密码
[x] Excel 批量导入(表头不固定时能自动定位,并回传体检报告)
[x] 权限:没有 user:view 的人看不到账号列表
[x] **越权防线**:学校管理员不能给自己人分配平台运营角色
[x] **自锁防线**:不能停用自己、不能取消本单位最后一个管理员
用法
----
python scripts/e2e-m0-user.py
python scripts/e2e-m0-user.py --base http://localhost:8080
退出码:0 = 全部通过;1 = 有失败项(可直接用于 CI)。
依赖:cryptography(RSA 加密登录密码)、openpyxl(构造导入用的 Excel)
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
# 与 application-dev.yml 的 REDIS_PASSWORD 默认值一致,仅用于 E2E 直写锁键
REDIS_PWD = os.environ.get("REDIS_PASSWORD", "redis_dev_pwd")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
# 本次测试创建出来的账号,收尾时清理
CREATED_IDS: list[int] = []
# ── 断言 ──────────────────────────────────────────────
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def show(body: dict, indent: int = 9) -> None:
"""
打印完整响应体。
有些失败只看 msg 是查不动的:参数校验失败时 msg 只有一句
"存在阻断性校验问题,无法提交",真正是哪个字段错了在 data.problems 里。
"""
pad = " " * indent
text = json.dumps(body, ensure_ascii=False)
print(f"{pad}完整响应:{text[:400]}{'…' if len(text) > 400 else ''}")
def redis_set(key: str, value: str, ttl_seconds: int) -> bool:
"""
直接往 Redis 里写一个键。
只用在「验证登录锁定会被重置密码解除」这一项上:
正常路径下要触发锁定得先连续失败、再通过图形验证码继续失败 ——
脚本没法自动识别验证码图片,所以这里直接把锁键写进去,
模拟"本人密码忘了、反复试错把账号试锁了"这个真实场景。
"""
try:
r = subprocess.run(
["docker", "exec", "xt-redis", "redis-cli",
"-a", REDIS_PWD, "--no-auth-warning", "SET", key, value, "EX", str(ttl_seconds)],
capture_output=True, text=True, timeout=25)
return r.returncode == 0 and "OK" in (r.stdout or "")
except Exception:
return False
# ── HTTP ────────────────────────────────────────────
def rsa_encrypt(plain: str) -> str:
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
except ImportError:
sys.exit("缺少依赖:pip install cryptography")
with open(PUB_KEY, "rb") as f:
public_key = serialization.load_pem_public_key(f.read())
cipher = public_key.encrypt(plain.encode("utf-8"), padding.PKCS1v15())
return base64.b64encode(cipher).decode("ascii")
def call(method: str, url: str, token: str | None = None,
body: dict | None = None, timeout: int = 30) -> tuple[int, dict]:
"""返回 (http_status, 响应体)。业务失败也是 200 + code!=0。"""
headers = {"Content-Type": "application/json; charset=utf-8"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8")
try:
return e.code, json.loads(raw)
except json.JSONDecodeError:
return e.code, {"raw": raw}
def post_multipart(url: str, token: str, file_path: str,
field: str = "file") -> tuple[int, dict]:
"""multipart 上传(导入接口用)。"""
boundary = "----xt" + uuid.uuid4().hex
filename = os.path.basename(file_path)
with open(file_path, "rb") as f:
content = f.read()
parts: list[bytes] = []
parts.append(f"--{boundary}\r\n".encode())
parts.append(
f'Content-Disposition: form-data; name="{field}"; filename="{filename}"\r\n'.encode())
parts.append(b"Content-Type: application/vnd.openxmlformats-officedocument."
b"spreadsheetml.sheet\r\n\r\n")
parts.append(content)
parts.append(f"\r\n--{boundary}--\r\n".encode())
payload = b"".join(parts)
req = urllib.request.Request(
url, data=payload, method="POST",
headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Authorization": f"Bearer {token}",
})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8")
try:
return e.code, json.loads(raw)
except json.JSONDecodeError:
return e.code, {"raw": raw}
# ── 业务封装 ──────────────────────────────────────────
def login(base: str, username: str, password: str,
tenant_code: str | None = None) -> tuple[int, dict]:
payload = {"username": username, "password": rsa_encrypt(password)}
if tenant_code:
payload["tenantCode"] = tenant_code
return call("POST", f"{base}/api/v1/auth/login", body=payload)
def main() -> int:
ap = argparse.ArgumentParser(description="账号管理 E2E")
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"),
help="学校租户的租户码(用 admin 登录时需要)")
ap.add_argument("--admin", default="admin")
ap.add_argument("--password", default="Admin@123")
args = ap.parse_args()
base = args.base.rstrip("/")
print("=" * 70)
print(f"账号管理 E2E base={base} 租户={args.tenant} 管理员={args.admin}")
print("=" * 70)
# ── 0. 登录 ────────────────────────────────────────
print("\n[0] 准备:管理员登录")
status, body = login(base, args.admin, args.password, args.tenant)
if body.get("code") != 0:
fail("管理员登录", f"HTTP {status} code={body.get('code')} msg={body.get('msg')}")
return 1
token = body["data"]["token"]
admin_uid = body["data"]["user"]["id"]
ok(f"管理员登录(uid={admin_uid},角色={body['data']['roleCodes']})")
# ── 1. 权限 ────────────────────────────────────────
print("\n[1] 访问控制")
status, body = call("GET", f"{base}/api/v1/users")
check("未登录访问 /users 返回 40101",
body.get("code") == 40101, f"实际 code={body.get('code')}")
# ── 2. 新增 ────────────────────────────────────────
print("\n[2] 新增账号")
suffix = uuid.uuid4().hex[:6]
teacher_name = f"测试班主任{suffix}"
teacher_user = f"t{suffix}"
teacher_phone = "13800138001"
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": teacher_user,
"realName": teacher_name,
"phone": teacher_phone,
"roleCodes": ["HEAD_TEACHER"],
"userType": 3,
})
created = body.get("data") or {}
if not check("新增班主任账号成功", body.get("code") == 0,
f"HTTP {status} code={body.get('code')} msg={body.get('msg')}"):
return 1
teacher_uid = created["id"]
teacher_pwd = created["initialPassword"]
CREATED_IDS.append(teacher_uid)
check("返回了初始密码", bool(teacher_pwd), "initialPassword 为空")
print(f" → id={teacher_uid} 账号={teacher_user} 初始密码={teacher_pwd}")
# 用初始密码登录
status, body = login(base, teacher_user, teacher_pwd, args.tenant)
if not check("新账号能用初始密码登录", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}"):
return 1
teacher_token = body["data"]["token"]
check("新账号拿到 HEAD_TEACHER 角色",
"HEAD_TEACHER" in (body["data"]["roleCodes"] or []),
f"roleCodes={body['data']['roleCodes']}")
check("班主任没有 user:view,访问 /users 被拒",
call("GET", f"{base}/api/v1/users", teacher_token)[1].get("code") == 40301,
"班主任竟然能看账号列表")
# ── 3. 字段校验 ────────────────────────────────────
print("\n[3] 字段校验")
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": "dup" + suffix, "realName": "重名测试",
"phone": "13800138002", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
})
if body.get("code") == 0:
CREATED_IDS.append(body["data"]["id"])
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": teacher_user, "realName": "重复账号",
"phone": "13800138003", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
})
check("账号重复被拒", body.get("code") != 0,
f"重复账号竟然建成功了 code={body.get('code')}")
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": "bad" + suffix, "realName": "手机号错",
"phone": "1380013", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
})
check("手机号格式错被拒", body.get("code") != 0,
f"非法手机号竟然通过了 code={body.get('code')}")
# ── 4. 越权防线(最关键)──────────────────────────
print("\n[4] 越权防线:学校管理员不能分配平台运营角色")
# 学校管理员用自己租户的 token 建账号,并试图授予 PLATFORM_ADMIN。
# 若不拦,这个账号会带上 tenant:manage,而 /tenants 只校验权限码、
# 不校验"是否真的在平台租户下" → 学校管理员即可开通租户、拉全平台租户列表。
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": "esc" + suffix,
"realName": "提权测试",
"phone": "13800138004",
"roleCodes": ["PLATFORM_ADMIN"],
"userType": 3,
})
check("分配 PLATFORM_ADMIN 被拒(40301)",
body.get("code") == 40301,
f"竟然放行了!code={body.get('code')} msg={body.get('msg')}")
check("该账号没有被创建出来",
call("GET", f"{base}/api/v1/users?keyword=esc{suffix}", token)[1]
.get("data", {}).get("total", -1) == 0,
"越权账号已经落库")
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": "esc2" + suffix, "realName": "提权测试2",
"phone": "13800138005", "roleCodes": ["不存在的角色"], "userType": 3,
})
check("未知角色被拒", body.get("code") != 0,
f"未知角色竟然通过了 code={body.get('code')}")
# 用户类型与角色必须自洽(§2.7 的映射:SCHOOL_ADMIN→2、HEAD_TEACHER→3)
# 这里是"给了 3 却是学校管理员角色"的组合,必须被拒
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": "mix" + suffix, "realName": "类型不符",
"phone": "13800138006", "roleCodes": ["SCHOOL_ADMIN"], "userType": 3,
})
check("「用户类型 3(班主任)+ 学校管理员角色」被拒", body.get("code") != 0,
f"矛盾组合竟然通过了 code={body.get('code')}")
# 回归护栏:班主任 = 用户类型 3。
# ⚠️ 曾经因为照 sys_user 的旧列注释(3=社会人员)写校验,
# 把「3」当成一期不支持的类型直接拒掉 —— 而 3 恰恰是最常用的班主任。
# 这条断言就是防止那个误判再回来。
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": "ht" + suffix, "realName": "班主任回归",
"phone": "13800138007", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
})
check("班主任(用户类型 3)能正常创建 —— 不是「社会人员(二期)」",
body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
if body.get("code") == 0:
CREATED_IDS.append(body["data"]["id"])
# 角色与用户类型两个字段都要回填正确
st2, bd2 = call("GET", f"{base}/api/v1/users?keyword=ht{suffix}", token)
row2 = next((h for h in (bd2.get("data") or {}).get("list", [])
if h["id"] == body["data"]["id"]), None)
check("列表回填的 userType 也是 3",
row2 is not None and row2.get("userType") == 3,
f"userType={row2.get('userType') if row2 else 'N/A'}")
# ── 5. 自锁防线 ────────────────────────────────────
print("\n[5] 自锁防线")
status, body = call("PUT", f"{base}/api/v1/users/{admin_uid}/status?status=0", token)
check("不能停用当前登录的账号", body.get("code") != 0,
f"竟然停用了自己 code={body.get('code')}")
status, body = call("DELETE", f"{base}/api/v1/users/{admin_uid}", token)
check("不能软删当前登录的账号", body.get("code") != 0,
f"竟然删了自己 code={body.get('code')}")
# 唯一管理员把自己降级成普通角色 → 会锁死租户,必须拦
# (realName 从 /auth/me 取,不要复用上面那个已经被 DELETE 用掉的 body)
_, me = call("GET", f"{base}/api/v1/auth/me", token)
admin_name = (((me or {}).get("data") or {}).get("user") or {}).get("realName") or args.admin
status, body = call("PUT", f"{base}/api/v1/users/{admin_uid}", token, {
"realName": admin_name,
"phone": "13800138000",
"roleCodes": ["HEAD_TEACHER"],
"userType": 3,
})
check("不能取消本单位最后一个管理员的角色", body.get("code") != 0,
f"竟然把自己降级了 —— 这个租户从此没人能管账号 code={body.get('code')}")
# ── 6. 查询 ────────────────────────────────────────
print("\n[6] 查询与分页")
status, body = call("GET", f"{base}/api/v1/users?pageNum=1&pageSize=5", token)
data = body.get("data") or {}
check("列表返回 {list,total,pageNum,pageSize}",
isinstance(data.get("list"), list) and "total" in data
and data.get("pageNum") == 1 and data.get("pageSize") == 5,
f"实际 keys={list(data.keys())}")
status, body = call(
"GET", f"{base}/api/v1/users?"
+ urllib.parse.urlencode({"keyword": teacher_name}), token)
hits = (body.get("data") or {}).get("list") or []
check("按姓名关键字能搜到", any(h["id"] == teacher_uid for h in hits),
f"命中 {len(hits)} 条")
status, body = call(
"GET", f"{base}/api/v1/users?"
+ urllib.parse.urlencode({"keyword": teacher_phone}), token)
hits = (body.get("data") or {}).get("list") or []
check("按完整手机号能搜到(走 HMAC 等值匹配)",
any(h["id"] == teacher_uid for h in hits), f"命中 {len(hits)} 条")
status, body = call(
"GET", f"{base}/api/v1/users?"
+ urllib.parse.urlencode({"keyword": teacher_phone[3:8]}), token)
hits = (body.get("data") or {}).get("list") or []
check("手机号只给中间几位搜不到(加密的必然取舍,不是 bug)",
not any(h["id"] == teacher_uid for h in hits),
"居然搜到了 —— 说明手机号不是加密存的")
status, body = call(
"GET", f"{base}/api/v1/users?"
+ urllib.parse.urlencode({"roleCode": "HEAD_TEACHER"}), token)
hits = (body.get("data") or {}).get("list") or []
check("按角色过滤能搜到", any(h["id"] == teacher_uid for h in hits),
f"命中 {len(hits)} 条")
# 手机号必须脱敏
status, body = call(
"GET", f"{base}/api/v1/users?" + urllib.parse.urlencode({"keyword": teacher_user}), token)
row = next((h for h in (body.get("data") or {}).get("list", []) if h["id"] == teacher_uid), None)
if not check("列表里的手机号是脱敏值",
row is not None and row.get("phoneMasked") == "138****8001",
f"phoneMasked={row.get('phoneMasked') if row else 'N/A'}"):
show(body)
# ── 7. 修改 ────────────────────────────────────────
print("\n[7] 修改账号")
status, body = call("PUT", f"{base}/api/v1/users/{teacher_uid}", token, {
"realName": teacher_name + "改",
"phone": teacher_phone,
"roleCodes": ["HEAD_TEACHER", "DEPUTY_TEACHER"],
"userType": 3,
})
check("修改姓名与角色成功", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
if body.get("code") != 0:
show(body)
status, body = call("GET", f"{base}/api/v1/users?keyword={teacher_user}", token)
row = next((h for h in (body.get("data") or {}).get("list", []) if h["id"] == teacher_uid), None)
check("两个角色都生效", row is not None and sorted(row.get("roleCodes") or [])
== ["DEPUTY_TEACHER", "HEAD_TEACHER"],
f"roleCodes={row.get('roleCodes') if row else 'N/A'}")
check("登录名没有被改掉(username 不可改)",
row is not None and row.get("username") == teacher_user,
f"username={row.get('username') if row else 'N/A'}")
# ── 8. 启用 / 停用 ─────────────────────────────────
print("\n[8] 启用 / 停用")
status, body = call("PUT", f"{base}/api/v1/users/{teacher_uid}/status?status=0", token)
check("停用成功", body.get("code") == 0, f"code={body.get('code')}")
status, body = login(base, teacher_user, teacher_pwd, args.tenant)
check("停用后无法登录", body.get("code") != 0,
f"停用账号竟然登录成功 code={body.get('code')}")
status, body = call("PUT", f"{base}/api/v1/users/{teacher_uid}/status?status=1", token)
check("启用成功", body.get("code") == 0, f"code={body.get('code')}")
status, body = login(base, teacher_user, teacher_pwd, args.tenant)
check("启用后能重新登录", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/users/{teacher_uid}/status?status=9", token)
check("非法状态值被拒", body.get("code") != 0,
f"status=9 竟然通过了 code={body.get('code')}")
# ── 9. 重置密码 + 锁定解除 ─────────────────────────
print("\n[9] 重置密码(含登录锁定解除)")
# 9.1 验证码闸门:连续失败 2 次后再登录会要求图形验证码
for _ in range(3):
login(base, teacher_user, "WrongPassword123", args.tenant)
status, body = login(base, teacher_user, teacher_pwd, args.tenant)
check("连续失败后要求图形验证码(40001)",
body.get("code") == 40001 and "验证码" in (body.get("msg") or ""),
f"code={body.get('code')} msg={body.get('msg')}")
# 9.2 锁定与解除
# 正常路径下要触发锁定得"连续失败 + 通过验证码继续失败",
# 脚本没法自动识别验证码图片,所以直接把锁键写进 Redis 来复现真实场景
# (本人忘密码、反复试错把账号试锁了)。
lock_key = f"xt:login:lock:{teacher_user}"
if redis_set(lock_key, "1", 600):
status, body = login(base, teacher_user, teacher_pwd, args.tenant)
check("账号被锁定时登录返回 40013",
body.get("code") == 40013,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/users/{teacher_uid}/reset-password",
token, {"mode": "GENERATE"})
new_pwd = (body.get("data") or {}).get("newPassword")
check("重置密码成功并返回新密码", body.get("code") == 0 and bool(new_pwd),
f"code={body.get('code')} msg={body.get('msg')}")
if body.get("code") != 0:
show(body)
status, body = login(base, teacher_user, new_pwd, args.tenant)
check("重置后立即可登录 —— 锁定被一并解除",
body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}"
"(若为 40013,说明重置密码没有清 LoginGuard)")
else:
print(" [SKIP] 连不上 xt-redis,跳过「锁定与解除」两项")
status, body = call("POST", f"{base}/api/v1/users/{teacher_uid}/reset-password",
token, {"mode": "GENERATE"})
new_pwd = (body.get("data") or {}).get("newPassword")
check("重置密码成功并返回新密码", body.get("code") == 0 and bool(new_pwd),
f"code={body.get('code')} msg={body.get('msg')}")
status, body = login(base, teacher_user, teacher_pwd, args.tenant)
check("旧密码失效", body.get("code") != 0,
f"旧密码还能登录 code={body.get('code')}")
status, body = call("POST", f"{base}/api/v1/users/{teacher_uid}/reset-password",
token, {"mode": "CUSTOM", "newPassword": "abc"})
check("自定义弱密码被拒", body.get("code") != 0,
f"弱密码竟然通过了 code={body.get('code')}")
# ── 10. 修改自己的密码 ─────────────────────────────
print("\n[10] 修改自己的密码")
status, body = login(base, teacher_user, new_pwd, args.tenant)
t2 = body["data"]["token"]
status, body = call("PUT", f"{base}/api/v1/auth/password", t2, {
"oldPassword": rsa_encrypt("WrongOld123"),
"newPassword": rsa_encrypt("NewPass456"),
})
check("原密码错被拒", body.get("code") != 0,
f"原密码错竟然改了密码 code={body.get('code')}")
status, body = call("PUT", f"{base}/api/v1/auth/password", t2, {
"oldPassword": rsa_encrypt(new_pwd),
"newPassword": rsa_encrypt("NewPass456"),
})
check("修改自己的密码成功", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = login(base, teacher_user, "NewPass456", args.tenant)
check("能用新密码登录", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
# ── 11. Excel 批量导入 ─────────────────────────────
print("\n[11] Excel 批量导入(表头不在第 1 行)")
import_path = build_import_xlsx(suffix, base, token)
if import_path:
status, body = post_multipart(f"{base}/api/v1/users/import", token, import_path)
rep = body.get("data") or {}
if check("导入接口返回体检报告", body.get("code") == 0,
f"HTTP {status} code={body.get('code')} msg={body.get('msg')}"):
print(f" → total={rep.get('total')} success={rep.get('success')} "
f"failed={rep.get('failed')} headerRow={rep.get('headerRow')}")
print(f" → 表头原文:{rep.get('headerText')}")
cols = "、".join(f"{c['fieldName']}→{c['column']}列" for c in rep.get("columns") or [])
print(f" → 列映射:{cols}")
check("自动定位到第 3 行表头(前两行是标题与说明)",
rep.get("headerRow") == 3, f"headerRow={rep.get('headerRow')}")
check("回传了表头原文", bool(rep.get("headerText")),
"headerText 为空 —— 界面无法让用户核对")
check("回传了列映射且含姓名/账号/手机号",
{"realName", "username", "phone"}.issubset(
{c["field"] for c in rep.get("columns") or []}),
f"columns={rep.get('columns')}")
check("3 条合法行全部导入", rep.get("success") == 3,
f"success={rep.get('success')}")
check("4 条非法行被逐条报出原因", rep.get("failed") == 4,
f"failed={rep.get('failed')}")
err = rep.get("errors") or []
print(" → 失败明细:")
for e in err:
print(f" 第 {e['row']} 行 {e['field']}={e['value']}:{e['reason']}")
check("失败明细带行号与原因", all(
e.get("row") and e.get("reason") for e in err),
f"errors={err}")
check("提供了 3 个新账号的初始密码",
len(rep.get("initialPasswords") or []) == 3,
f"initialPasswords={len(rep.get('initialPasswords') or [])}")
for u in rep.get("initialPasswords") or []:
CREATED_IDS.append(u["username"])
# 导入的账号能用初始密码登录
if rep.get("initialPasswords"):
first = rep["initialPasswords"][0]
status, body = login(base, first["username"], first["password"], args.tenant)
check("导入的账号能用初始密码登录", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
else:
ok("(跳过后续导入断言)")
try:
os.remove(import_path)
except OSError:
pass
# ── 12. 软删 ───────────────────────────────────────
print("\n[12] 软删")
status, body = call("DELETE", f"{base}/api/v1/users/{teacher_uid}", token)
check("软删成功", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/users?keyword={teacher_user}", token)
hits = (body.get("data") or {}).get("list") or []
check("软删后列表里查不到", not any(h["id"] == teacher_uid for h in hits),
f"还能查到 {len(hits)} 条")
status, body = login(base, teacher_user, "NewPass456", args.tenant)
check("软删后无法登录", body.get("code") != 0,
f"软删账号竟然登录成功 code={body.get('code')}")
# ── 13. 平台运营角色不再发给学校租户 ────────────────
print("\n[13] 平台运营角色不给学校租户发放")
status, body = call(
"GET", f"{base}/api/v1/users?" + urllib.parse.urlencode(
{"roleCode": "PLATFORM_ADMIN", "pageSize": 200}), token)
platform_holders = (body.get("data") or {}).get("list") or []
check("本学校租户里没有任何账号持有平台运营角色",
len(platform_holders) == 0,
f"有 {len(platform_holders)} 个:"
f"{[h['username'] for h in platform_holders]}")
# ── 收尾 ───────────────────────────────────────────
print("\n[14] 清理测试数据")
for uid in CREATED_IDS:
if isinstance(uid, int):
call("DELETE", f"{base}/api/v1/users/{uid}", token)
else:
st, bd = call("GET", f"{base}/api/v1/users?keyword={uid}&pageSize=50", token)
for h in (bd.get("data") or {}).get("list") or []:
call("DELETE", f"{base}/api/v1/users/{h['id']}", token)
ok(f"已尝试清理 {len(CREATED_IDS)} 个测试账号")
# ── 汇总 ───────────────────────────────────────────
print("\n" + "=" * 70)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
if FAILED:
print("\n失败项:")
for name, detail in FAILED:
print(f" · {name}\n {detail}")
print("=" * 70)
return 1 if FAILED else 0
def build_import_xlsx(suffix: str, base: str, token: str) -> str | None:
"""
构造一个「表头不在第 1 行」的导入文件。
真实场景就长这样:上面两行是标题和制表说明,第 3 行才是表头。
顺手混进 4 行有问题的数据,用来验证体检报告能把它们逐条点出来。
"""
try:
from openpyxl import Workbook
except ImportError:
print(" [SKIP] 未安装 openpyxl,跳过导入测试")
return None
# 先取一个真实存在的班级名,避免"组织找不到"这条做成假失败
org_name = ""
st, bd = call("GET", f"{base}/api/v1/orgs/tree", token)
def walk(nodes):
nonlocal org_name
for n in nodes or []:
if n.get("orgType") == 3 and not org_name:
org_name = n["orgName"]
walk(n.get("children"))
walk((bd.get("data") or []) if st == 200 else [])
wb = Workbook()
ws = wb.active
ws.title = "教师账号"
ws.append(["24中技数控1班 教师账号表"]) # 第 1 行:标题
ws.append([None, None, "制表:教务处", None, None]) # 第 2 行:说明
ws.append(["姓名", "账号", "手机号", "班级", "角色"]) # 第 3 行:表头
# 3 条合法
ws.append([f"导入甲{suffix}", f"imp1{suffix}", "13900139001", org_name, "班主任"])
ws.append([f"导入乙{suffix}", f"imp2{suffix}", "13900139002", org_name, None])
ws.append([f"导入丙{suffix}", f"imp3{suffix}", "13900139003", None, "副班主任"])
# 4 条非法
ws.append(["手机号错", f"imp4{suffix}", "1390013", None, None])
ws.append(["账号重复", f"imp1{suffix}", "13900139005", None, None])
ws.append(["角色不存在", f"imp5{suffix}", "13900139006", None, "不存在角色"])
ws.append(["组织找不到", f"imp6{suffix}", "13900139007", "没有这个班", None])
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
f"_tmp_import_{suffix}.xlsx")
wb.save(path)
print(f" 构造的导入文件:表头在第 3 行,7 行数据(3 合法 / 4 非法)")
return path
if __name__ == "__main__":
raise SystemExit(main())