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} —— 服务端生成一个随机初始密码,管理员抄给本人
+ * - {@code CUSTOM} —— 管理员自己指定
+ *
+ * 默认 {@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)。
+ *
+ *
本类里有三处不是"字段校验"、而是"防止系统被用坏"的护栏,
+ * 改动时请当成硬约束而不是可协商的规则:
+ *
+ * - {@link #resolveAssignableRoles} —— 拦住「学校管理员给自己人分配平台运营角色」的
+ * 权限提升。不拦的后果是任何学校管理员都能开通租户、看全平台租户列表。
+ * - {@link #assertNotSelf} —— 不能停用/删除当前登录的账号。不拦的后果是
+ * 管理员把自己关在门外,且没有自助手段恢复。
+ * - {@link #assertAnotherAdminExists} —— 不能取消本单位最后一个管理员。
+ * 生效点是改角色(把自己降级成普通角色),不是停用/删除 —— 原因见方法注释。
+ *
+ */
+@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