diff --git a/.gitignore b/.gitignore index ea936b0..b2bcfd1 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,11 @@ out/ *.tmp *.bak +# ── Python(scripts/ 下的验收与工具脚本)── +# 跑 python -m py_compile 或导入脚本时会生成字节码,不该入库 +__pycache__/ +*.py[cod] + # ── 上传/导出的本地测试数据 ─────────── /data/ /upload/ diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/MaskUtil.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/MaskUtil.java index d539078..60c906d 100644 --- a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/MaskUtil.java +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/MaskUtil.java @@ -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())); } + + /** + * 校验密码强度,不合规直接抛业务异常。 + * + *

规则:8~32 位,且字母与数字都要有。 + * 不强制大小写与符号——那两类要求实测只会把人逼去写 + * {@code Abc123!} 这种既难记又不安全的密码,然后贴到便签上。 + * 真正该堵的是「123456」「姓名拼音」这类,所以最低要求设在这里。 + * + *

放在本类是因为初始密码的生成也在这里({@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, "密码需同时包含字母和数字"); + } + } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/controller/AuthController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/controller/AuthController.java index a1dd1de..88d7c23 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/controller/AuthController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/controller/AuthController.java @@ -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 changePassword(@Valid @RequestBody ChangePasswordReq req) { + authService.changePassword(req); + return ApiResult.ok(); + } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/dto/ChangePasswordReq.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/dto/ChangePasswordReq.java new file mode 100644 index 0000000..a6ac7b0 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/dto/ChangePasswordReq.java @@ -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)。 + * + *

⚠️ {@code oldPassword} 在实现上是必填的,与文档里 + * 「强制改密场景 {@code oldPassword} 可空」不同。原因: + * 「可空」的前提是系统知道自己处在"必须改密"状态(需要一个 + * {@code must_change_password} 标记),而 {@code sys_user} 目前没有这个列。 + * 在没有该标记的情况下放行"不填原密码也能改",等于任何拿到有效 token 的人 + * 都能直接改掉密码、把原主彻底挤出去——从"暂时被盗用"升级成"永久失去账号"。 + * 所以要等二期补上 {@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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java index fbc55b4..8bd69d6 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java @@ -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)。 + * + *

必须校验原密码:文档写的是「强制改密场景 {@code oldPassword} 可空」, + * 但那需要一个 {@code must_change_password} 标记来界定"是否处在强制改密态", + * 而 {@code sys_user} 目前没有这个列。在没有该标记的前提下放行"不填原密码", + * 等于任何拿到有效 token 的人都能改掉密码、把原主永久挤出去。 + * 所以要等二期补上该标记后再开放这个分支,现在一律校验。 + * + *

已知缺口(需要后续补):改密成功后,该账号此前签发的 token 仍然有效, + * 直到自然过期。也就是说"发现密码泄露 → 立刻改密"并不能立刻踢掉已经登进来的人。 + * 要做到这点,需要让 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) { diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/controller/UserController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/controller/UserController.java new file mode 100644 index 0000000..51bccee --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/controller/UserController.java @@ -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)。 + * + *

列表用 {@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> 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 create(@Valid @RequestBody UserUpsertReq req) { + return ApiResult.ok(userService.create(req)); + } + + @PutMapping("/{id}") + @RequirePerm(PermissionDef.USER_MANAGE) + @Operation(summary = "修改账号", description = "登录名与密码不在此接口修改") + public ApiResult 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 delete(@PathVariable Long id) { + userService.delete(id); + return ApiResult.ok(); + } + + @PutMapping("/{id}/status") + @RequirePerm(PermissionDef.USER_MANAGE) + @Operation(summary = "启用 / 停用账号", + description = "只改 status,数据保留可再启用。与 DELETE 的软删不是一回事") + public ApiResult 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 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 importUsers( + @RequestPart("file") MultipartFile file, + @RequestParam(required = false) String defaultRoleCode) { + return ApiResult.ok(userService.importExcel(file, defaultRoleCode)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/dto/ResetPasswordReq.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/dto/ResetPasswordReq.java new file mode 100644 index 0000000..49e051b --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/dto/ResetPasswordReq.java @@ -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)。 + * + *

两种模式: + *

+ * 默认 {@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()); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/dto/UserImportResult.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/dto/UserImportResult.java new file mode 100644 index 0000000..bd0fa3b --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/dto/UserImportResult.java @@ -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)。 + * + *

为什么导入接口一律返回这个结构:实测的真实 Excel 里, + * 表头行一份在第 1 行、另一份在第 6 行;列名也有多种写法。 + * 如果导入只回一个「成功 40 条」,用户根本不知道剩下 2 条为什么没进去, + * 也看不到系统把哪一行当成了表头——错了也没法纠正。 + * + *

所以:逐行的失败明细(行号 + 字段 + 原值 + 原因)与 + * 识别到的表头行号都必须回传,让用户在界面上确认。 + */ +@Getter +@Setter +@Schema(description = "账号批量导入体检报告") +public class UserImportResult { + + private int total; + + private int success; + + private int failed; + + /** 服务端自动定位到的表头所在行号(从 1 开始)。 */ + private int headerRow; + + /** + * 表头那一行的原文(用 {@code |} 连接)。 + * + *

只回一个行号是不够的 —— 用户没法据此判断"系统认对了吗"。 + * 把原文一起回传,界面就能显示「第 6 行:姓名 | 账号 | 手机号 | 所属班级」, + * 让用户一眼确认,而不是导入完了才发现整列错位。 + */ + private String headerText; + + /** 识别到的列映射:哪个字段落在哪一列。 */ + private List columns = new ArrayList<>(); + + /** 逐行失败明细。 */ + private List errors = new ArrayList<>(); + + /** + * 新账号的初始密码。 + * + *

⚠️ 仅此一次返回。库里存的是 BCrypt 哈希,之后任何人(包括 DBA) + * 都无法还原,忘了只能重新重置。 + */ + private List 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; + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/dto/UserUpsertReq.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/dto/UserUpsertReq.java new file mode 100644 index 0000000..f078069 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/dto/UserUpsertReq.java @@ -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)。 + * + *

{@code username} 只在新增时使用;修改接口忽略它(§5.9「{@code username} 不可改」)。 + * 登录名一改,用户手里的通知、导出的历史包、别人抄下来的账号就全对不上了, + * 而且没有什么收益——需要换登录名时停用旧的、建新的更清楚。 + */ +@Getter +@Setter +@Schema(description = "账号新增或修改") +public class UserUpsertReq { + + /** + * 登录账号。 + * + *

⚠️ 这里刻意没有 {@code @NotBlank}。本 DTO 同时用于新增与修改, + * 而修改不允许改登录名(§5.9)。如果加必填注解,{@code PUT /users/{id}} + * 会因为「没传一个用不上的字段」而整体校验失败 —— 表现为修改接口永远返回 40001, + * 且错误信息只有一句"存在阻断性校验问题",从字面完全看不出是登录名的问题。 + * + *

“新增时必填”由 {@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 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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/dto/UserVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/dto/UserVO.java new file mode 100644 index 0000000..3071584 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/dto/UserVO.java @@ -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)。 + * + *

为什么不返回手机号明文:§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 roleCodes; + + /** 角色中文名,与 {@link #roleCodes} 一一对应。 */ + private List roleNames; + + private LocalDateTime lastLoginTime; + + private LocalDateTime createTime; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysUser.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysUser.java index dc51608..d20c184 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysUser.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysUser.java @@ -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} 学生。 + * + *

口径来源是《03-接口设计》§2.7,与 {@code BuiltinRole.UserType} 一致。 + * + *

⚠️ 库里该列的注释曾经写的是「1教师/管理员 2填报人(学生) 3社会人员」, + * 与上面这套不一致,已由 {@code V3__fix_user_type_comment.sql} 更正。 + * 不要照旧注释或旧文档写代码 —— 这个不一致已经造成过一次真实误判 + * (把 {@code 3} 当成"社会人员"而拒掉,而它其实是班主任)。 + */ private Integer userType; /** {@code 0} 停用 / {@code 1} 正常 / {@code 2} 待激活。 */ diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java index 479ee1a..bbbf440 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java @@ -54,4 +54,51 @@ public interface UserMapper extends BaseMapper { @Select("SELECT * FROM sys_user WHERE tenant_id = #{tenantId} AND phone_hash = #{phoneHash} AND deleted = 0") List selectByPhoneHash(@Param("tenantId") Long tenantId, @Param("phoneHash") String phoneHash); + + /** + * 本租户里除某人之外还有几个可用的管理员账号。 + * + *

为什么需要:停用/删除最后一个管理员 = 这个租户从此没人能登录、 + * 没人能再建账号,只能由平台运营手工改库救回来。这类"系统被自己锁死"的 + * 故障一旦发生,现场没有任何自助手段,所以必须在服务端拦住。 + * + *

为什么显式传 tenantId 并关掉拦截器:这条 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); + + /** + * 某人当前是否绑着管理员角色。 + * + *

用于判断一次「改角色」是否会让这个人失去管理员身份 —— + * 只有"本来是管理员、改完不是了"才需要做最后管理员的检查, + * 否则把一个普通班主任改成另一个普通角色也会被拦,那是误伤。 + */ + @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); } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/service/UserService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/service/UserService.java new file mode 100644 index 0000000..0d25ea7 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/service/UserService.java @@ -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)。 + * + *

本类里有三处不是"字段校验"、而是"防止系统被用坏"的护栏, + * 改动时请当成硬约束而不是可协商的规则: + *

    + *
  1. {@link #resolveAssignableRoles} —— 拦住「学校管理员给自己人分配平台运营角色」的 + * 权限提升。不拦的后果是任何学校管理员都能开通租户、看全平台租户列表。
  2. + *
  3. {@link #assertNotSelf} —— 不能停用/删除当前登录的账号。不拦的后果是 + * 管理员把自己关在门外,且没有自助手段恢复。
  4. + *
  5. {@link #assertAnotherAdminExists} —— 不能取消本单位最后一个管理员。 + * 生效点是改角色(把自己降级成普通角色),不是停用/删除 —— 原因见方法注释。
  6. + *
+ */ +@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 SCHOOL_ASSIGNABLE = Set.of( + "SCHOOL_ADMIN", "HEAD_TEACHER", "DEPUTY_TEACHER", "AUDITOR", "STUDENT"); + + /** 平台租户可分配的角色。 */ + private static final Set PLATFORM_ASSIGNABLE = Set.of("PLATFORM_ADMIN"); + + /** 拥有 {@code user:manage} 的角色。判断"还有没有人能管账号"只需看这两个。 */ + private static final Set ADMIN_ROLE_CODES = Set.of("PLATFORM_ADMIN", "SCHOOL_ADMIN"); + + /** + * 角色 → 用户类型(《03-接口设计》§2.7,与 {@code BuiltinRole.UserType} 一致)。 + * + *

⚠️ 不要照 {@code sys_user} 那行 DDL 注释写。那行注释写的是 + * 「1教师/管理员 2填报人(学生) 3社会人员」,与接口契约的 + * 「1平台运营 2学校管理员 3班主任 4学生」不一致。 + * 权威的是接口契约 + {@code BuiltinRole.UserType}(两者一致),DDL 注释是错的 + * (已在 V3 迁移里更正)。 + * + *

这个不一致已经造成过一次真实误判:把 {@code 3} 当成"社会人员(二期)"而拒掉, + * 而 {@code 3} 恰恰是班主任 —— 最高频的一种账号。 + */ + private static final Map 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 USER_TYPE_NAMES = Map.of( + 1, "平台运营", + 2, "学校管理员", + 3, "班主任", + 4, "学生"); + + /** + * 导入文件的表头别名。 + * + *

实测的真实 Excel 列名写法并不统一(「手机号 / 手机 / 联系电话」都出现过), + * 表头所在行也不固定(一份在第 1 行、一份在第 6 行)。所以这里不假设表头位置, + * 也不假设列序,而是**按列名认列**,并把识别到的行号回传给界面让用户确认。 + */ + private static final Map> HEADER_ALIASES = new LinkedHashMap<>(); + + /** 字段 → 中文名,用于导入报告里的列映射展示。 */ + private static final Map 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)。 + * + *

关于按手机号搜索:手机号是 AES-GCM 加密存储的(每次密文都不同, + * 且不含原文片段),所以 {@code LIKE '%138%'} 这类模糊搜索在原理上就做不到。 + * 这里对完整手机号走 {@code phone_hash} 等值匹配——准确、且不牺牲加密。 + * 代价是"只记得中间几位"时搜不出来,这是加密换来的必然取舍,不是遗漏。 + */ + public PageResult 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 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 page = userMapper.selectPage(new Page<>(num, size), q); + List 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 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)。 + * + *

{@code username} 与 {@code password} 不在这里改: + * 前者改了会让历史通知、导出包里的账号全对不上;后者走单独的重置密码接口, + * 免得"编辑资料"顺手把密码也换了却没人知道。 + */ + @Transactional(rollbackFor = Exception.class) + public void update(Long id, UserUpsertReq req) { + SysUser exist = requireUser(id); + long tenantId = UserContext.tenantId(); + + List 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)。 + * + *

按契约这是软删({@code deleted=1})。软删之后这条记录对本租户就"消失"了, + * 界面上的「启用」走 {@link #updateStatus}(改 {@code status}),两者不是一回事: + *

    + *
  • 改 {@code status=0} —— 账号还在列表里,可以再启用。日常「某人离职」用这个。
  • + *
  • 软删 —— 从列表里消失,用于建错了、重复导入这类要清理的场景。
  • + *
+ */ + @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.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)──────────────────────────── + + /** + * 批量导入账号,返回「体检报告」。 + * + *

部分成功是设计目标:42 行里有 2 行手机号写错,不应该让另外 40 个人白等, + * 但也不能悄悄丢掉那 2 行。所以整体不抛异常(能进的全进), + * 逐行把「行号 + 字段 + 原值 + 原因」回传,由界面上的人去修。 + */ + @Transactional(rollbackFor = Exception.class) + public UserImportResult importExcel(MultipartFile file, String defaultRoleCode) { + assertImportFile(file); + + BuiltinRole fallbackRole = resolveFallbackRole(defaultRoleCode); + + List> 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 headerCells = rows.get(headerIdx); + Map 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 orgByName = new HashMap<>(); + Map orgByCode = new HashMap<>(); + for (SysOrg o : orgMapper.selectList(Wrappers.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 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 existingNames = new LinkedHashSet<>(); + if (!dataNames.isEmpty()) { + for (SysUser u : userMapper.selectList(Wrappers.lambdaQuery() + .in(SysUser::getUsername, dataNames))) { + existingNames.add(u.getUsername()); + } + } + + long tenantId = UserContext.tenantId(); + Set seenInFile = new LinkedHashSet<>(); + + for (int i = headerIdx + 1; i < rows.size(); i++) { + Map 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; + } + + // ── 护栏 ────────────────────────────────────── + + /** + * 校验并解析可分配的角色。 + * + *

⚠️ 这里是权限提升的防线,不是普通的字段校验。 + * + *

开通租户时 {@code RoleProvisioner} 会为租户发放全部内置角色, + * 其中包含 {@code PLATFORM_ADMIN}。而 {@code /tenants} 这类平台接口 + * 只校验权限码 {@code tenant:manage},不会再确认调用者是否真的在平台租户下。 + * 于是:如果这里不拦,学校管理员只要在新增账号时传 + * {@code roleCodes: ["PLATFORM_ADMIN"]},就能造出一个带 {@code tenant:manage} 的账号, + * 进而开通租户、拉取全平台租户列表 —— 一个租户级管理员直接越权到平台级。 + * + *

两道防线:本方法按"当前租户能分配什么角色"白名单校验; + * 同时 {@code RoleProvisioner} 不再给学校租户发放 {@code PLATFORM_ADMIN} 角色行。 + * 任一道单独都够用,但都留着 —— 这类错误一旦发生是不可逆的信息泄露。 + */ + private List resolveAssignableRoles(List codes) { + Long tenantId = UserContext.tenantId(); + boolean platform = tenantId != null && tenantId == LoginUser.PLATFORM_TENANT_ID; + Set allowed = platform ? PLATFORM_ASSIGNABLE : SCHOOL_ASSIGNABLE; + + LinkedHashSet 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 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; + } + + /** + * 用户类型必须与所选角色自洽。 + * + *

做法不是"逐条 if",而是从角色反推用户类型再比对: + * 角色与用户类型本来就是一对一绑定的(见 {@link #ROLE_TO_USER_TYPE}), + * 反过来推只有一个正确答案,写成一堆 if 迟早会漏掉某个组合。 + * + *

多角色时要求它们属于同一类(比如「班主任 + 副班主任」都是 3 可以, + * 「班主任 + 审核员」是 3 与 2 就不能共存)—— 否则"这个人到底是几"没有答案。 + */ + private void assertRoleMatchesUserType(Integer userType, List roles) { + if (userType == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择用户类型"); + } + + Set 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 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.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 + "当前登录的账号,请换一个管理员账号操作"); + } + } + + /** + * 「让这个人不再是管理员」之后,本单位是否还有别的可用管理员。 + * + *

在哪些路径上会真正生效(这一点很容易误判): + *

    + *
  • 改角色({@link #update} 里把管理员改成普通角色)—— 会生效。 + * 因为目标可以是操作者自己,此时"除目标以外的管理员"就是 0。
  • + *
  • 停用 / 软删 —— 实际上永远不触发。能调这两个接口的人, + * 自己必然是一个活跃管理员({@code user:manage} 只来自 + * {@code SCHOOL_ADMIN} / {@code PLATFORM_ADMIN}), + * 所以"除目标以外还有管理员"里至少能数到操作者自己,恒 ≥ 1。 + * 更根本的原因是同一路径上已有 {@link #assertNotSelf}:不能停用自己。
  • + *
+ * 两份检查都留着 —— 权限模型一旦放宽(比如允许"审核员"停用账号), + * 停用路径上的这次检查立刻就有了意义。但不要以为停用路径被它保护着。 + */ + 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 + "后就没人能再管理本单位的账号了,请先另建一个管理员账号"); + } + } + + /** + * 这次改角色是否会让目标失去全部管理员角色。 + * + *

只有在目标原本确实是管理员时才返回 true —— + * 否则把一个普通班主任换成另一个普通角色也会被上面那次检查拦住,那是误伤。 + */ + private boolean losingAdminRole(SysUser target, List 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); + } + + /** + * 账号是否还负责着在办任务。 + * + *

⚠️ M0 阶段恒为 false —— 这条约束还没生效。 + * {@code collect_task} / {@code collect_task_org} 是 M1 的表,且当前没有 + * 「任务负责人」字段,所以这里没有任何可查的依据。 + * M1 接入任务后必须补上实现,否则会出现「把还在带班的班主任停用了, + * 任务卡在半路没人能审」这种线上问题,而错误码 {@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 roles) { + userRoleMapper.delete(Wrappers.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; + } + + /** + * 找表头行。 + * + *

打分而不是"找第 1 行":取命中别名最多的一行,且必须命中「姓名」或「账号」之一、 + * 总命中数不少于 2。这样即使表格前面有标题、说明、空行,也能正确定位。 + * + * @return 0 基的行下标;找不到返回 -1 + */ + private int detectHeaderRow(List> rows) { + int best = -1; + int bestScore = 0; + int scan = Math.min(rows.size(), 20); // 表头不可能在第 20 行之后 + for (int i = 0; i < scan; i++) { + Map 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 mapColumns(Map headerRow) { + Map colOf = new LinkedHashMap<>(); + if (headerRow == null) { + return colOf; + } + List> cells = new ArrayList<>(headerRow.entrySet()); + cells.sort(Comparator.comparing(Map.Entry::getKey)); + for (Map.Entry e : cells) { + String text = e.getValue() == null ? "" : e.getValue().trim(); + if (text.isEmpty()) { + continue; + } + for (Map.Entry> 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 seenInFile, + Set 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 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 buildColumnMap(Map colOf) { + List 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 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 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 toVoList(List users) { + if (users == null || users.isEmpty()) { + return List.of(); + } + + List userIds = users.stream().map(SysUser::getId).toList(); + + List orgIds = users.stream().map(SysUser::getOrgId).filter(Objects::nonNull).distinct().toList(); + Map orgNames = new HashMap<>(); + if (!orgIds.isEmpty()) { + for (SysOrg o : orgMapper.selectByIds(orgIds)) { + orgNames.put(o.getId(), o.getOrgName()); + } + } + + Map> 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 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 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; + + /** 初始密码,仅此一次返回;库里只存 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; + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/RoleProvisioner.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/RoleProvisioner.java index 63ce6aa..5b34077 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/RoleProvisioner.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/RoleProvisioner.java @@ -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; } diff --git a/backend/xt-server/src/main/resources/db/migration/V3__fix_user_type_comment.sql b/backend/xt-server/src/main/resources/db/migration/V3__fix_user_type_comment.sql new file mode 100644 index 0000000..8327ca9 --- /dev/null +++ b/backend/xt-server/src/main/resources/db/migration/V3__fix_user_type_comment.sql @@ -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 一致)'; diff --git a/docs/db/schema-postgresql.sql b/docs/db/schema-postgresql.sql index 7b4bf84..85b6a37 100644 --- a/docs/db/schema-postgresql.sql +++ b/docs/db/schema-postgresql.sql @@ -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 ( diff --git a/docs/交接摘要.md b/docs/交接摘要.md index c2e8098..e469a78 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -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
PC: Vue3 + Element Plus;小程序: uni-app | +| 技术栈定版 | ✅ Java 21 + **Spring Boot 4.1.1** + MyBatis-Plus 3.5.17(boot4) + PG16 + Redis7
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` 的 `` | 首次渲染被当成一次过渡,元素被加上 `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\\.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。 + +--- **关键实现决策(不要在后继开发中改回去)** diff --git a/docs/数据信息填报系统PRD.md b/docs/数据信息填报系统PRD.md index 454a256..9aa5fc9 100644 --- a/docs/数据信息填报系统PRD.md +++ b/docs/数据信息填报系统PRD.md @@ -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待激活'; ``` diff --git a/frontend-pc/src/App.vue b/frontend-pc/src/App.vue index 3d51d7f..7494249 100644 --- a/frontend-pc/src/App.vue +++ b/frontend-pc/src/App.vue @@ -9,7 +9,6 @@ + + diff --git a/frontend-pc/src/layout/components/HeaderRightBar/components/LayoutModeItem.vue b/frontend-pc/src/layout/components/HeaderRightBar/components/LayoutModeItem.vue index be6d771..306dd52 100644 --- a/frontend-pc/src/layout/components/HeaderRightBar/components/LayoutModeItem.vue +++ b/frontend-pc/src/layout/components/HeaderRightBar/components/LayoutModeItem.vue @@ -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; diff --git a/frontend-pc/src/layout/components/HeaderRightBar/index.vue b/frontend-pc/src/layout/components/HeaderRightBar/index.vue index 1ab5d43..50de56f 100644 --- a/frontend-pc/src/layout/components/HeaderRightBar/index.vue +++ b/frontend-pc/src/layout/components/HeaderRightBar/index.vue @@ -77,6 +77,8 @@ + + diff --git a/frontend-pc/src/views/org/index.vue b/frontend-pc/src/views/org/index.vue index 0e0575b..cd411d3 100644 --- a/frontend-pc/src/views/org/index.vue +++ b/frontend-pc/src/views/org/index.vue @@ -24,7 +24,7 @@ + block-node :default-expand-all="true" @select="onSelect"> + + diff --git a/frontend-pc/src/views/user/UserImportModal.vue b/frontend-pc/src/views/user/UserImportModal.vue new file mode 100644 index 0000000..c8ed988 --- /dev/null +++ b/frontend-pc/src/views/user/UserImportModal.vue @@ -0,0 +1,381 @@ + + + + + diff --git a/frontend-pc/src/views/user/index.vue b/frontend-pc/src/views/user/index.vue new file mode 100644 index 0000000..6c74ac7 --- /dev/null +++ b/frontend-pc/src/views/user/index.vue @@ -0,0 +1,437 @@ + + + + + diff --git a/frontend-pc/tsconfig.json b/frontend-pc/tsconfig.json index 16f67e8..36a1f41 100644 --- a/frontend-pc/tsconfig.json +++ b/frontend-pc/tsconfig.json @@ -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/**/*", diff --git a/scripts/dev-clean-test-users.py b/scripts/dev-clean-test-users.py new file mode 100644 index 0000000..a38a145 --- /dev/null +++ b/scripts/dev-clean-test-users.py @@ -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')}") diff --git a/scripts/e2e-m0-user.py b/scripts/e2e-m0-user.py new file mode 100644 index 0000000..28323ce --- /dev/null +++ b/scripts/e2e-m0-user.py @@ -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())