feat(user): 账号管理(CRUD、Excel 导入、改密)并修掉 5 处缺陷
后端补齐 /users 全套(列表/新增/修改/软删/启停/重置密码/批量导入)与
PUT /auth/password;前端加「组织与账号 → 账号管理」一屏(左组织树 + 右列表、
新增编辑弹窗、导入体检报告、初始密码只显示一次的强确认)。
验收:e2e-m0-user.py 55/55;e2e-m0.py 回归 36/36;typecheck 0 错、build 通过。
账号管理页已在浏览器肉眼确认(首屏与切页均可见、数据与分页正确)。
本轮修掉的 5 个真问题 —— 都不是功能没做完,而是做完了但错的:
1) 学校租户也发放 PLATFORM_ADMIN 角色行
学校管理员在新增账号时传 roleCodes=[PLATFORM_ADMIN] 就能造出带
tenant:manage 的账号,而 /tenants 只校验权限码、不校验是否真在平台租户下
→ 任何学校管理员都能开通租户、拉全平台租户列表(租户级越权到平台级)。
RoleProvisioner 不再给非平台租户发该角色行 + 服务端按租户白名单校验。
2) UserUpsertReq.username 的 @NotBlank 对修改也生效
PUT /users/{id} 永远返回 40001,且明细只说存在阻断性校验问题,
看不出是登录名 —— 改账号功能整体不可用。去掉该注解,
必填性改由 UserService.create 判定。
3) Main.vue 的 <transition appear>
首次渲染被当成一次过渡,元素被加上 zoom-fade-enter-from(opacity:0)后
永不清理 → DOM 内容齐全、接口数据正常、文字可选中,但整块内容区肉眼不可见。
极易误判成页面没渲染而往路由/接口方向排查。去掉 appear。
4) assertAnotherAdminExists 在停用/删除路径上不可达
能调这两个接口的人自己必然是活跃管理员,所以除目标外还有管理员恒成立;
而真正会锁死租户的路径 —— 把管理员改成普通角色(目标可以是操作者自己)——
当时没有拦。补 losingAdminRole() + UserMapper.countAdminRoles(),
并在方法注释里写明哪条路径真生效、哪条只是防御性的。
5) sys_user.user_type 有两套互相冲突的枚举
DDL/实体注释写「1教师/管理员 2填报人(学生) 3社会人员」,而权威契约
(03-接口设计 §2.7)与 BuiltinRole.UserType 是
「1平台运营 2学校管理员 3班主任 4学生」—— 2 和 3 完全错位。
后果:userType=3(班主任,最高频的账号)被当成社会人员(二期)直接拒掉,
建班主任账号会失败;前端还把学校管理员显示成填报人(学生)。
改法:后端从角色反推用户类型再比对;新增 V3 迁移更正库注释
(V1 已应用不能改,Flyway 会校验 checksum);更正 PRD 与 schema DDL 的同一行;
前端「用户类型」从手选改为由角色推导,从根上消除矛盾组合;
E2E 加 3 条回归断言锁住这个语义。
顺带修掉:
· components/index.ts 里 6 个指向已删组件的悬空导出(构建不报错、用到才炸)
· tsconfig 的 lib 停在 ES2020 而代码用了 Array.prototype.at
· 两处 .at() 的空值风险(useBreakpoint / useTabsStore)
· dashboard 的 userStore.menus 拼错,导致平台判断恒为 false
· http.ts 里两个永不命中的错误码常量
· 账号列表的 fixed 列缺 :scroll 配置,会浮在其它列上面
· .gitignore 补 __pycache__
新增脚本:
· scripts/e2e-m0-user.py 账号管理 55 项验收(含越权与自锁的负向断言)
· scripts/dev-clean-test-users.py 清理 E2E 残留的测试账号
This commit is contained in:
@@ -2,6 +2,9 @@ package cn.tianbaotong.fill.common.util;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
|
||||
import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||
import cn.tianbaotong.fill.common.exception.BizException;
|
||||
|
||||
/**
|
||||
* 脱敏展示(《03-接口设计》§1.7)。
|
||||
*
|
||||
@@ -112,4 +115,27 @@ public final class MaskUtil {
|
||||
private static char pick(String s) {
|
||||
return s.charAt(RANDOM.nextInt(s.length()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验密码强度,不合规直接抛业务异常。
|
||||
*
|
||||
* <p>规则:8~32 位,且<b>字母与数字都要有</b>。
|
||||
* 不强制大小写与符号——那两类要求实测只会把人逼去写
|
||||
* {@code Abc123!} 这种既难记又不安全的密码,然后贴到便签上。
|
||||
* 真正该堵的是「123456」「姓名拼音」这类,所以最低要求设在这里。
|
||||
*
|
||||
* <p>放在本类是因为初始密码的生成也在这里({@link #generateInitialPassword}):
|
||||
* 生成的规则与校验的规则必须满足同一个口径,分开写迟早会出现
|
||||
* "系统生成的初始密码被自己的校验规则拒掉"。
|
||||
*/
|
||||
public static void assertPasswordStrong(String plain) {
|
||||
if (plain == null || plain.length() < 8 || plain.length() > 32) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "密码长度需为 8~32 位");
|
||||
}
|
||||
boolean hasLetter = plain.chars().anyMatch(Character::isLetter);
|
||||
boolean hasDigit = plain.chars().anyMatch(Character::isDigit);
|
||||
if (!hasLetter || !hasDigit) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "密码需同时包含字母和数字");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -2,6 +2,7 @@ package cn.tianbaotong.fill.module.auth.controller;
|
||||
|
||||
import cn.tianbaotong.fill.common.api.ApiResult;
|
||||
import cn.tianbaotong.fill.common.crypto.RsaCipher;
|
||||
import cn.tianbaotong.fill.module.auth.dto.ChangePasswordReq;
|
||||
import cn.tianbaotong.fill.module.auth.dto.LoginReq;
|
||||
import cn.tianbaotong.fill.module.auth.dto.LoginVO;
|
||||
import cn.tianbaotong.fill.module.auth.service.AuthService;
|
||||
@@ -15,6 +16,7 @@ import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -73,4 +75,12 @@ public class AuthController {
|
||||
authService.logout(token == null ? null : String.valueOf(token));
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@PutMapping("/password")
|
||||
@Operation(summary = "修改自己的密码",
|
||||
description = "原密码与新密码均为 RSA 密文。已知缺口:改密后此前签发的 token 仍有效至自然过期")
|
||||
public ApiResult<Void> changePassword(@Valid @RequestBody ChangePasswordReq req) {
|
||||
authService.changePassword(req);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package cn.tianbaotong.fill.module.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 修改自己的密码(《03-接口设计》§5.9)。
|
||||
*
|
||||
* <p>⚠️ {@code oldPassword} 在实现上是<b>必填</b>的,与文档里
|
||||
* 「强制改密场景 {@code oldPassword} 可空」不同。原因:
|
||||
* 「可空」的前提是系统知道自己处在"必须改密"状态(需要一个
|
||||
* {@code must_change_password} 标记),而 {@code sys_user} 目前没有这个列。
|
||||
* 在没有该标记的情况下放行"不填原密码也能改",等于<b>任何拿到有效 token 的人
|
||||
* 都能直接改掉密码、把原主彻底挤出去</b>——从"暂时被盗用"升级成"永久失去账号"。
|
||||
* 所以要等二期补上 {@code must_change_password} 后再开放这个分支。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "修改自己的密码")
|
||||
public class ChangePasswordReq {
|
||||
|
||||
@NotBlank(message = "请填写原密码")
|
||||
@Schema(description = "原密码(RSA 加密后传输,与登录同一套公钥)",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String oldPassword;
|
||||
|
||||
@NotBlank(message = "请填写新密码")
|
||||
@Schema(description = "新密码(RSA 加密后传输),8~32 位且至少含字母与数字",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String newPassword;
|
||||
}
|
||||
+66
@@ -5,6 +5,8 @@ import cn.tianbaotong.fill.common.context.LoginUser;
|
||||
import cn.tianbaotong.fill.common.context.UserContext;
|
||||
import cn.tianbaotong.fill.common.crypto.RsaCipher;
|
||||
import cn.tianbaotong.fill.common.exception.BizException;
|
||||
import cn.tianbaotong.fill.common.util.MaskUtil;
|
||||
import cn.tianbaotong.fill.module.auth.dto.ChangePasswordReq;
|
||||
import cn.tianbaotong.fill.module.auth.dto.LoginReq;
|
||||
import cn.tianbaotong.fill.module.auth.dto.LoginVO;
|
||||
import cn.tianbaotong.fill.module.log.entity.SysLoginLog;
|
||||
@@ -181,6 +183,70 @@ public class AuthService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ── 修改自己的密码 ─────────────────────────────
|
||||
|
||||
/**
|
||||
* 修改自己的密码(§5.9)。
|
||||
*
|
||||
* <p><b>必须校验原密码</b>:文档写的是「强制改密场景 {@code oldPassword} 可空」,
|
||||
* 但那需要一个 {@code must_change_password} 标记来界定"是否处在强制改密态",
|
||||
* 而 {@code sys_user} 目前没有这个列。在没有该标记的前提下放行"不填原密码",
|
||||
* 等于任何拿到有效 token 的人都能改掉密码、把原主永久挤出去。
|
||||
* 所以要等二期补上该标记后再开放这个分支,现在一律校验。
|
||||
*
|
||||
* <p><b>已知缺口(需要后续补)</b>:改密成功后,<b>该账号此前签发的 token 仍然有效</b>,
|
||||
* 直到自然过期。也就是说"发现密码泄露 → 立刻改密"并不能立刻踢掉已经登进来的人。
|
||||
* 要做到这点,需要让 JWT 带上签发时间并在校验时与"最近一次改密时间"比较
|
||||
* (或在 Redis 里记一个 per-user 的失效时间戳)。本次未实现,是因为它涉及
|
||||
* {@code JwtService} 与 {@code AuthInterceptor} 的改动,不适合混在账号管理这一刀里做。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void changePassword(ChangePasswordReq req) {
|
||||
Long uid = UserContext.uid();
|
||||
SysUser user = userMapper.selectById(uid);
|
||||
if (user == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
String oldPlain = decryptOrNull(req.getOldPassword());
|
||||
if (oldPlain == null || user.getPassword() == null
|
||||
|| !passwordEncoder.matches(oldPlain, user.getPassword())) {
|
||||
throw new BizException(ErrorCode.BAD_CREDENTIALS, "原密码不正确");
|
||||
}
|
||||
|
||||
String newPlain = decryptOrNull(req.getNewPassword());
|
||||
if (newPlain == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "密码解密失败,请刷新页面后重试");
|
||||
}
|
||||
if (newPlain.equals(oldPlain)) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "新密码不能与原密码相同");
|
||||
}
|
||||
MaskUtil.assertPasswordStrong(newPlain);
|
||||
|
||||
SysUser patch = new SysUser();
|
||||
patch.setId(uid);
|
||||
patch.setPassword(passwordEncoder.encode(newPlain));
|
||||
userMapper.updateById(patch);
|
||||
|
||||
// 顺手解锁:能登录进来改密说明没被锁,但改密前可能刚试错过几次
|
||||
if (user.getUsername() != null && !user.getUsername().isBlank()) {
|
||||
loginGuard.clear(user.getUsername());
|
||||
}
|
||||
log.info("账号 {} 修改了自己的密码", user.getUsername());
|
||||
}
|
||||
|
||||
/** 解密前端传来的 RSA 密文;密文非法(未加密/公钥不匹配/被截断)时返回 null。 */
|
||||
private String decryptOrNull(String cipherText) {
|
||||
if (cipherText == null || cipherText.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return rsaCipher.decrypt(cipherText);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 登出 ──────────────────────────────────────
|
||||
|
||||
public void logout(String token) {
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package cn.tianbaotong.fill.module.user.controller;
|
||||
|
||||
import cn.tianbaotong.fill.common.api.ApiResult;
|
||||
import cn.tianbaotong.fill.common.api.PageResult;
|
||||
import cn.tianbaotong.fill.module.user.dto.ResetPasswordReq;
|
||||
import cn.tianbaotong.fill.module.user.dto.UserImportResult;
|
||||
import cn.tianbaotong.fill.module.user.dto.UserUpsertReq;
|
||||
import cn.tianbaotong.fill.module.user.dto.UserVO;
|
||||
import cn.tianbaotong.fill.module.user.service.UserService;
|
||||
import cn.tianbaotong.fill.security.PermissionDef;
|
||||
import cn.tianbaotong.fill.security.RequirePerm;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 账号管理(《03-接口设计》§5.6 ~ §5.9)。
|
||||
*
|
||||
* <p>列表用 {@code user:view}、写操作用 {@code user:manage},与
|
||||
* {@code PermissionDef} 里的两个权限码对应 —— 学校里有相当多的场景是
|
||||
* "让教务处能看账号但别改",按钮级权限才有意义,所以读写分开。
|
||||
*/
|
||||
@Tag(name = "账号管理")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping
|
||||
@RequirePerm(PermissionDef.USER_VIEW)
|
||||
@Operation(summary = "账号列表",
|
||||
description = "keyword 支持姓名/账号模糊;完整手机号走加密索引等值匹配(模糊搜不了,见 UserService)")
|
||||
public ApiResult<PageResult<UserVO>> list(
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) Long orgId,
|
||||
@RequestParam(required = false) String roleCode,
|
||||
@RequestParam(required = false) Integer userType,
|
||||
@RequestParam(required = false) Integer status,
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
return ApiResult.ok(userService.list(keyword, orgId, roleCode, userType, status, pageNum, pageSize));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@RequirePerm(PermissionDef.USER_MANAGE)
|
||||
@Operation(summary = "新增账号",
|
||||
description = "初始密码由服务端生成并仅此一次返回;角色可选范围与当前租户有关")
|
||||
public ApiResult<UserService.CreateResult> create(@Valid @RequestBody UserUpsertReq req) {
|
||||
return ApiResult.ok(userService.create(req));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@RequirePerm(PermissionDef.USER_MANAGE)
|
||||
@Operation(summary = "修改账号", description = "登录名与密码不在此接口修改")
|
||||
public ApiResult<Void> update(@PathVariable Long id, @Valid @RequestBody UserUpsertReq req) {
|
||||
userService.update(id, req);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@RequirePerm(PermissionDef.USER_MANAGE)
|
||||
@Operation(summary = "停用账号(软删)",
|
||||
description = "有在办任务时返回 40014;不能停用自己或本单位最后一个管理员")
|
||||
public ApiResult<Void> delete(@PathVariable Long id) {
|
||||
userService.delete(id);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/status")
|
||||
@RequirePerm(PermissionDef.USER_MANAGE)
|
||||
@Operation(summary = "启用 / 停用账号",
|
||||
description = "只改 status,数据保留可再启用。与 DELETE 的软删不是一回事")
|
||||
public ApiResult<Void> updateStatus(@PathVariable Long id,
|
||||
@RequestParam Integer status) {
|
||||
userService.updateStatus(id, status);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reset-password")
|
||||
@RequirePerm(PermissionDef.USER_MANAGE)
|
||||
@Operation(summary = "重置密码",
|
||||
description = "mode=GENERATE 由服务端生成。会同时解除该账号的登录锁定")
|
||||
public ApiResult<UserService.ResetResult> resetPassword(@PathVariable Long id,
|
||||
@RequestBody(required = false) ResetPasswordReq req) {
|
||||
return ApiResult.ok(userService.resetPassword(id, req == null ? new ResetPasswordReq() : req));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@RequirePerm(PermissionDef.USER_MANAGE)
|
||||
@Operation(summary = "批量导入账号",
|
||||
description = "返回体检报告:识别到的表头行号 + 逐行失败明细 + 初始密码清单(仅此一次)")
|
||||
public ApiResult<UserImportResult> importUsers(
|
||||
@RequestPart("file") MultipartFile file,
|
||||
@RequestParam(required = false) String defaultRoleCode) {
|
||||
return ApiResult.ok(userService.importExcel(file, defaultRoleCode));
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package cn.tianbaotong.fill.module.user.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 重置密码(《03-接口设计》§5.9)。
|
||||
*
|
||||
* <p>两种模式:
|
||||
* <ul>
|
||||
* <li>{@code GENERATE} —— 服务端生成一个随机初始密码,管理员抄给本人</li>
|
||||
* <li>{@code CUSTOM} —— 管理员自己指定</li>
|
||||
* </ul>
|
||||
* 默认 {@code GENERATE}:人手编的密码几乎必然是「姓名拼音 + 123」这种,
|
||||
* 而这类密码在密码喷洒里是第一批被试的。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "重置密码")
|
||||
public class ResetPasswordReq {
|
||||
|
||||
@Schema(description = "GENERATE 服务端生成 / CUSTOM 管理员指定,默认 GENERATE", example = "GENERATE")
|
||||
private String mode;
|
||||
|
||||
@Schema(description = "mode=CUSTOM 时必填,8~32 位且至少含字母与数字", example = "Ab3#k9mP")
|
||||
private String newPassword;
|
||||
|
||||
public boolean isCustom() {
|
||||
return "CUSTOM".equalsIgnoreCase(mode == null ? "" : mode.trim());
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package cn.tianbaotong.fill.module.user.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 批量导入的「体检报告」(《03-接口设计》§5.8)。
|
||||
*
|
||||
* <p><b>为什么导入接口一律返回这个结构</b>:实测的真实 Excel 里,
|
||||
* 表头行一份在第 1 行、另一份在第 6 行;列名也有多种写法。
|
||||
* 如果导入只回一个「成功 40 条」,用户根本不知道剩下 2 条为什么没进去,
|
||||
* 也看不到系统把哪一行当成了表头——错了也没法纠正。
|
||||
*
|
||||
* <p>所以:<b>逐行的失败明细</b>(行号 + 字段 + 原值 + 原因)与
|
||||
* <b>识别到的表头行号</b>都必须回传,让用户在界面上确认。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "账号批量导入体检报告")
|
||||
public class UserImportResult {
|
||||
|
||||
private int total;
|
||||
|
||||
private int success;
|
||||
|
||||
private int failed;
|
||||
|
||||
/** 服务端自动定位到的表头所在行号(从 1 开始)。 */
|
||||
private int headerRow;
|
||||
|
||||
/**
|
||||
* 表头那一行的<b>原文</b>(用 {@code |} 连接)。
|
||||
*
|
||||
* <p>只回一个行号是不够的 —— 用户没法据此判断"系统认对了吗"。
|
||||
* 把原文一起回传,界面就能显示「第 6 行:姓名 | 账号 | 手机号 | 所属班级」,
|
||||
* 让用户一眼确认,而不是导入完了才发现整列错位。
|
||||
*/
|
||||
private String headerText;
|
||||
|
||||
/** 识别到的列映射:哪个字段落在哪一列。 */
|
||||
private List<ColumnMap> columns = new ArrayList<>();
|
||||
|
||||
/** 逐行失败明细。 */
|
||||
private List<RowError> errors = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 新账号的初始密码。
|
||||
*
|
||||
* <p>⚠️ <b>仅此一次返回</b>。库里存的是 BCrypt 哈希,之后任何人(包括 DBA)
|
||||
* 都无法还原,忘了只能重新重置。
|
||||
*/
|
||||
private List<InitialPassword> initialPasswords = new ArrayList<>();
|
||||
|
||||
/** 识别到的列映射:字段 → Excel 列字母。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "识别到的列映射")
|
||||
public static class ColumnMap {
|
||||
|
||||
/** 字段名,如 {@code realName}。 */
|
||||
private String field;
|
||||
|
||||
/** 字段中文名,如「姓名」。 */
|
||||
private String fieldName;
|
||||
|
||||
/** Excel 列字母,如 {@code A}。 */
|
||||
private String column;
|
||||
|
||||
public ColumnMap() {
|
||||
}
|
||||
|
||||
public ColumnMap(String field, String fieldName, String column) {
|
||||
this.field = field;
|
||||
this.fieldName = fieldName;
|
||||
this.column = column;
|
||||
}
|
||||
}
|
||||
|
||||
/** 一行导入失败的原因。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "导入失败明细行")
|
||||
public static class RowError {
|
||||
/** Excel 里的行号(含表头,从 1 开始),与用户看到的行号一致。 */
|
||||
private int row;
|
||||
|
||||
private String field;
|
||||
|
||||
private String value;
|
||||
|
||||
private String reason;
|
||||
|
||||
public RowError() {
|
||||
}
|
||||
|
||||
public RowError(int row, String field, String value, String reason) {
|
||||
this.row = row;
|
||||
this.field = field;
|
||||
this.value = value;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/** 账号 → 初始密码。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "初始密码(仅此一次返回)")
|
||||
public static class InitialPassword {
|
||||
|
||||
private String username;
|
||||
|
||||
private String password;
|
||||
|
||||
public InitialPassword() {
|
||||
}
|
||||
|
||||
public InitialPassword(String username, String password) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package cn.tianbaotong.fill.module.user.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 账号新增 / 修改请求(《03-接口设计》§5.7 / §5.9)。
|
||||
*
|
||||
* <p>{@code username} 只在新增时使用;修改接口<b>忽略</b>它(§5.9「{@code username} 不可改」)。
|
||||
* 登录名一改,用户手里的通知、导出的历史包、别人抄下来的账号就全对不上了,
|
||||
* 而且没有什么收益——需要换登录名时停用旧的、建新的更清楚。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "账号新增或修改")
|
||||
public class UserUpsertReq {
|
||||
|
||||
/**
|
||||
* 登录账号。
|
||||
*
|
||||
* <p>⚠️ 这里<b>刻意没有 {@code @NotBlank}</b>。本 DTO 同时用于新增与修改,
|
||||
* 而修改不允许改登录名(§5.9)。如果加必填注解,{@code PUT /users/{id}}
|
||||
* 会因为「没传一个用不上的字段」而整体校验失败 —— 表现为<b>修改接口永远返回 40001</b>,
|
||||
* 且错误信息只有一句"存在阻断性校验问题",从字面完全看不出是登录名的问题。
|
||||
*
|
||||
* <p>“新增时必填”由 {@code UserService.create} 判定。
|
||||
* {@code @Pattern} 对 {@code null} 是通过的(不会与"修改时不传"冲突),
|
||||
* 只有真传了值才校验格式。
|
||||
*/
|
||||
@Size(max = 64, message = "登录账号过长")
|
||||
@Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.-]{1,63}$",
|
||||
message = "登录账号只能以字母开头,可含字母、数字、下划线、点或短横线")
|
||||
@Schema(description = "登录账号,租户内唯一;修改时忽略", example = "zhangsan")
|
||||
private String username;
|
||||
|
||||
@NotBlank(message = "请填写姓名")
|
||||
@Size(max = 64, message = "姓名过长")
|
||||
@Schema(description = "姓名", example = "张三", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String realName;
|
||||
|
||||
@NotBlank(message = "请填写手机号")
|
||||
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
|
||||
@Schema(description = "手机号(服务端 AES 加密存储,列表只回脱敏值)", example = "13800138888",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "所属组织 ID")
|
||||
private Long orgId;
|
||||
|
||||
@NotEmpty(message = "请至少选择一个角色")
|
||||
@Schema(description = "角色码,至少 1 个。可选值与当前租户有关,见 UserService 的校验",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<String> roleCodes;
|
||||
|
||||
@NotNull(message = "请选择用户类型")
|
||||
@Min(value = 1, message = "用户类型不合法")
|
||||
@Max(value = 4, message = "用户类型不合法")
|
||||
@Schema(description = "1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生(§2.7,必须与所选角色一致)",
|
||||
example = "3", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer userType;
|
||||
|
||||
@Schema(description = "0 停用 / 1 正常 / 2 待激活,默认 1")
|
||||
private Integer status;
|
||||
|
||||
/** 预留给后续的邮箱字段(当前库里没有该列,仅占位避免前端反复问)。 */
|
||||
@Email(message = "邮箱格式不正确")
|
||||
@Schema(description = "邮箱(当前版本未落库,保留字段)", deprecated = true)
|
||||
private String email;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package cn.tianbaotong.fill.module.user.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 账号视图(《03-接口设计》§5.6)。
|
||||
*
|
||||
* <p><b>为什么不返回手机号明文</b>:§1.7 的硬规则是「身份证号与手机号一律不返回明文,
|
||||
* 只返回脱敏值」。列表是一次拉 20~200 条的接口,一旦返回明文就等于给了一个
|
||||
* 批量导出联系方式的通道。审核场景确实要看完整号码时走单独接口并记审计日志。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class UserVO {
|
||||
|
||||
private Long id;
|
||||
|
||||
/** 登录名。学生的可能为空(走微信登录)。 */
|
||||
private String username;
|
||||
|
||||
private String realName;
|
||||
|
||||
/** 脱敏手机号,形如 {@code 138****8888}。 */
|
||||
private String phoneMasked;
|
||||
|
||||
private Long orgId;
|
||||
|
||||
/** 组织名称,由服务端一次装配(避免前端逐个查)。 */
|
||||
private String orgName;
|
||||
|
||||
/**
|
||||
* 用户类型:{@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生
|
||||
* (《03-接口设计》§2.7)。
|
||||
*/
|
||||
private Integer userType;
|
||||
|
||||
/** {@code 0} 停用 / {@code 1} 正常 / {@code 2} 待激活。 */
|
||||
private Integer status;
|
||||
|
||||
/** 角色码,可多角色。 */
|
||||
private List<String> roleCodes;
|
||||
|
||||
/** 角色中文名,与 {@link #roleCodes} 一一对应。 */
|
||||
private List<String> roleNames;
|
||||
|
||||
private LocalDateTime lastLoginTime;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
+10
-1
@@ -53,7 +53,16 @@ public class SysUser extends TenantEntity {
|
||||
|
||||
private String avatarUrl;
|
||||
|
||||
/** {@code 1} 教师或管理员 / {@code 2} 填报人(学生)/ {@code 3} 社会人员。 */
|
||||
/**
|
||||
* 用户类型:{@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生。
|
||||
*
|
||||
* <p>口径来源是《03-接口设计》§2.7,与 {@code BuiltinRole.UserType} 一致。
|
||||
*
|
||||
* <p>⚠️ 库里该列的注释曾经写的是「1教师/管理员 2填报人(学生) 3社会人员」,
|
||||
* 与上面这套<b>不一致</b>,已由 {@code V3__fix_user_type_comment.sql} 更正。
|
||||
* 不要照旧注释或旧文档写代码 —— 这个不一致已经造成过一次真实误判
|
||||
* (把 {@code 3} 当成"社会人员"而拒掉,而它其实是<b>班主任</b>)。
|
||||
*/
|
||||
private Integer userType;
|
||||
|
||||
/** {@code 0} 停用 / {@code 1} 正常 / {@code 2} 待激活。 */
|
||||
|
||||
+47
@@ -54,4 +54,51 @@ public interface UserMapper extends BaseMapper<SysUser> {
|
||||
@Select("SELECT * FROM sys_user WHERE tenant_id = #{tenantId} AND phone_hash = #{phoneHash} AND deleted = 0")
|
||||
List<SysUser> selectByPhoneHash(@Param("tenantId") Long tenantId,
|
||||
@Param("phoneHash") String phoneHash);
|
||||
|
||||
/**
|
||||
* 本租户里<b>除某人之外</b>还有几个可用的管理员账号。
|
||||
*
|
||||
* <p><b>为什么需要</b>:停用/删除最后一个管理员 = 这个租户从此没人能登录、
|
||||
* 没人能再建账号,只能由平台运营手工改库救回来。这类"系统被自己锁死"的
|
||||
* 故障一旦发生,现场没有任何自助手段,所以必须在服务端拦住。
|
||||
*
|
||||
* <p><b>为什么显式传 tenantId 并关掉拦截器</b>:这条 SQL 是「越租户统计会得出错误结论」
|
||||
* 的典型——如果把别校的管理员也算进来,判断就会偏向"还有人管",从而放行一次
|
||||
* 会锁死本租户的停用操作。所以这里把租户条件写成显式参数,
|
||||
* 让「哪个租户」只有一个来源,而不是"默认会加上"。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
SELECT COUNT(DISTINCT u.id)
|
||||
FROM sys_user u
|
||||
JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0 AND ur.tenant_id = u.tenant_id
|
||||
JOIN sys_role r ON r.id = ur.role_id AND r.deleted = 0 AND r.tenant_id = u.tenant_id
|
||||
WHERE u.deleted = 0
|
||||
AND u.status = 1
|
||||
AND u.tenant_id = #{tenantId}
|
||||
AND u.id <> #{excludeUserId}
|
||||
AND r.role_code IN ('PLATFORM_ADMIN', 'SCHOOL_ADMIN')
|
||||
""")
|
||||
Long countOtherAdmins(@Param("tenantId") Long tenantId,
|
||||
@Param("excludeUserId") Long excludeUserId);
|
||||
|
||||
/**
|
||||
* 某人当前是否绑着管理员角色。
|
||||
*
|
||||
* <p>用于判断一次「改角色」是否会让这个人失去管理员身份 ——
|
||||
* 只有"本来是管理员、改完不是了"才需要做最后管理员的检查,
|
||||
* 否则把一个普通班主任改成另一个普通角色也会被拦,那是误伤。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
SELECT COUNT(*)
|
||||
FROM sys_user_role ur
|
||||
JOIN sys_role r ON r.id = ur.role_id AND r.deleted = 0 AND r.tenant_id = ur.tenant_id
|
||||
JOIN sys_user u ON u.id = ur.user_id AND u.deleted = 0 AND u.tenant_id = ur.tenant_id
|
||||
WHERE ur.deleted = 0
|
||||
AND ur.tenant_id = #{tenantId}
|
||||
AND ur.user_id = #{userId}
|
||||
AND r.role_code IN ('PLATFORM_ADMIN', 'SCHOOL_ADMIN')
|
||||
""")
|
||||
Long countAdminRoles(@Param("tenantId") Long tenantId, @Param("userId") Long userId);
|
||||
}
|
||||
|
||||
+997
@@ -0,0 +1,997 @@
|
||||
package cn.tianbaotong.fill.module.user.service;
|
||||
|
||||
import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||
import cn.tianbaotong.fill.common.api.PageResult;
|
||||
import cn.tianbaotong.fill.common.context.LoginUser;
|
||||
import cn.tianbaotong.fill.common.context.UserContext;
|
||||
import cn.tianbaotong.fill.common.exception.BizException;
|
||||
import cn.tianbaotong.fill.common.util.MaskUtil;
|
||||
import cn.tianbaotong.fill.module.auth.service.LoginGuard;
|
||||
import cn.tianbaotong.fill.module.org.entity.SysOrg;
|
||||
import cn.tianbaotong.fill.module.org.mapper.OrgMapper;
|
||||
import cn.tianbaotong.fill.module.user.dto.ResetPasswordReq;
|
||||
import cn.tianbaotong.fill.module.user.dto.UserImportResult;
|
||||
import cn.tianbaotong.fill.module.user.dto.UserUpsertReq;
|
||||
import cn.tianbaotong.fill.module.user.dto.UserVO;
|
||||
import cn.tianbaotong.fill.module.user.entity.SysUser;
|
||||
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
|
||||
import cn.tianbaotong.fill.module.user.mapper.UserRoleMapper;
|
||||
import cn.tianbaotong.fill.security.BuiltinRole;
|
||||
import cn.tianbaotong.fill.support.RoleProvisioner;
|
||||
import cn.tianbaotong.fill.support.SensitiveCodec;
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 账号管理服务(《03-接口设计》§5.6 ~ §5.9)。
|
||||
*
|
||||
* <p><b>本类里有三处不是"字段校验"、而是"防止系统被用坏"的护栏</b>,
|
||||
* 改动时请当成硬约束而不是可协商的规则:
|
||||
* <ol>
|
||||
* <li>{@link #resolveAssignableRoles} —— 拦住「学校管理员给自己人分配平台运营角色」的
|
||||
* 权限提升。不拦的后果是任何学校管理员都能开通租户、看全平台租户列表。</li>
|
||||
* <li>{@link #assertNotSelf} —— 不能停用/删除当前登录的账号。不拦的后果是
|
||||
* 管理员把自己关在门外,且没有自助手段恢复。</li>
|
||||
* <li>{@link #assertAnotherAdminExists} —— 不能取消本单位最后一个管理员。
|
||||
* 生效点是<b>改角色</b>(把自己降级成普通角色),不是停用/删除 —— 原因见方法注释。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserService {
|
||||
|
||||
private static final int MAX_PAGE_SIZE = 200;
|
||||
private static final int MAX_IMPORT_ROWS = 2000;
|
||||
private static final long MAX_IMPORT_BYTES = 10L * 1024 * 1024;
|
||||
|
||||
/** 登录名规则,与 {@code UserUpsertReq.username} 上的注解一致。 */
|
||||
private static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z][A-Za-z0-9_.-]{1,63}$");
|
||||
|
||||
private static final Pattern PHONE_RE = Pattern.compile("^1[3-9]\\d{9}$");
|
||||
|
||||
/** 学校租户可分配的角色。 */
|
||||
private static final Set<String> SCHOOL_ASSIGNABLE = Set.of(
|
||||
"SCHOOL_ADMIN", "HEAD_TEACHER", "DEPUTY_TEACHER", "AUDITOR", "STUDENT");
|
||||
|
||||
/** 平台租户可分配的角色。 */
|
||||
private static final Set<String> PLATFORM_ASSIGNABLE = Set.of("PLATFORM_ADMIN");
|
||||
|
||||
/** 拥有 {@code user:manage} 的角色。判断"还有没有人能管账号"只需看这两个。 */
|
||||
private static final Set<String> ADMIN_ROLE_CODES = Set.of("PLATFORM_ADMIN", "SCHOOL_ADMIN");
|
||||
|
||||
/**
|
||||
* 角色 → 用户类型(《03-接口设计》§2.7,与 {@code BuiltinRole.UserType} 一致)。
|
||||
*
|
||||
* <p>⚠️ <b>不要照 {@code sys_user} 那行 DDL 注释写</b>。那行注释写的是
|
||||
* 「1教师/管理员 2填报人(学生) 3社会人员」,与接口契约的
|
||||
* 「1平台运营 2学校管理员 3班主任 4学生」<b>不一致</b>。
|
||||
* 权威的是接口契约 + {@code BuiltinRole.UserType}(两者一致),DDL 注释是错的
|
||||
* (已在 V3 迁移里更正)。
|
||||
*
|
||||
* <p>这个不一致已经造成过一次真实误判:把 {@code 3} 当成"社会人员(二期)"而拒掉,
|
||||
* 而 {@code 3} 恰恰是<b>班主任</b> —— 最高频的一种账号。
|
||||
*/
|
||||
private static final Map<String, Integer> ROLE_TO_USER_TYPE = Map.of(
|
||||
"PLATFORM_ADMIN", 1,
|
||||
"SCHOOL_ADMIN", 2,
|
||||
"AUDITOR", 2,
|
||||
"HEAD_TEACHER", 3,
|
||||
"DEPUTY_TEACHER", 3,
|
||||
"STUDENT", 4);
|
||||
|
||||
/** 用户类型 → 中文名(用于错误提示)。与 {@link #ROLE_TO_USER_TYPE} 同一套口径。 */
|
||||
private static final Map<Integer, String> USER_TYPE_NAMES = Map.of(
|
||||
1, "平台运营",
|
||||
2, "学校管理员",
|
||||
3, "班主任",
|
||||
4, "学生");
|
||||
|
||||
/**
|
||||
* 导入文件的表头别名。
|
||||
*
|
||||
* <p>实测的真实 Excel 列名写法并不统一(「手机号 / 手机 / 联系电话」都出现过),
|
||||
* 表头所在行也不固定(一份在第 1 行、一份在第 6 行)。所以这里不假设表头位置,
|
||||
* 也不假设列序,而是**按列名认列**,并把识别到的行号回传给界面让用户确认。
|
||||
*/
|
||||
private static final Map<String, Set<String>> HEADER_ALIASES = new LinkedHashMap<>();
|
||||
|
||||
/** 字段 → 中文名,用于导入报告里的列映射展示。 */
|
||||
private static final Map<String, String> IMPORT_FIELD_NAMES = Map.of(
|
||||
"realName", "姓名",
|
||||
"username", "账号",
|
||||
"phone", "手机号",
|
||||
"org", "组织",
|
||||
"role", "角色");
|
||||
|
||||
static {
|
||||
HEADER_ALIASES.put("realName", Set.of("姓名", "名字", "真实姓名", "教师姓名", "学生姓名"));
|
||||
HEADER_ALIASES.put("username", Set.of("账号", "登录账号", "用户名", "登录名", "工号", "教工号"));
|
||||
HEADER_ALIASES.put("phone", Set.of("手机号", "手机号码", "手机", "联系电话", "电话", "联系方式"));
|
||||
HEADER_ALIASES.put("org", Set.of("组织", "部门", "班级", "所属组织", "所属班级", "所在班级"));
|
||||
HEADER_ALIASES.put("role", Set.of("角色", "岗位", "身份"));
|
||||
}
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final UserRoleMapper userRoleMapper;
|
||||
private final OrgMapper orgMapper;
|
||||
private final RoleProvisioner roleProvisioner;
|
||||
private final SensitiveCodec codec;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final LoginGuard loginGuard;
|
||||
|
||||
// ── 列表 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 账号分页列表(§5.6)。
|
||||
*
|
||||
* <p><b>关于按手机号搜索</b>:手机号是 AES-GCM 加密存储的(每次密文都不同,
|
||||
* 且不含原文片段),所以 {@code LIKE '%138%'} 这类<b>模糊</b>搜索在原理上就做不到。
|
||||
* 这里对<b>完整手机号</b>走 {@code phone_hash} 等值匹配——准确、且不牺牲加密。
|
||||
* 代价是"只记得中间几位"时搜不出来,这是加密换来的必然取舍,不是遗漏。
|
||||
*/
|
||||
public PageResult<UserVO> list(String keyword, Long orgId, String roleCode, Integer userType,
|
||||
Integer status, int pageNum, int pageSize) {
|
||||
int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
||||
int num = Math.max(pageNum, 1);
|
||||
|
||||
// 先归一化再声明为 final —— 下面的 lambda 要捕获它,
|
||||
// 直接复用可变的局部变量编译不过(而且"捕获到后来又改过"本身就是 bug 温床)
|
||||
String raw = keyword == null ? null : keyword.trim();
|
||||
final String kw = (raw == null || raw.isEmpty()) ? null : raw;
|
||||
|
||||
LambdaQueryWrapper<SysUser> q = Wrappers.lambdaQuery();
|
||||
q.eq(orgId != null, SysUser::getOrgId, orgId)
|
||||
.eq(userType != null, SysUser::getUserType, userType)
|
||||
.eq(status != null, SysUser::getStatus, status);
|
||||
|
||||
if (kw != null) {
|
||||
if (PHONE_RE.matcher(kw).matches()) {
|
||||
String phoneHash = codec.hash(kw);
|
||||
q.and(w -> w.like(SysUser::getRealName, kw)
|
||||
.or().like(SysUser::getUsername, kw)
|
||||
.or().eq(SysUser::getPhoneHash, phoneHash));
|
||||
} else {
|
||||
q.and(w -> w.like(SysUser::getRealName, kw)
|
||||
.or().like(SysUser::getUsername, kw));
|
||||
}
|
||||
}
|
||||
|
||||
if (roleCode != null && BuiltinRole.ofCode(roleCode) != null) {
|
||||
// roleCode 已经过内置角色白名单校验,拼串是安全的。
|
||||
// 用子查询而不是 JOIN,是为了让 MP 的租户过滤与逻辑删除条件继续作用于主表。
|
||||
q.inSql(SysUser::getId,
|
||||
"SELECT ur.user_id FROM sys_user_role ur"
|
||||
+ " JOIN sys_role r ON r.id = ur.role_id"
|
||||
+ " WHERE ur.deleted = 0 AND r.deleted = 0"
|
||||
+ " AND r.role_code = '" + roleCode + "'");
|
||||
}
|
||||
|
||||
q.orderByDesc(SysUser::getId);
|
||||
|
||||
IPage<SysUser> page = userMapper.selectPage(new Page<>(num, size), q);
|
||||
List<SysUser> rows = page.getRecords();
|
||||
return PageResult.of(toVoList(rows), page.getTotal(), num, size);
|
||||
}
|
||||
|
||||
// ── 新增 ──────────────────────────────────────
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public CreateResult create(UserUpsertReq req) {
|
||||
long tenantId = UserContext.tenantId();
|
||||
|
||||
// 登录名必填只在新增时成立 —— 所以不在 DTO 上用 @NotBlank
|
||||
// (那样会让 PUT /users/{id} 也要求它),见 UserUpsertReq.username 的说明
|
||||
if (req.getUsername() == null || req.getUsername().isBlank()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写登录账号");
|
||||
}
|
||||
String username = req.getUsername().trim();
|
||||
|
||||
assertUsernameUnique(username, null);
|
||||
List<BuiltinRole> roles = resolveAssignableRoles(req.getRoleCodes());
|
||||
assertRoleMatchesUserType(req.getUserType(), roles);
|
||||
|
||||
String initialPassword = MaskUtil.generateInitialPassword();
|
||||
|
||||
SysUser u = new SysUser();
|
||||
u.setUsername(username);
|
||||
u.setRealName(req.getRealName().trim());
|
||||
u.setPassword(passwordEncoder.encode(initialPassword));
|
||||
u.setOrgId(req.getOrgId());
|
||||
u.setUserType(req.getUserType());
|
||||
u.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
||||
codec.applyPhone(u, req.getPhone());
|
||||
userMapper.insert(u);
|
||||
|
||||
for (BuiltinRole br : roles) {
|
||||
roleProvisioner.bindRole(tenantId, u.getId(), br);
|
||||
}
|
||||
|
||||
log.info("新增账号 {}(id={}, 角色={})", username, u.getId(), roles.stream()
|
||||
.map(BuiltinRole::getCode).toList());
|
||||
|
||||
return new CreateResult(u.getId(), initialPassword);
|
||||
}
|
||||
|
||||
// ── 修改 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 修改账号(§5.9)。
|
||||
*
|
||||
* <p>{@code username} 与 {@code password} <b>不在这里改</b>:
|
||||
* 前者改了会让历史通知、导出包里的账号全对不上;后者走单独的重置密码接口,
|
||||
* 免得"编辑资料"顺手把密码也换了却没人知道。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void update(Long id, UserUpsertReq req) {
|
||||
SysUser exist = requireUser(id);
|
||||
long tenantId = UserContext.tenantId();
|
||||
|
||||
List<BuiltinRole> roles = resolveAssignableRoles(req.getRoleCodes());
|
||||
assertRoleMatchesUserType(req.getUserType(), roles);
|
||||
|
||||
// 停用会被改成停用时,先过一遍"别把自己/最后一个管理员锁死"
|
||||
boolean turningOff = req.getStatus() != null && req.getStatus() == 0
|
||||
&& (exist.getStatus() == null || exist.getStatus() != 0);
|
||||
if (turningOff) {
|
||||
assertNotSelf(id, "停用");
|
||||
assertAnotherAdminExists(exist, "停用");
|
||||
}
|
||||
|
||||
// 「把管理员改成普通角色」是锁死租户的另一条路,而且比停用更隐蔽:
|
||||
// 账号还在、还能登录,但再也进不了账号管理与角色授权,界面上看不出问题。
|
||||
//
|
||||
// ⚠️ 这条**必须**单独判,不能指望上面那条覆盖它:
|
||||
// 能调到本接口的人自己必然是活跃管理员,所以在"停用"这条路径上
|
||||
// 「除目标以外还有管理员」恒成立 —— 那次检查永远不触发,是死代码。
|
||||
// 而改角色这条路径上,目标可以是操作者自己,那就真的会锁死。
|
||||
if (losingAdminRole(exist, roles)) {
|
||||
assertAnotherAdminExists(exist, "取消管理员角色");
|
||||
}
|
||||
|
||||
SysUser patch = new SysUser();
|
||||
patch.setId(id);
|
||||
patch.setRealName(req.getRealName().trim());
|
||||
patch.setOrgId(req.getOrgId());
|
||||
patch.setUserType(req.getUserType());
|
||||
if (req.getStatus() != null) {
|
||||
patch.setStatus(req.getStatus());
|
||||
}
|
||||
codec.applyPhone(patch, req.getPhone());
|
||||
userMapper.updateById(patch);
|
||||
|
||||
rebindRoles(tenantId, id, roles);
|
||||
}
|
||||
|
||||
// ── 停用(软删)──────────────────────────────
|
||||
|
||||
/**
|
||||
* 停用账号(§5.9)。
|
||||
*
|
||||
* <p>按契约这是<b>软删</b>({@code deleted=1})。软删之后这条记录对本租户就"消失"了,
|
||||
* 界面上的「启用」走 {@link #updateStatus}(改 {@code status}),两者不是一回事:
|
||||
* <ul>
|
||||
* <li>改 {@code status=0} —— 账号还在列表里,可以再启用。日常「某人离职」用这个。</li>
|
||||
* <li>软删 —— 从列表里消失,用于建错了、重复导入这类要清理的场景。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long id) {
|
||||
SysUser exist = requireUser(id);
|
||||
|
||||
assertNotSelf(id, "停用");
|
||||
assertAnotherAdminExists(exist, "停用");
|
||||
|
||||
if (hasActiveTask(id)) {
|
||||
throw new BizException(ErrorCode.USER_HAS_ACTIVE_TASK,
|
||||
"该账号还负责着在办的收集任务,请先把任务移交他人");
|
||||
}
|
||||
|
||||
userMapper.deleteById(id);
|
||||
// 角色关联一并软删,否则重新建同名账号时会继承上一个人的角色
|
||||
userRoleMapper.delete(Wrappers.<cn.tianbaotong.fill.module.user.entity.SysUserRole>lambdaQuery()
|
||||
.eq(cn.tianbaotong.fill.module.user.entity.SysUserRole::getUserId, id));
|
||||
}
|
||||
|
||||
// ── 启用 / 停用(改 status,不删数据)──────────
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateStatus(Long id, Integer status) {
|
||||
SysUser exist = requireUser(id);
|
||||
if (status == null || (status != 0 && status != 1)) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "状态只能是 0 停用或 1 正常");
|
||||
}
|
||||
if (status == 0) {
|
||||
assertNotSelf(id, "停用");
|
||||
assertAnotherAdminExists(exist, "停用");
|
||||
}
|
||||
|
||||
SysUser patch = new SysUser();
|
||||
patch.setId(id);
|
||||
patch.setStatus(status);
|
||||
userMapper.updateById(patch);
|
||||
}
|
||||
|
||||
// ── 重置密码 ──────────────────────────────────
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ResetResult resetPassword(Long id, ResetPasswordReq req) {
|
||||
SysUser target = requireUser(id);
|
||||
|
||||
String plain;
|
||||
if (req.isCustom()) {
|
||||
plain = req.getNewPassword();
|
||||
assertPasswordStrength(plain);
|
||||
} else {
|
||||
plain = MaskUtil.generateInitialPassword();
|
||||
}
|
||||
|
||||
SysUser patch = new SysUser();
|
||||
patch.setId(id);
|
||||
patch.setPassword(passwordEncoder.encode(plain));
|
||||
userMapper.updateById(patch);
|
||||
|
||||
// 重置密码的真实场景基本都是"本人忘了密码、反复试错把账号试锁了"。
|
||||
// 不在这里解锁的话会出现「管理员刚重置完,本人还是登不进去」,
|
||||
// 而且两边都看不出原因 —— 锁定是按账号计的,跟密码对不对没关系。
|
||||
if (target.getUsername() != null && !target.getUsername().isBlank()) {
|
||||
loginGuard.clear(target.getUsername());
|
||||
}
|
||||
|
||||
log.info("重置账号 {}(id={})的密码", target.getUsername(), id);
|
||||
return new ResetResult(target.getUsername(), plain);
|
||||
}
|
||||
|
||||
// ── 批量导入(§5.8)────────────────────────────
|
||||
|
||||
/**
|
||||
* 批量导入账号,返回「体检报告」。
|
||||
*
|
||||
* <p>部分成功是<b>设计目标</b>:42 行里有 2 行手机号写错,不应该让另外 40 个人白等,
|
||||
* 但也不能悄悄丢掉那 2 行。所以整体不抛异常(能进的全进),
|
||||
* 逐行把「行号 + 字段 + 原值 + 原因」回传,由界面上的人去修。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public UserImportResult importExcel(MultipartFile file, String defaultRoleCode) {
|
||||
assertImportFile(file);
|
||||
|
||||
BuiltinRole fallbackRole = resolveFallbackRole(defaultRoleCode);
|
||||
|
||||
List<Map<Integer, String>> rows;
|
||||
try (InputStream in = file.getInputStream()) {
|
||||
// headRowNumber(0):不把第 1 行当表头,先整表读进来,表头行由我们自己找。
|
||||
// 实测两份真实文件的表头分别在第 1 行和第 6 行,"默认第 1 行"是行不通的。
|
||||
rows = EasyExcel.read(in).sheet(0).headRowNumber(0).doReadSync();
|
||||
} catch (Exception e) {
|
||||
log.warn("读取导入文件失败:{}", e.getMessage());
|
||||
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND,
|
||||
"无法读取该 Excel,请用 Excel 另存为 .xlsx 后重试");
|
||||
}
|
||||
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND, "文件里没有读到任何内容");
|
||||
}
|
||||
|
||||
int headerIdx = detectHeaderRow(rows);
|
||||
if (headerIdx < 0) {
|
||||
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND,
|
||||
"没能找到表头行,请确认表格里有「姓名」和「账号」这两列");
|
||||
}
|
||||
Map<Integer, String> headerCells = rows.get(headerIdx);
|
||||
Map<String, Integer> colOf = mapColumns(headerCells);
|
||||
int headerRow = headerIdx + 1;
|
||||
|
||||
UserImportResult result = new UserImportResult();
|
||||
result.setHeaderRow(headerRow);
|
||||
// 行号 + 原文 + 列映射三样一起回,用户才能真的核对"系统认对了吗"。
|
||||
// 只回行号的话,界面上只能显示"表头在第 6 行",而用户无法判断
|
||||
// 第 6 行被认成了什么、有没有整列错位。
|
||||
result.setHeaderText(joinHeaderText(headerCells));
|
||||
result.setColumns(buildColumnMap(colOf));
|
||||
|
||||
// 预加载组织与已有账号,避免逐行查库(2000 行 × 2 次查询是不可接受的)
|
||||
Map<String, Long> orgByName = new HashMap<>();
|
||||
Map<String, Long> orgByCode = new HashMap<>();
|
||||
for (SysOrg o : orgMapper.selectList(Wrappers.<SysOrg>lambdaQuery())) {
|
||||
if (o.getOrgName() != null) {
|
||||
orgByName.putIfAbsent(o.getOrgName().trim(), o.getId());
|
||||
}
|
||||
if (o.getOrgCode() != null && !o.getOrgCode().isBlank()) {
|
||||
orgByCode.putIfAbsent(o.getOrgCode().trim(), o.getId());
|
||||
}
|
||||
}
|
||||
|
||||
List<String> dataNames = new ArrayList<>();
|
||||
for (int i = headerIdx + 1; i < rows.size() && i - headerIdx <= MAX_IMPORT_ROWS; i++) {
|
||||
String uname = cell(rows.get(i), colOf.get("username"));
|
||||
if (uname != null && !uname.isBlank()) {
|
||||
dataNames.add(uname.trim());
|
||||
}
|
||||
}
|
||||
Set<String> existingNames = new LinkedHashSet<>();
|
||||
if (!dataNames.isEmpty()) {
|
||||
for (SysUser u : userMapper.selectList(Wrappers.<SysUser>lambdaQuery()
|
||||
.in(SysUser::getUsername, dataNames))) {
|
||||
existingNames.add(u.getUsername());
|
||||
}
|
||||
}
|
||||
|
||||
long tenantId = UserContext.tenantId();
|
||||
Set<String> seenInFile = new LinkedHashSet<>();
|
||||
|
||||
for (int i = headerIdx + 1; i < rows.size(); i++) {
|
||||
Map<Integer, String> row = rows.get(i);
|
||||
int excelRow = i + 1; // Excel 的行号从 1 开始,与用户看到的一致
|
||||
if (isBlankRow(row)) {
|
||||
continue;
|
||||
}
|
||||
result.setTotal(result.getTotal() + 1);
|
||||
|
||||
String realName = cell(row, colOf.get("realName"));
|
||||
String username = cell(row, colOf.get("username"));
|
||||
String phone = normalizePhone(cell(row, colOf.get("phone")));
|
||||
String orgText = cell(row, colOf.get("org"));
|
||||
String roleText = cell(row, colOf.get("role"));
|
||||
|
||||
UserImportResult.RowError err =
|
||||
validateImportRow(excelRow, realName, username, phone, seenInFile, existingNames);
|
||||
if (err == null && orgText != null && !orgText.isBlank()) {
|
||||
Long orgId = orgByName.get(orgText.trim());
|
||||
if (orgId == null) {
|
||||
orgId = orgByCode.get(orgText.trim());
|
||||
}
|
||||
if (orgId == null) {
|
||||
err = new UserImportResult.RowError(excelRow, "org", orgText, "找不到该组织");
|
||||
}
|
||||
}
|
||||
|
||||
BuiltinRole role = fallbackRole;
|
||||
if (err == null && roleText != null && !roleText.isBlank()) {
|
||||
role = resolveRoleByText(roleText);
|
||||
if (role == null) {
|
||||
err = new UserImportResult.RowError(excelRow, "role", roleText, "角色不存在或不可分配");
|
||||
}
|
||||
}
|
||||
// 用户类型从角色推导(二者一对一绑定)—— 导入文件里没有这一列,
|
||||
// 也不该要求用户去填:填错就是整行白导。
|
||||
Integer userType = role == null ? null : ROLE_TO_USER_TYPE.get(role.getCode());
|
||||
if (err == null && userType == null) {
|
||||
err = new UserImportResult.RowError(excelRow, "role", roleText,
|
||||
"该角色没有对应的用户类型");
|
||||
}
|
||||
|
||||
if (err != null) {
|
||||
result.setFailed(result.getFailed() + 1);
|
||||
result.getErrors().add(err);
|
||||
continue;
|
||||
}
|
||||
|
||||
String initialPassword = MaskUtil.generateInitialPassword();
|
||||
SysUser u = new SysUser();
|
||||
u.setUsername(username.trim());
|
||||
u.setRealName(realName.trim());
|
||||
u.setPassword(passwordEncoder.encode(initialPassword));
|
||||
u.setUserType(userType);
|
||||
u.setStatus(1);
|
||||
codec.applyPhone(u, phone);
|
||||
if (orgText != null && !orgText.isBlank()) {
|
||||
Long orgId = orgByName.get(orgText.trim());
|
||||
u.setOrgId(orgId != null ? orgId : orgByCode.get(orgText.trim()));
|
||||
}
|
||||
userMapper.insert(u);
|
||||
roleProvisioner.bindRole(tenantId, u.getId(), role);
|
||||
|
||||
seenInFile.add(username.trim());
|
||||
result.setSuccess(result.getSuccess() + 1);
|
||||
result.getInitialPasswords()
|
||||
.add(new UserImportResult.InitialPassword(username.trim(), initialPassword));
|
||||
}
|
||||
|
||||
log.info("账号批量导入:共 {} 行,成功 {},失败 {},表头在第 {} 行",
|
||||
result.getTotal(), result.getSuccess(), result.getFailed(), result.getHeaderRow());
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 护栏 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 校验并解析可分配的角色。
|
||||
*
|
||||
* <p>⚠️ <b>这里是权限提升的防线,不是普通的字段校验。</b>
|
||||
*
|
||||
* <p>开通租户时 {@code RoleProvisioner} 会为租户发放全部内置角色,
|
||||
* 其中包含 {@code PLATFORM_ADMIN}。而 {@code /tenants} 这类平台接口
|
||||
* 只校验权限码 {@code tenant:manage},<b>不会</b>再确认调用者是否真的在平台租户下。
|
||||
* 于是:如果这里不拦,学校管理员只要在新增账号时传
|
||||
* {@code roleCodes: ["PLATFORM_ADMIN"]},就能造出一个带 {@code tenant:manage} 的账号,
|
||||
* 进而开通租户、拉取全平台租户列表 —— 一个租户级管理员直接越权到平台级。
|
||||
*
|
||||
* <p>两道防线:本方法按"当前租户能分配什么角色"白名单校验;
|
||||
* 同时 {@code RoleProvisioner} 不再给学校租户发放 {@code PLATFORM_ADMIN} 角色行。
|
||||
* 任一道单独都够用,但都留着 —— 这类错误一旦发生是不可逆的信息泄露。
|
||||
*/
|
||||
private List<BuiltinRole> resolveAssignableRoles(List<String> codes) {
|
||||
Long tenantId = UserContext.tenantId();
|
||||
boolean platform = tenantId != null && tenantId == LoginUser.PLATFORM_TENANT_ID;
|
||||
Set<String> allowed = platform ? PLATFORM_ASSIGNABLE : SCHOOL_ASSIGNABLE;
|
||||
|
||||
LinkedHashSet<String> distinct = new LinkedHashSet<>();
|
||||
if (codes != null) {
|
||||
for (String c : codes) {
|
||||
if (c != null && !c.isBlank()) {
|
||||
distinct.add(c.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (distinct.isEmpty()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请至少选择一个角色");
|
||||
}
|
||||
|
||||
List<BuiltinRole> roles = new ArrayList<>();
|
||||
for (String c : distinct) {
|
||||
BuiltinRole br = BuiltinRole.ofCode(c);
|
||||
if (br == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色不存在:" + c);
|
||||
}
|
||||
if (!allowed.contains(c)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
|
||||
}
|
||||
roles.add(br);
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户类型必须与所选角色自洽。
|
||||
*
|
||||
* <p>做法不是"逐条 if",而是<b>从角色反推用户类型再比对</b>:
|
||||
* 角色与用户类型本来就是一对一绑定的(见 {@link #ROLE_TO_USER_TYPE}),
|
||||
* 反过来推只有一个正确答案,写成一堆 if 迟早会漏掉某个组合。
|
||||
*
|
||||
* <p>多角色时要求它们属于同一类(比如「班主任 + 副班主任」都是 3 可以,
|
||||
* 「班主任 + 审核员」是 3 与 2 就不能共存)—— 否则"这个人到底是几"没有答案。
|
||||
*/
|
||||
private void assertRoleMatchesUserType(Integer userType, List<BuiltinRole> roles) {
|
||||
if (userType == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择用户类型");
|
||||
}
|
||||
|
||||
Set<Integer> expected = new LinkedHashSet<>();
|
||||
for (BuiltinRole br : roles) {
|
||||
Integer t = ROLE_TO_USER_TYPE.get(br.getCode());
|
||||
if (t != null) {
|
||||
expected.add(t);
|
||||
}
|
||||
}
|
||||
if (expected.isEmpty()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "所选角色没有对应的用户类型");
|
||||
}
|
||||
if (expected.size() > 1) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Integer t : expected) {
|
||||
names.add(USER_TYPE_NAMES.getOrDefault(t, String.valueOf(t)));
|
||||
}
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"所选角色分属不同的用户类型(" + String.join("、", names)
|
||||
+ "),请只选同一类的角色");
|
||||
}
|
||||
|
||||
int want = expected.iterator().next();
|
||||
if (want != userType) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"用户类型与所选角色不符:这些角色对应的用户类型是「"
|
||||
+ USER_TYPE_NAMES.getOrDefault(want, String.valueOf(want)) + "」");
|
||||
}
|
||||
}
|
||||
|
||||
private void assertUsernameUnique(String username, Long excludeId) {
|
||||
SysUser dup = userMapper.selectOne(Wrappers.<SysUser>lambdaQuery()
|
||||
.eq(SysUser::getUsername, username)
|
||||
.ne(excludeId != null, SysUser::getId, excludeId)
|
||||
.last("LIMIT 1"));
|
||||
if (dup != null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "登录账号已存在:" + username);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertNotSelf(Long id, String action) {
|
||||
if (Objects.equals(id, UserContext.uid())) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"不能" + action + "当前登录的账号,请换一个管理员账号操作");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「让这个人不再是管理员」之后,本单位是否还有别的可用管理员。
|
||||
*
|
||||
* <p><b>在哪些路径上会真正生效</b>(这一点很容易误判):
|
||||
* <ul>
|
||||
* <li>改角色({@link #update} 里把管理员改成普通角色)—— <b>会生效</b>。
|
||||
* 因为目标可以是操作者自己,此时"除目标以外的管理员"就是 0。</li>
|
||||
* <li>停用 / 软删 —— <b>实际上永远不触发</b>。能调这两个接口的人,
|
||||
* 自己必然是一个活跃管理员({@code user:manage} 只来自
|
||||
* {@code SCHOOL_ADMIN} / {@code PLATFORM_ADMIN}),
|
||||
* 所以"除目标以外还有管理员"里至少能数到操作者自己,恒 ≥ 1。
|
||||
* 更根本的原因是同一路径上已有 {@link #assertNotSelf}:不能停用自己。</li>
|
||||
* </ul>
|
||||
* 两份检查都留着 —— 权限模型一旦放宽(比如允许"审核员"停用账号),
|
||||
* 停用路径上的这次检查立刻就有了意义。但<b>不要以为停用路径被它保护着</b>。
|
||||
*/
|
||||
private void assertAnotherAdminExists(SysUser target, String action) {
|
||||
if (target.getStatus() != null && target.getStatus() != 1) {
|
||||
// 本来就是停用状态,谈不上"从这个状态掉下去"
|
||||
return;
|
||||
}
|
||||
Long other = userMapper.countOtherAdmins(target.getTenantId(), target.getId());
|
||||
if (other == null || other == 0) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"「" + target.getRealName() + "」是本单位唯一的管理员,"
|
||||
+ action + "后就没人能再管理本单位的账号了,请先另建一个管理员账号");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这次改角色是否会让目标失去全部管理员角色。
|
||||
*
|
||||
* <p>只有在目标<b>原本确实是管理员</b>时才返回 true ——
|
||||
* 否则把一个普通班主任换成另一个普通角色也会被上面那次检查拦住,那是误伤。
|
||||
*/
|
||||
private boolean losingAdminRole(SysUser target, List<BuiltinRole> newRoles) {
|
||||
boolean stillAdmin = newRoles.stream()
|
||||
.anyMatch(r -> ADMIN_ROLE_CODES.contains(r.getCode()));
|
||||
if (stillAdmin) {
|
||||
return false;
|
||||
}
|
||||
Long now = userMapper.countAdminRoles(target.getTenantId(), target.getId());
|
||||
return now != null && now > 0;
|
||||
}
|
||||
|
||||
private void assertPasswordStrength(String plain) {
|
||||
cn.tianbaotong.fill.common.util.MaskUtil.assertPasswordStrong(plain);
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号是否还负责着在办任务。
|
||||
*
|
||||
* <p>⚠️ <b>M0 阶段恒为 false —— 这条约束还没生效。</b>
|
||||
* {@code collect_task} / {@code collect_task_org} 是 M1 的表,且当前没有
|
||||
* 「任务负责人」字段,所以这里没有任何可查的依据。
|
||||
* <b>M1 接入任务后必须补上实现</b>,否则会出现「把还在带班的班主任停用了,
|
||||
* 任务卡在半路没人能审」这种线上问题,而错误码 {@code 40014} 与界面提示
|
||||
* 都已经按"会拦住"写好了 —— 那会让人以为拦住了。
|
||||
*/
|
||||
private boolean hasActiveTask(Long userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
private SysUser requireUser(Long id) {
|
||||
SysUser u = userMapper.selectById(id);
|
||||
if (u == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "账号不存在或已被停用");
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
private void rebindRoles(long tenantId, Long userId, List<BuiltinRole> roles) {
|
||||
userRoleMapper.delete(Wrappers.<cn.tianbaotong.fill.module.user.entity.SysUserRole>lambdaQuery()
|
||||
.eq(cn.tianbaotong.fill.module.user.entity.SysUserRole::getUserId, userId));
|
||||
for (BuiltinRole br : roles) {
|
||||
roleProvisioner.bindRole(tenantId, userId, br);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 导入的内部工具 ─────────────────────────────
|
||||
|
||||
private void assertImportFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择要导入的 Excel 文件");
|
||||
}
|
||||
if (file.getSize() > MAX_IMPORT_BYTES) {
|
||||
throw new BizException(ErrorCode.FILE_TOO_LARGE,
|
||||
"文件超过 10MB,请拆分后分批导入");
|
||||
}
|
||||
String name = file.getOriginalFilename();
|
||||
String lower = name == null ? "" : name.toLowerCase();
|
||||
if (!lower.endsWith(".xlsx") && !lower.endsWith(".xls")) {
|
||||
throw new BizException(ErrorCode.FILE_FORMAT_NOT_ALLOWED, "只支持 .xlsx / .xls 文件");
|
||||
}
|
||||
}
|
||||
|
||||
private BuiltinRole resolveFallbackRole(String defaultRoleCode) {
|
||||
BuiltinRole br = defaultRoleCode == null || defaultRoleCode.isBlank()
|
||||
? BuiltinRole.HEAD_TEACHER
|
||||
: BuiltinRole.ofCode(defaultRoleCode.trim());
|
||||
if (br == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "默认角色不存在:" + defaultRoleCode);
|
||||
}
|
||||
if (resolveAssignableRoles(List.of(br.getCode())).isEmpty()) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
|
||||
}
|
||||
return br;
|
||||
}
|
||||
|
||||
/**
|
||||
* 找表头行。
|
||||
*
|
||||
* <p>打分而不是"找第 1 行":取命中别名最多的一行,且必须命中「姓名」或「账号」之一、
|
||||
* 总命中数不少于 2。这样即使表格前面有标题、说明、空行,也能正确定位。
|
||||
*
|
||||
* @return 0 基的行下标;找不到返回 -1
|
||||
*/
|
||||
private int detectHeaderRow(List<Map<Integer, String>> rows) {
|
||||
int best = -1;
|
||||
int bestScore = 0;
|
||||
int scan = Math.min(rows.size(), 20); // 表头不可能在第 20 行之后
|
||||
for (int i = 0; i < scan; i++) {
|
||||
Map<String, Integer> cols = mapColumns(rows.get(i));
|
||||
int score = cols.size();
|
||||
boolean hasKey = cols.containsKey("realName") || cols.containsKey("username");
|
||||
if (hasKey && score >= 2 && score > bestScore) {
|
||||
best = i;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 把一行按列名别名映射成「字段 → 列下标」。 */
|
||||
private Map<String, Integer> mapColumns(Map<Integer, String> headerRow) {
|
||||
Map<String, Integer> colOf = new LinkedHashMap<>();
|
||||
if (headerRow == null) {
|
||||
return colOf;
|
||||
}
|
||||
List<Map.Entry<Integer, String>> cells = new ArrayList<>(headerRow.entrySet());
|
||||
cells.sort(Comparator.comparing(Map.Entry::getKey));
|
||||
for (Map.Entry<Integer, String> e : cells) {
|
||||
String text = e.getValue() == null ? "" : e.getValue().trim();
|
||||
if (text.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (Map.Entry<String, Set<String>> alias : HEADER_ALIASES.entrySet()) {
|
||||
if (colOf.containsKey(alias.getKey())) {
|
||||
continue;
|
||||
}
|
||||
if (alias.getValue().contains(text)) {
|
||||
colOf.put(alias.getKey(), e.getKey());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return colOf;
|
||||
}
|
||||
|
||||
private UserImportResult.RowError validateImportRow(int excelRow, String realName, String username,
|
||||
String phone, Set<String> seenInFile,
|
||||
Set<String> existingNames) {
|
||||
if (realName == null || realName.isBlank()) {
|
||||
return new UserImportResult.RowError(excelRow, "realName", realName, "姓名为空");
|
||||
}
|
||||
if (username == null || username.isBlank()) {
|
||||
return new UserImportResult.RowError(excelRow, "username", username, "账号为空");
|
||||
}
|
||||
String uname = username.trim();
|
||||
if (!USERNAME_RE.matcher(uname).matches()) {
|
||||
return new UserImportResult.RowError(excelRow, "username", uname,
|
||||
"账号只能以字母开头,可含字母、数字、下划线、点或短横线");
|
||||
}
|
||||
if (seenInFile.contains(uname)) {
|
||||
return new UserImportResult.RowError(excelRow, "username", uname, "文件内该账号重复");
|
||||
}
|
||||
if (existingNames.contains(uname)) {
|
||||
return new UserImportResult.RowError(excelRow, "username", uname, "账号已存在");
|
||||
}
|
||||
if (phone == null || !PHONE_RE.matcher(phone).matches()) {
|
||||
return new UserImportResult.RowError(excelRow, "phone", phone, "手机号格式不正确");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 去掉手机号里常见的空格、短横线,Excel 里存成数字时还会带 .0。 */
|
||||
private static String normalizePhone(String raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String s = raw.trim().replace(" ", "").replace("-", "");
|
||||
if (s.endsWith(".0")) {
|
||||
s = s.substring(0, s.length() - 2);
|
||||
}
|
||||
return s.isEmpty() ? null : s;
|
||||
}
|
||||
|
||||
/** 角色列既可能写中文名(「班主任」)也可能写角色码(HEAD_TEACHER),两种都认。 */
|
||||
private BuiltinRole resolveRoleByText(String text) {
|
||||
String t = text.trim();
|
||||
BuiltinRole byCode = BuiltinRole.ofCode(t.toUpperCase());
|
||||
if (byCode != null) {
|
||||
try {
|
||||
resolveAssignableRoles(List.of(byCode.getCode()));
|
||||
return byCode;
|
||||
} catch (BizException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
for (BuiltinRole br : BuiltinRole.all()) {
|
||||
if (br.getName().equals(t)) {
|
||||
try {
|
||||
resolveAssignableRoles(List.of(br.getCode()));
|
||||
return br;
|
||||
} catch (BizException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 表头原文,用 | 连接,供界面展示确认。 */
|
||||
private static String joinHeaderText(Map<Integer, String> headerCells) {
|
||||
if (headerCells == null || headerCells.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
headerCells.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.filter(e -> e.getValue() != null && !e.getValue().isBlank())
|
||||
.forEach(e -> {
|
||||
if (!sb.isEmpty()) {
|
||||
sb.append(" | ");
|
||||
}
|
||||
sb.append(e.getValue().trim());
|
||||
});
|
||||
String s = sb.toString();
|
||||
return s.length() > 300 ? s.substring(0, 300) + "…" : s;
|
||||
}
|
||||
|
||||
/** 字段 → 列字母,供界面核对"系统把哪一列当成了什么"。 */
|
||||
private static List<UserImportResult.ColumnMap> buildColumnMap(Map<String, Integer> colOf) {
|
||||
List<UserImportResult.ColumnMap> list = new ArrayList<>();
|
||||
for (String field : HEADER_ALIASES.keySet()) {
|
||||
Integer idx = colOf.get(field);
|
||||
if (idx != null) {
|
||||
list.add(new UserImportResult.ColumnMap(field,
|
||||
IMPORT_FIELD_NAMES.getOrDefault(field, field),
|
||||
columnLetter(idx)));
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 0 → A、25 → Z、26 → AA。 */
|
||||
private static String columnLetter(int index) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int n = index;
|
||||
while (n >= 0) {
|
||||
sb.insert(0, (char) ('A' + n % 26));
|
||||
n = n / 26 - 1;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String cell(Map<Integer, String> row, Integer col) { if (row == null || col == null) {
|
||||
return null;
|
||||
}
|
||||
String v = row.get(col);
|
||||
return v == null ? null : v.trim();
|
||||
}
|
||||
|
||||
private static boolean isBlankRow(Map<Integer, String> row) {
|
||||
if (row == null || row.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (String v : row.values()) {
|
||||
if (v != null && !v.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── VO 装配 ───────────────────────────────────
|
||||
|
||||
private List<UserVO> toVoList(List<SysUser> users) {
|
||||
if (users == null || users.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<Long> userIds = users.stream().map(SysUser::getId).toList();
|
||||
|
||||
List<Long> orgIds = users.stream().map(SysUser::getOrgId).filter(Objects::nonNull).distinct().toList();
|
||||
Map<Long, String> orgNames = new HashMap<>();
|
||||
if (!orgIds.isEmpty()) {
|
||||
for (SysOrg o : orgMapper.selectByIds(orgIds)) {
|
||||
orgNames.put(o.getId(), o.getOrgName());
|
||||
}
|
||||
}
|
||||
|
||||
Map<Long, List<String>> rolesByUser = new HashMap<>();
|
||||
for (UserRoleMapper.UserRoleRow row : userRoleMapper.selectRoleCodesByUserIds(userIds)) {
|
||||
if (row.getUserId() != null && row.getRoleCode() != null) {
|
||||
rolesByUser.computeIfAbsent(row.getUserId(), k -> new ArrayList<>()).add(row.getRoleCode());
|
||||
}
|
||||
}
|
||||
|
||||
List<UserVO> list = new ArrayList<>(users.size());
|
||||
for (SysUser u : users) {
|
||||
UserVO vo = new UserVO();
|
||||
vo.setId(u.getId());
|
||||
vo.setUsername(u.getUsername());
|
||||
vo.setRealName(u.getRealName());
|
||||
vo.setPhoneMasked(MaskUtil.maskPhone(codec.plainPhone(u)));
|
||||
vo.setOrgId(u.getOrgId());
|
||||
vo.setOrgName(u.getOrgId() == null ? null : orgNames.get(u.getOrgId()));
|
||||
vo.setUserType(u.getUserType());
|
||||
vo.setStatus(u.getStatus());
|
||||
vo.setLastLoginTime(u.getLastLoginTime());
|
||||
vo.setCreateTime(u.getCreateTime());
|
||||
|
||||
List<String> codes = rolesByUser.getOrDefault(u.getId(), List.of());
|
||||
vo.setRoleCodes(codes);
|
||||
// 一期角色全部内置(没有自定义角色的界面),名字直接从代码取;
|
||||
// 二期支持自定义角色后,这里要改成从 sys_role.role_name 读。
|
||||
vo.setRoleNames(codes.stream()
|
||||
.map(c -> {
|
||||
BuiltinRole br = BuiltinRole.ofCode(c);
|
||||
return br == null ? c : br.getName();
|
||||
})
|
||||
.toList());
|
||||
list.add(vo);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// ── 返回值 ────────────────────────────────────
|
||||
|
||||
/** 新增结果。{@code initialPassword} 只在这里返回一次。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CreateResult {
|
||||
|
||||
private Long id;
|
||||
|
||||
/** 初始密码,<b>仅此一次返回</b>;库里只存 BCrypt 哈希,之后无法还原。 */
|
||||
private String initialPassword;
|
||||
|
||||
public CreateResult(Long id, String initialPassword) {
|
||||
this.id = id;
|
||||
this.initialPassword = initialPassword;
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置密码结果。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class ResetResult {
|
||||
|
||||
private String username;
|
||||
|
||||
private String newPassword;
|
||||
|
||||
public ResetResult(String username, String newPassword) {
|
||||
this.username = username;
|
||||
this.newPassword = newPassword;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,20 @@ public class RoleProvisioner {
|
||||
|
||||
/** 幂等:已存在的角色跳过。 */
|
||||
public void provisionBuiltinRoles(long tenantId) {
|
||||
// ⚠️ 平台运营角色只发给平台租户(tenant_id=0)。
|
||||
// 以前这里是「所有租户都发全部内置角色」,看起来更"整齐",
|
||||
// 但结果是每个学校租户的 sys_role 里都躺着一行 PLATFORM_ADMIN。
|
||||
// 只要有一个入口能把它绑到账号上(比如新增账号时传 roleCodes),
|
||||
// 学校管理员就能给自己人造出带 tenant:manage 的账号 —— 因为
|
||||
// /tenants 只校验权限码、不校验"是否真的在平台租户下"。
|
||||
// 真正的防线在 UserService.resolveAssignableRoles,这里同时把角色行也去掉:
|
||||
// 列表里根本不存在的角色,比"存在但被禁止使用"更难被绕过。
|
||||
boolean platformTenant = tenantId == cn.tianbaotong.fill.common.context.LoginUser.PLATFORM_TENANT_ID;
|
||||
|
||||
for (BuiltinRole br : BuiltinRole.all()) {
|
||||
if (!platformTenant && br == BuiltinRole.PLATFORM_ADMIN) {
|
||||
continue;
|
||||
}
|
||||
if (findRole(tenantId, br.getCode()) != null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- ============================================================================
|
||||
-- 更正 sys_user.user_type 的列注释
|
||||
-- ============================================================================
|
||||
--
|
||||
-- 背景:V1 建 sys_user 时把 user_type 的注释写成了
|
||||
-- '1教师/管理员 2填报人(学生) 3社会人员'
|
||||
-- 但**权威口径是《03-接口设计》§2.7**(与代码里的 BuiltinRole.UserType 一致):
|
||||
-- 1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生
|
||||
--
|
||||
-- 这不是"措辞略不同":两套口径里 2 和 3 的含义完全错位
|
||||
-- (旧注释的 2=学生,新口径的 2=学校管理员;旧注释的 3=社会人员,新口径的 3=班主任)。
|
||||
--
|
||||
-- 已经造成过一次真实误判:按旧注释把 user_type=3 当成"社会人员(一期不做)"直接拒绝,
|
||||
-- 而它其实是**班主任** —— 账号管理里最高频的一种。
|
||||
--
|
||||
-- 为什么不直接改 V1:Flyway 会校验已应用迁移的 checksum,
|
||||
-- 改 V1 会让所有已有环境的启动直接失败。所以用一条新迁移覆盖注释。
|
||||
--
|
||||
-- 注意:这只是注释变更,不动数据、不动结构,纯 DDL 元信息,可安全重复执行。
|
||||
-- ============================================================================
|
||||
|
||||
COMMENT ON COLUMN sys_user.user_type IS '1平台运营 2学校管理员 3班主任 4学生(口径见《03-接口设计》§2.7,与 BuiltinRole.UserType 一致)';
|
||||
Reference in New Issue
Block a user